From 49d942014a649be0ea3208765a66853a77169cf5 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Sun, 23 Aug 2026 16:11:17 +0200 Subject: [PATCH 01/12] chore(release): harden CI and release publication --- .github/dependabot.yml | 1 + .github/workflows/ci.yml | 9 +- .github/workflows/release-please.yml | 93 ++++++++++--------- ...6-08-23-pirsch-mcp-modernization-design.md | 69 ++++++++++++++ package-lock.json | 2 +- package.json | 3 +- scripts/release-workflow.node.mjs | 17 ++++ 7 files changed, 146 insertions(+), 48 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-23-pirsch-mcp-modernization-design.md create mode 100644 scripts/release-workflow.node.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index be20316..416b3e2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,6 +10,7 @@ updates: toolchain: dependency-type: "development" patterns: + - "@modelcontextprotocol/client" - "@eslint/js" - "@types/*" - "@vitest/coverage-v8" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8259e18..d7a06eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,15 @@ on: jobs: test: runs-on: ubuntu-latest + strategy: + matrix: + node-version: ["20.19", "24"] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: ${{ matrix.node-version }} cache: "npm" - name: Install dependencies @@ -29,6 +32,8 @@ jobs: - name: Test run: npm test + - name: Test release workflow + run: npm run test:release-workflow + - name: Test coverage run: npm run test:coverage - continue-on-error: true diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 2869c1b..9c750aa 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,73 +1,78 @@ -# Release Please - Automated versioning and npm publishing -# -# How it works: -# 1. Every push to main with conventional commits updates a "Release PR" -# 2. When you merge the Release PR, it: -# - Updates version in package.json -# - Updates CHANGELOG.md -# - Creates a GitHub Release with tag -# - Triggers the npm publish job below -# -# Commit message format: -# feat: add new feature → minor version bump -# fix: fix a bug → patch version bump -# feat!: breaking change → major version bump -# chore/docs: no version bump -# -# npm Publishing: -# Uses Trusted Publishing (OIDC) - no NPM_TOKEN needed! -# Requires Node 24+ for npm 11.5.1+ OIDC support. -# Configure at: https://www.npmjs.com/package/@verygoodplugins/mcp-pirsch/access -# -# RELEASE_PLEASE_TOKEN: -# Uses org-level PAT so the Release PR triggers CI workflows. -# PRs created by GITHUB_TOKEN don't trigger other workflows (GitHub security). - name: Release Please on: push: - branches: - - main + branches: [main] -permissions: - contents: write - pull-requests: write +permissions: {} jobs: release-please: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} steps: - - uses: googleapis/release-please-action@v4 + - uses: googleapis/release-please-action@0dfd8538845b8e92600d271a895a5372865d4062 # v5 id: release with: - manifest-file: ".release-please-manifest.json" - config-file: "release-please-config.json" - token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + manifest-file: .release-please-manifest.json + config-file: release-please-config.json + token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }} npm-publish: needs: release-please - if: ${{ needs.release-please.outputs.release_created }} + if: ${{ needs.release-please.outputs.release_created == 'true' }} runs-on: ubuntu-latest environment: npm permissions: contents: read id-token: write steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "24" - registry-url: "https://registry.npmjs.org" - - # Use npm install for cross-version lockfile compatibility - - run: npm install + registry-url: https://registry.npmjs.org + - run: npm install -g npm@11.19.0 + - run: npm ci --ignore-scripts - run: npm run build - run: npm test - - # Trusted Publishing: No NPM_TOKEN needed! - run: npm publish --provenance --access public + + mcp-registry-publish: + needs: [release-please, npm-publish] + if: ${{ needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + - name: Install pinned MCP Publisher + run: | + set -euo pipefail + archive="$RUNNER_TEMP/mcp-publisher_linux_amd64.tar.gz" + install_dir="$(mktemp -d "$RUNNER_TEMP/mcp-publisher.XXXXXX")" + curl --fail --location --show-error --silent \ + --output "$archive" \ + "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.1/mcp-publisher_linux_amd64.tar.gz" + printf '%s %s\n' "a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc" "$archive" | sha256sum --check --strict + tar -xzf "$archive" -C "$install_dir" mcp-publisher + chmod +x "$install_dir/mcp-publisher" + echo "$install_dir" >> "$GITHUB_PATH" + - name: Publish to MCP Registry + run: | + mcp-publisher login github-oidc + mcp-publisher publish server.json + env: + MCP_REGISTRY_URL: https://registry.modelcontextprotocol.io diff --git a/docs/superpowers/specs/2026-08-23-pirsch-mcp-modernization-design.md b/docs/superpowers/specs/2026-08-23-pirsch-mcp-modernization-design.md new file mode 100644 index 0000000..66d4054 --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-pirsch-mcp-modernization-design.md @@ -0,0 +1,69 @@ +# Pirsch MCP 1.0 modernization + +## Goal + +Turn `@verygoodplugins/mcp-pirsch` into a current, differentiated, read-only +Pirsch Analytics MCP server. It will support the 2026-07-28 MCP protocol and +the complete useful Pirsch API v1 analytics surface while keeping its +period-comparison and session-investigation advantages. + +## Public API + +Version 1.0 replaces the current 17-tool catalog with four model-friendly, +read-only tools: + +1. `pirsch_list_domains` returns only the safe fields needed to choose a + domain: ID, hostname, display name, and timezone. +2. `pirsch_query_statistics` takes an explicit metric and validated filter, + covering documented v1 read endpoints, including overview, traffic, + acquisition, event, device, geography, tags, funnels, and session data. +3. `pirsch_list_filter_options` discovers valid filter values for a date range. +4. `pirsch_compare_periods` retains the existing true-total comparison and + series output. + +Statistics requests use `PIRSCH_DEFAULT_DOMAIN_ID` when set. Without it, +requests must include a domain ID; the server must never silently select the +first account-scoped domain. Existing `pirsch_*` tool names are removed in 1.0 +and mapped in the migration guide; no legacy aliases are enabled by default. + +## Implementation + +- Migrate from `@modelcontextprotocol/sdk` v1 to + `@modelcontextprotocol/server` v2 and `serveStdio`, targeting MCP + 2026-07-28 while retaining legacy-client compatibility provided by the SDK. +- Use Zod v4 input/output schemas, structured content plus JSON text fallback, + readable titles, and read-only tool annotations. Tool errors return + `isError: true`. +- Keep a single native-`fetch` Pirsch API v1 client. It owns token caching, + concurrent refresh deduplication, timeout, retry-after handling, response + validation, and redacted error messages. Credentials are validated lazily so + discovery works without secrets. +- Make a direct stdio entry point and an importable server factory so symlinked + npm/npx execution needs no entrypoint guard. Preserve tested lifecycle + behavior that prevents orphaned stdio processes. +- Require Node.js 22.19 or newer; remove `node-fetch` and the vulnerable + monolithic MCP v1 SDK. + +## Delivery and propagation + +- Update package metadata, README, `.env.example`, registry manifest, CI, + security checks, and release automation. Registry publication runs only after + the npm package has published successfully and uses GitHub OIDC. +- Release as 1.0 with a migration section and a current Codex configuration + example. Do not publish or create a pull request during this implementation; + those remain post-review release actions. +- Feed reusable findings into `../mcp-ecosystem`: strengthen the TypeScript + standard/audit for v2 structured tool results, tool safety annotations, + lazy credential validation, safe discovery responses, and registry-version + publication verification. Use the existing modern template baseline rather + than duplicating its already-landed SDK v2 and OIDC work. + +## Validation + +- Unit-test filter translation, validation, token refresh/retry/timeout, safe + domain projection, metric routing, and comparison calculations. +- Add MCP client tests for tool discovery, structured results, errors, and + legacy/2026 protocol negotiation; keep an Inspector CLI smoke test for a + built npm-style entry point. +- Run the full local quality gate, package dry-run, registry validation, and + a safe live read-only Pirsch smoke test when credentials are available. diff --git a/package-lock.json b/package-lock.json index f00565c..8ac3658 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "vitest": "^3.2.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.19.0" } }, "node_modules/@ampproject/remapping": { diff --git a/package.json b/package.json index 8305443..981c5fd 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "mcpName": "io.github.verygoodplugins/mcp-pirsch", "engines": { - "node": ">=18.0.0" + "node": ">=20.19.0" }, "bin": { "mcp-pirsch": "dist/index.js" @@ -19,6 +19,7 @@ "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "test": "vitest run", + "test:release-workflow": "node --test scripts/release-workflow.node.mjs", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", diff --git a/scripts/release-workflow.node.mjs b/scripts/release-workflow.node.mjs new file mode 100644 index 0000000..d54156b --- /dev/null +++ b/scripts/release-workflow.node.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('registry publication follows npm publication and uses OIDC', () => { + const workflow = readFileSync('.github/workflows/release-please.yml', 'utf8'); + const registryStart = workflow.indexOf('\n mcp-registry-publish:\n'); + const afterRegistryStart = workflow.slice(registryStart + 1); + const nextJobOffset = afterRegistryStart.search(/\n [a-z][\w-]*:\n/); + const registryJob = nextJobOffset === -1 ? afterRegistryStart : afterRegistryStart.slice(0, nextJobOffset); + + assert.ok(registryStart >= 0, 'expected an mcp-registry-publish job'); + assert.match(registryJob, /needs: \[release-please, npm-publish\]/); + assert.match(registryJob, /id-token: write/); + assert.match(registryJob, /mcp-publisher login github-oidc/); + assert.doesNotMatch(registryJob, /continue-on-error/); +}); From 3ca6b6411cb0802a48ff3c1ab6b454dda7c31fd6 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Sun, 23 Aug 2026 16:14:53 +0200 Subject: [PATCH 02/12] chore(runtime): stage Node 22 and MCP v2 dependencies --- .github/workflows/ci.yml | 2 +- .../2026-08-23-pirsch-mcp-modernization.md | 280 +++++++++++++++++ package-lock.json | 289 ++++++++++++------ package.json | 9 +- 4 files changed, 487 insertions(+), 93 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-23-pirsch-mcp-modernization.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7a06eb..24c443f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: ["20.19", "24"] + node-version: ["22.19", "24"] steps: - uses: actions/checkout@v4 diff --git a/docs/superpowers/plans/2026-08-23-pirsch-mcp-modernization.md b/docs/superpowers/plans/2026-08-23-pirsch-mcp-modernization.md new file mode 100644 index 0000000..01ca55b --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-pirsch-mcp-modernization.md @@ -0,0 +1,280 @@ +# Pirsch MCP 1.0 Modernization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver a lean, safe, MCP 2026-compatible Pirsch Analytics v1 server and turn its reusable lessons into ecosystem standards. + +**Architecture:** Split the program into an importable MCP server factory, a focused native-fetch Pirsch v1 client, schema/metric definitions, and a direct stdio entry point. All retrievals are read-only, return structured content plus JSON text, and share one token-owning client. + +**Tech Stack:** Node.js 22.19+, TypeScript, `@modelcontextprotocol/server` 2.0.0, Zod v4, native `fetch`, Vitest, MCP Inspector, `mcp-publisher` 1.8.1. + +## Global Constraints + +- Ship exactly four default tools: `pirsch_list_domains`, `pirsch_query_statistics`, `pirsch_list_filter_options`, and `pirsch_compare_periods`. +- Remain stdio-only and read-only; no Pirsch tracking or configuration write endpoint may be registered. +- Require Node.js `>=22.19.0` and remove `@modelcontextprotocol/sdk` v1 and `node-fetch`. +- Use MCP SDK v2 Zod schemas, structured content, JSON text fallback, titles, and read-only annotations. +- Use `PIRSCH_DEFAULT_DOMAIN_ID` or require `domainId`; never silently choose an account-scoped domain. +- Never return Pirsch credentials or excess domain/account metadata. +- Do not publish to npm, the MCP Registry, GitHub, or open/close pull requests. + +--- + +### Task 1: Establish the v2 server seam and dependency baseline + +**Files:** + +- Create: `src/server.ts`, `src/server.test.ts` +- Modify: `src/index.ts`, `package.json`, `package-lock.json` + +**Interfaces:** `createPirschServer(options?: PirschServerOptions): McpServer` is importable by tests; `src/index.ts` invokes `serveStdio(() => createPirschServer())`. + +- [ ] **Step 1: Write the failing factory-seam test over the public MCP protocol.** + +```ts +import { describe, expect, it } from 'vitest'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { createPirschServer } from './server.js'; + +describe('createPirschServer', () => { + it('connects an empty factory before tool registration', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createPirschServer(); + await server.connect(serverTransport); + const client = new Client({ name: 'test', version: '0.0.0' }); + await client.connect(clientTransport); + expect((await client.listTools()).tools).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run `npx vitest run src/server.test.ts`; expect a missing-module failure.** + +- [ ] **Step 3: Replace dependencies and add the minimal seam.** + +```json +"engines": { "node": ">=22.19.0" }, +"dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "^2.0.0", + "dotenv": "^17.4.2", + "node-fetch": "^3.3.2", + "zod": "^4.4.3" +} +``` + +Keep the legacy SDK and `node-fetch` in this transition commit because the legacy entry point and client still compile. Task 2 removes them together with their remaining callers. + +```ts +// src/index.ts +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { createPirschServer } from './server.js'; +void serveStdio(() => createPirschServer()); +console.error('Pirsch MCP server running on stdio'); +``` + +- [ ] **Step 4: Run `npx vitest run src/server.test.ts && npm run build`; expect the factory seam to pass. The four-tool assertion belongs to Task 3, where registration is introduced.** +- [ ] **Step 5: Commit.** + +```bash +git add package.json package-lock.json src/index.ts src/server.ts src/server.test.ts +git commit -m "refactor: establish MCP v2 server factory" +``` + +### Task 2: Build a hardened Pirsch v1 read client and metric registry + +**Files:** + +- Create: `src/pirsch-client.ts`, `src/pirsch-client.test.ts`, `src/metrics.ts`, `src/metrics.test.ts` +- Modify: `src/types.ts`, `src/filters.ts`, `src/filters.test.ts` +- Delete: `src/pirsch-api.ts`, `src/pirsch-api.test.ts` + +**Interfaces:** `PirschClient.listDomains()` returns safe summaries; `PirschClient.get(endpoint, filter)` serves documented read endpoints; `statisticsMetrics` and `filterOptionMetrics` hold endpoint and input requirements. + +- [ ] **Step 1: Write failing tests for concurrent token refresh, projected domains, redacted errors, retry-after, and encoded `eventMeta`/`tags`.** + +```ts +it('shares an in-flight token refresh', async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(jsonResponse({ access_token: 'token', expires_at: futureIso() })) + .mockResolvedValue(jsonResponse([])); + const client = new PirschClient(credentials, { fetch }); + await Promise.all([client.listDomains(), client.listDomains()]); + expect(fetch).toHaveBeenCalledTimes(3); +}); + +it('returns only safe domain fields', async () => { + await expect(client.listDomains()).resolves.toEqual([ + { id: 'domain-1', hostname: 'example.com', displayName: 'Example', timezone: 'UTC' }, + ]); +}); +``` + +- [ ] **Step 2: Run `npx vitest run src/pirsch-client.test.ts src/metrics.test.ts`; expect missing-module failures.** + +- [ ] **Step 3: Implement native-fetch client behavior and complete metric maps.** + +```ts +export const statisticsMetrics = { + overview: { endpoint: '/statistics/overview', dateRange: 'forbidden' }, + total: { endpoint: '/statistics/total', dateRange: 'required' }, + visitors: { endpoint: '/statistics/visitor', dateRange: 'required' }, + pages: { endpoint: '/statistics/page', dateRange: 'required' }, + entry_pages: { endpoint: '/statistics/page/entry', dateRange: 'required' }, + exit_pages: { endpoint: '/statistics/page/exit', dateRange: 'required' }, + sessions: { endpoint: '/statistics/session/list', dateRange: 'required' }, + session_details: { endpoint: '/statistics/session/details', dateRange: 'optional', requires: ['visitorId', 'sessionId'] }, + // Include documented durations, UTM, event, acquisition, device, geography, + // tag, keyword, funnel, hour, minute, weekday, growth, and active metrics. +} as const; +``` + +The client owns one refresh promise, `AbortSignal.timeout`, 401 refresh/retry, bounded 429 retry honoring numeric `Retry-After`, parsed redacted errors, and lazy credential validation. Add date, range, 1–100 limit, 0–3600 active-window, and session-ID validation in Zod-backed filter code. + +- [ ] **Step 4: Run `npx vitest run src/pirsch-client.test.ts src/metrics.test.ts src/filters.test.ts && npm run typecheck`; expect pass.** +- [ ] **Step 5: Commit.** + +```bash +git add src/pirsch-client.ts src/pirsch-client.test.ts src/metrics.ts src/metrics.test.ts src/types.ts src/filters.ts src/filters.test.ts +git rm src/pirsch-api.ts src/pirsch-api.test.ts +git commit -m "feat: add comprehensive Pirsch v1 read client" +``` + +### Task 3: Register the compact MCP API and structured results + +**Files:** + +- Modify: `src/server.ts`, `src/server.test.ts` +- Create: `src/mcp.test.ts` +- Delete: `src/index.test.ts`, `src/utils.ts`, `src/utils.test.ts` + +**Interfaces:** `createPirschServer({ clientFactory, defaultDomainId })` permits injected clients. Successful calls return `content` and `structuredContent`; failures return text plus `isError: true`. + +- [ ] **Step 1: Write failing in-memory MCP client tests.** + +```ts +it('returns safe domains as structured content', async () => { + const result = await client.callTool({ name: 'pirsch_list_domains', arguments: {} }); + expect(result.structuredContent).toEqual({ + domains: [{ id: 'domain-1', hostname: 'example.com', timezone: 'UTC' }], + }); + expect(JSON.parse(result.content[0].text)).toEqual(result.structuredContent); +}); + +it('marks invalid input as an MCP error result', async () => { + const result = await client.callTool({ name: 'pirsch_query_statistics', arguments: { metric: 'pages' } }); + expect(result.isError).toBe(true); +}); +``` + +- [ ] **Step 2: Run `npx vitest run src/server.test.ts src/mcp.test.ts`; expect no registered-tool failure.** + +- [ ] **Step 3: Register exact schemas and tool metadata.** + +```ts +server.registerTool('pirsch_query_statistics', { + title: 'Query Pirsch analytics', + description: 'Read one documented Pirsch Analytics API v1 metric for a selected domain and filter.', + inputSchema: statisticsQuerySchema, + outputSchema: z.object({ domainId: z.string(), metric: z.string(), data: z.unknown() }), + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, +}, async (input) => toToolResult(() => queryStatistics(client(), input, defaultDomainId))); +``` + +Apply matching title/output-schema/annotation rules to the other three tools. `pirsch_compare_periods` must call totals endpoints for totals and visitor endpoints for chart series. Delete the 17-tool dispatcher and first-domain fallback. + +- [ ] **Step 4: Run `npm run build && npx vitest run src/server.test.ts src/mcp.test.ts && npx -y @modelcontextprotocol/inspector@latest --cli node dist/index.js --method tools/list --format json`; expect four tools and passing tests.** +- [ ] **Step 5: Commit.** + +```bash +git add src/server.ts src/server.test.ts src/mcp.test.ts src/index.ts +git rm src/index.test.ts src/utils.ts src/utils.test.ts +git commit -m "feat!: replace legacy Pirsch tool catalog" +``` + +### Task 4: Make release and consumer surfaces 1.0-ready + +**Files:** + +- Modify: `README.md`, `.env.example`, `server.json`, `CLAUDE.md`, `src/index.spawn.test.ts` +- Create: `AGENTS.md`, `scripts/release-workflow.test.mjs` +- Modify: `.github/workflows/ci.yml`, `.github/workflows/security.yml`, `.github/workflows/release-please.yml`, `.github/dependabot.yml`, `release-please-config.json`, `.release-please-manifest.json` + +**Interfaces:** Registry publication is downstream of npm publication and uses GitHub OIDC. The README maps each retired tool name to one of the four tools. The release hardening is delivered first by PR #35; this task consumes that verified baseline rather than duplicating it. + +- [x] **Step 1: Keep the workflow assertion from the release-hardening slice.** + +```js +test('registry publication follows npm publication and uses OIDC', () => { + const workflow = readFileSync('.github/workflows/release-please.yml', 'utf8'); + assert.match(workflow, /needs: \[release-please, npm-publish\]/); + assert.match(workflow, /id-token: write/); + assert.match(workflow, /mcp-publisher login github-oidc/); + assert.doesNotMatch(workflow, /continue-on-error/); +}); +``` + +- [x] **Step 2: Run `node --test scripts/release-workflow.node.mjs`; expect pass.** +- [ ] **Step 3: Document 1.0 and consume the existing release hardening.** + +Use Node 22.19 and Node 24 in CI, and Node 24 for release; make dependency audit blocking. Add a post-npm MCP Registry job using pinned/checksummed `mcp-publisher` 1.8.1, `login github-oidc`, and `publish server.json`. Document Node 22.19+, OAuth read-only credentials, four-tool API, date/filter schemas, migration table, Codex config, and safe defaults. Create canonical `AGENTS.md`; reduce `CLAUDE.md` to a compatibility pointer. Add `server.json` environment-variable metadata and retain the `2025-12-11` schema. + +- [ ] **Step 4: Restore direct and symlinked bin spawn coverage; then run the release test, build, package dry-run, and registry validation; expect pass.** + +On a Linux runner, validate with the same pinned publisher binary used by the release workflow: + +```bash +publisher_dir="$(mktemp -d)" +publisher_archive="$publisher_dir/mcp-publisher_linux_amd64.tar.gz" +curl --fail --location --show-error --silent --output "$publisher_archive" \ + "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.1/mcp-publisher_linux_amd64.tar.gz" +printf '%s %s\n' "a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc" "$publisher_archive" | sha256sum --check --strict +tar -xzf "$publisher_archive" -C "$publisher_dir" mcp-publisher +"$publisher_dir/mcp-publisher" validate server.json +``` +- [ ] **Step 5: Commit.** + +```bash +git add README.md .env.example server.json .github package.json package-lock.json release-please-config.json .release-please-manifest.json CLAUDE.md AGENTS.md src/index.spawn.test.ts scripts/release-workflow.node.mjs +git commit -m "chore: prepare Pirsch MCP 1.0 delivery" +``` + +### Task 5: Propagate reusable safeguards into mcp-ecosystem + +**Files:** + +- Modify: `../mcp-ecosystem/STANDARDS.md`, `../mcp-ecosystem/README.md`, `../mcp-ecosystem/scripts/audit-server.sh`, `../mcp-ecosystem/scripts/tests/ecosystem-policy.test.mjs` + +**Interfaces:** `audit-server.sh ` identifies generic v2 structured-result and release-coupling findings, without Pirsch-specific rules. + +- [ ] **Step 1: Write a failing ecosystem audit test.** + +```js +test('audit reports missing v2 structured-result safeguards', () => { + const output = runAudit(fixtureWithLegacySdkAndUnsafeToolResult); + assert.match(output, /Legacy @modelcontextprotocol\/sdk detected/); + assert.match(output, /structuredContent.*outputSchema/); +}); +``` + +- [ ] **Step 2: Run `cd ../mcp-ecosystem && node --test scripts/tests/ecosystem-policy.test.mjs`; expect the new assertion to fail.** +- [ ] **Step 3: Add generic guidance and audit checks.** + +Cover `McpServer`/`serveStdio`, output schema plus structured/text result parity, `isError`, read-only annotations, lazy secret validation, safe discovery projection, and registry publication only after package publication. Do not duplicate the ecosystem’s already-landed v2 dependency/OIDC template work. + +- [ ] **Step 4: Run `cd ../mcp-ecosystem && node --test scripts/tests/ecosystem-policy.test.mjs && ./scripts/audit-server.sh ../mcp-pirsch`; expect passing tests and no Pirsch legacy-SDK/registry/stdout finding.** +- [ ] **Step 5: Commit in the ecosystem repository.** + +```bash +git -C ../mcp-ecosystem add STANDARDS.md README.md scripts/audit-server.sh scripts/tests/ecosystem-policy.test.mjs +git -C ../mcp-ecosystem commit -m "feat: codify MCP v2 tool safety standards" +``` + +### Task 6: Verify the integrated result + +**Files:** Modify only if verification exposes a defect. + +- [ ] **Step 1: Run `npm run typecheck && npm run lint && npm test && npm run test:coverage && npm run build`; expect zero failures.** +- [ ] **Step 2: Run `npm pack --dry-run`, registry validation, and an Inspector `tools/list`; expect intended package contents, valid manifest, and exactly four tools.** +- [ ] **Step 3: With existing local credentials, run a bounded, explicit, read-only `pirsch_list_domains` and `pirsch_query_statistics` smoke test; verify structured result and safe domain projection.** +- [ ] **Step 4: Run `git diff --check` and status checks in both repositories; report command evidence, breaking migration, and both commit IDs in the final handoff.** diff --git a/package-lock.json b/package-lock.json index 8ac3658..cc30072 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,17 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "dotenv": "^17.4.1", - "node-fetch": "^3.3.2" + "@modelcontextprotocol/server": "^2.0.0", + "dotenv": "^17.4.2", + "node-fetch": "^3.3.2", + "zod": "^4.4.3" }, "bin": { "mcp-pirsch": "dist/index.js" }, "devDependencies": { "@eslint/js": "^9.39.4", + "@modelcontextprotocol/client": "^2.0.0", "@types/node": "^25.5.2", "@vitest/coverage-v8": "^3.2.0", "eslint": "^9.39.4", @@ -29,7 +32,7 @@ "vitest": "^3.2.0" }, "engines": { - "node": ">=20.19.0" + "node": ">=22.19.0" } }, "node_modules/@ampproject/remapping": { @@ -717,12 +720,12 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -847,13 +850,44 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -887,6 +921,19 @@ } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1758,9 +1805,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1856,20 +1903,20 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -1879,6 +1926,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -2020,9 +2080,9 @@ "license": "MIT" }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "engines": { "node": ">=18" @@ -2060,9 +2120,9 @@ } }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -2070,6 +2130,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -2139,9 +2203,9 @@ } }, "node_modules/dotenv": { - "version": "17.4.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", - "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2219,9 +2283,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2579,12 +2643,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -2617,9 +2682,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", @@ -2974,9 +3039,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2986,9 +3051,9 @@ } }, "node_modules/hono": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", - "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", + "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -3022,9 +3087,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -3081,9 +3146,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -3377,12 +3442,16 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/merge-descriptors": { @@ -3478,12 +3547,32 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/node-domexception": { @@ -3816,12 +3905,13 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -3831,12 +3921,16 @@ } }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { @@ -4036,14 +4130,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -4055,13 +4149,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -4461,17 +4555,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -4884,21 +4995,21 @@ } }, "node_modules/zod": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", "peerDependencies": { - "zod": "^3.25 || ^4" + "zod": "^3.25.28 || ^4" } } } diff --git a/package.json b/package.json index 981c5fd..b5cc8a7 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "mcpName": "io.github.verygoodplugins/mcp-pirsch", "engines": { - "node": ">=20.19.0" + "node": ">=22.19.0" }, "bin": { "mcp-pirsch": "dist/index.js" @@ -56,12 +56,15 @@ "server.json" ], "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", - "dotenv": "^17.4.1", - "node-fetch": "^3.3.2" + "dotenv": "^17.4.2", + "node-fetch": "^3.3.2", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.39.4", + "@modelcontextprotocol/client": "^2.0.0", "@types/node": "^25.5.2", "@vitest/coverage-v8": "^3.2.0", "eslint": "^9.39.4", From 2d67e34d237e4f31c8e0148c1bd8d50263d83839 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Sun, 23 Aug 2026 16:17:10 +0200 Subject: [PATCH 03/12] feat(client): add isolated Pirsch API v1 read foundation --- src/filters.test.ts | 8 +- src/filters.ts | 54 ++++++++++- src/metrics.test.ts | 15 ++++ src/metrics.ts | 82 +++++++++++++++++ src/pirsch-client.test.ts | 126 ++++++++++++++++++++++++++ src/pirsch-client.ts | 182 ++++++++++++++++++++++++++++++++++++++ src/types.ts | 61 +++++++++++++ 7 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 src/metrics.test.ts create mode 100644 src/metrics.ts create mode 100644 src/pirsch-client.test.ts create mode 100644 src/pirsch-client.ts diff --git a/src/filters.test.ts b/src/filters.test.ts index fb80c33..63bbcda 100644 --- a/src/filters.test.ts +++ b/src/filters.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buildFilterParams } from './filters.js'; +import { buildFilterParams, validateV1Filter } from './filters.js'; describe('buildFilterParams', () => { const domainId = 'test-domain-123'; @@ -223,3 +223,9 @@ describe('buildFilterParams', () => { expect(params.get('tag')).toBe('premium'); }); }); + +describe('validateV1Filter', () => { + it('rejects nonexistent calendar dates', () => { + expect(() => validateV1Filter({ from: '2026-02-31' })).toThrow('from must be an ISO date.'); + }); +}); diff --git a/src/filters.ts b/src/filters.ts index ac4328a..1a30d7a 100644 --- a/src/filters.ts +++ b/src/filters.ts @@ -1,4 +1,4 @@ -import type { FilterInput } from './types.js'; +import type { FilterInput, PirschFilter } from './types.js'; export function buildFilterParams(filter: FilterInput, domainId: string, defaults?: { tz?: string }): URLSearchParams { const params = new URLSearchParams(); @@ -69,3 +69,55 @@ export function buildFilterParams(filter: FilterInput, domainId: string, default return params; } + +const v1FilterParameterNames = { + from: 'from', to: 'to', fromTime: 'from_time', toTime: 'to_time', timezone: 'tz', start: 'start', scale: 'scale', + hostname: 'hostname', path: 'path', entryPath: 'entry_path', exitPath: 'exit_path', pattern: 'pattern', event: 'event', + eventMetaKey: 'event_meta_key', language: 'language', country: 'country', region: 'region', city: 'city', + referrer: 'referrer', referrerName: 'referrer_name', channel: 'channel', operatingSystem: 'os', browser: 'browser', + platform: 'platform', screenClass: 'screen_class', utmSource: 'utm_source', utmMedium: 'utm_medium', + utmCampaign: 'utm_campaign', utmContent: 'utm_content', utmTerm: 'utm_term', customMetricType: 'custom_metric_type', + customMetricKey: 'custom_metric_key', tags: 'tag', offset: 'offset', limit: 'limit', + includeAverageTimeOnPage: 'include_avg_time_on_page', includeTitle: 'include_title', sort: 'sort', direction: 'direction', + search: 'search', keyword: 'keyword', visitorId: 'visitor_id', sessionId: 'session_id', +} as const satisfies Record; + +const isoDatePattern = /^\d{4}-\d{2}-\d{2}$/; +const clockPattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/; + +function isValidIsoDate(value: string): boolean { + if (!isoDatePattern.test(value)) return false; + const parsed = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} + +export function validateV1Filter(filter: PirschFilter): void { + if (filter.start !== undefined && (!Number.isInteger(filter.start) || filter.start < 0 || filter.start > 3_600)) { + throw new Error('start must be an integer from 0 to 3600.'); + } + if (filter.offset !== undefined && (!Number.isInteger(filter.offset) || filter.offset < 0)) { + throw new Error('offset must be a non-negative integer.'); + } + if (filter.limit !== undefined && (!Number.isInteger(filter.limit) || filter.limit < 1 || filter.limit > 100)) { + throw new Error('limit must be an integer from 1 to 100.'); + } + for (const [name, value] of [['from', filter.from], ['to', filter.to]]) { + if (value !== undefined && !isValidIsoDate(value)) throw new Error(`${name} must be an ISO date.`); + } + for (const [name, value] of [['fromTime', filter.fromTime], ['toTime', filter.toTime]]) { + if (value !== undefined && !clockPattern.test(value)) throw new Error(`${name} must be a 24-hour HH:MM time.`); + } + if (filter.from && filter.to && filter.from > filter.to) { + throw new Error('from must be on or before to.'); + } +} + +export function buildV1FilterParams(filter: PirschFilter, domainId: string, defaultTimezone?: string): URLSearchParams { + const params = new URLSearchParams({ id: domainId }); + for (const [key, parameter] of Object.entries(v1FilterParameterNames) as Array<[keyof PirschFilter, string]>) { + const value = filter[key]; + if (value !== undefined && value !== null && value !== '') params.set(parameter, String(value)); + } + if (!params.has('tz') && defaultTimezone) params.set('tz', defaultTimezone); + return params; +} diff --git a/src/metrics.test.ts b/src/metrics.test.ts new file mode 100644 index 0000000..7b7f2df --- /dev/null +++ b/src/metrics.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { filterOptionMetrics, statisticsMetrics } from './metrics.js'; + +describe('metric registries', () => { + it('exposes documented statistics metrics with their required inputs', () => { + expect(statisticsMetrics.pages).toMatchObject({ endpoint: '/statistics/page', dateRange: 'required' }); + expect(statisticsMetrics.session_details).toMatchObject({ endpoint: '/statistics/session/details', requires: ['visitorId', 'sessionId'] }); + expect(statisticsMetrics.active).toMatchObject({ endpoint: '/statistics/active', dateRange: 'optional' }); + }); + + it('exposes documented filter-option endpoints', () => { + expect(filterOptionMetrics.event).toBe('/options/event'); + expect(filterOptionMetrics.tagValue).toBe('/options/tag/value'); + }); +}); diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..ff512b0 --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,82 @@ +export type DateRangeRequirement = 'forbidden' | 'optional' | 'required'; + +export interface StatisticsMetricDefinition { + endpoint: string; + dateRange: DateRangeRequirement; + requires?: readonly ('event' | 'tags' | 'visitorId' | 'sessionId')[]; +} + +export const statisticsMetrics = { + overview: { endpoint: '/statistics/overview', dateRange: 'forbidden' }, + total: { endpoint: '/statistics/total', dateRange: 'required' }, + visitors: { endpoint: '/statistics/visitor', dateRange: 'required' }, + hostnames: { endpoint: '/statistics/hostname', dateRange: 'required' }, + pages: { endpoint: '/statistics/page', dateRange: 'required' }, + entry_pages: { endpoint: '/statistics/page/entry', dateRange: 'required' }, + exit_pages: { endpoint: '/statistics/page/exit', dateRange: 'required' }, + session_duration: { endpoint: '/statistics/duration/session', dateRange: 'required' }, + page_duration: { endpoint: '/statistics/duration/page', dateRange: 'required' }, + goals: { endpoint: '/statistics/goals', dateRange: 'required' }, + events: { endpoint: '/statistics/events', dateRange: 'required' }, + event_meta: { endpoint: '/statistics/event/meta', dateRange: 'required', requires: ['event'] }, + event_list: { endpoint: '/statistics/event/list', dateRange: 'required' }, + event_pages: { endpoint: '/statistics/event/page', dateRange: 'required', requires: ['event'] }, + growth: { endpoint: '/statistics/growth', dateRange: 'required' }, + active: { endpoint: '/statistics/active', dateRange: 'optional' }, + hours: { endpoint: '/statistics/hours', dateRange: 'required' }, + minutes: { endpoint: '/statistics/minutes', dateRange: 'required' }, + weekdays: { endpoint: '/statistics/weekdays', dateRange: 'required' }, + languages: { endpoint: '/statistics/language', dateRange: 'required' }, + referrers: { endpoint: '/statistics/referrer', dateRange: 'required' }, + channels: { endpoint: '/statistics/channel', dateRange: 'required' }, + operating_systems: { endpoint: '/statistics/os', dateRange: 'required' }, + browsers: { endpoint: '/statistics/browser', dateRange: 'required' }, + browser_versions: { endpoint: '/statistics/browser/version', dateRange: 'required' }, + countries: { endpoint: '/statistics/country', dateRange: 'required' }, + regions: { endpoint: '/statistics/region', dateRange: 'required' }, + cities: { endpoint: '/statistics/city', dateRange: 'required' }, + platforms: { endpoint: '/statistics/platform', dateRange: 'required' }, + screen_classes: { endpoint: '/statistics/screen', dateRange: 'required' }, + utm_sources: { endpoint: '/statistics/utm/source', dateRange: 'required' }, + utm_mediums: { endpoint: '/statistics/utm/medium', dateRange: 'required' }, + utm_campaigns: { endpoint: '/statistics/utm/campaign', dateRange: 'required' }, + utm_contents: { endpoint: '/statistics/utm/content', dateRange: 'required' }, + utm_terms: { endpoint: '/statistics/utm/term', dateRange: 'required' }, + tag_keys: { endpoint: '/statistics/tags', dateRange: 'required' }, + tag_details: { endpoint: '/statistics/tag/details', dateRange: 'required', requires: ['tags'] }, + keywords: { endpoint: '/statistics/keywords', dateRange: 'required' }, + funnels: { endpoint: '/statistics/funnel', dateRange: 'required' }, + sessions: { endpoint: '/statistics/session/list', dateRange: 'required' }, + session_details: { + endpoint: '/statistics/session/details', + dateRange: 'optional', + requires: ['visitorId', 'sessionId'], + }, +} as const satisfies Record; + +export const filterOptionMetrics = { + hostname: '/options/hostname', + page: '/options/page', + referrer: '/options/referrer', + referrerName: '/options/referrer/name', + channel: '/options/channel', + event: '/options/event', + country: '/options/country', + region: '/options/region', + city: '/options/city', + language: '/options/language', + browser: '/options/browser', + operatingSystem: '/options/os', + metadataKey: '/options/metadata/keys', + metadata: '/options/metadata', + utmSource: '/options/utm/source', + utmMedium: '/options/utm/medium', + utmCampaign: '/options/utm/campaign', + utmContent: '/options/utm/content', + utmTerm: '/options/utm/term', + tag: '/options/tag', + tagValue: '/options/tag/value', +} as const; + +export type StatisticsMetric = keyof typeof statisticsMetrics; +export type FilterOptionMetric = keyof typeof filterOptionMetrics; diff --git a/src/pirsch-client.test.ts b/src/pirsch-client.test.ts new file mode 100644 index 0000000..26f77c0 --- /dev/null +++ b/src/pirsch-client.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PirschClient, PirschError } from './pirsch-client.js'; + +const credentials = { clientId: 'client-id', clientSecret: 'client-secret' }; + +function jsonResponse(body: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } }); +} + +function futureIso(): string { + return new Date(Date.now() + 60 * 60 * 1000).toISOString(); +} + +describe('PirschClient', () => { + it('shares an in-flight token refresh and projects only safe domain fields', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ access_token: 'access-token', expires_at: futureIso() })) + .mockImplementation(() => Promise.resolve(jsonResponse([{ id: 'domain-1', hostname: 'example.com', display_name: 'Example', timezone: 'UTC', organization: { email: 'private@example.com' } }]))); + const client = new PirschClient(credentials, { fetch }); + + await expect(Promise.all([client.listDomains(), client.listDomains()])).resolves.toEqual([ + [{ id: 'domain-1', hostname: 'example.com', displayName: 'Example', timezone: 'UTC' }], + [{ id: 'domain-1', hostname: 'example.com', displayName: 'Example', timezone: 'UTC' }], + ]); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('encodes public filters using Pirsch API parameter names', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ access_token: 'access-token', expires_at: futureIso() })) + .mockResolvedValueOnce(jsonResponse([])); + const client = new PirschClient(credentials, { fetch }); + + await client.get('/statistics/event/meta', 'domain-1', { event: 'Signed up', eventMetaKey: 'plan name', tags: 'pro plan' }); + + const request = new URL(String(fetch.mock.calls[1]?.[0])); + expect(Object.fromEntries(request.searchParams)).toMatchObject({ id: 'domain-1', event: 'Signed up', event_meta_key: 'plan name', tag: 'pro plan' }); + }); + + it('honors numeric Retry-After values with a bounded retry', async () => { + const sleep = vi.fn().mockResolvedValue(undefined); + const fetch = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ access_token: 'access-token', expires_at: futureIso() })) + .mockResolvedValueOnce(jsonResponse({ error: 'too many requests' }, 429, { 'Retry-After': '2' })) + .mockResolvedValueOnce(jsonResponse([])); + const client = new PirschClient(credentials, { fetch, sleep }); + + await client.listDomains(); + + expect(sleep).toHaveBeenCalledWith(2_000); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('redacts credentials and response bodies from errors', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ error: 'client-secret access-token' }, 401)); + const client = new PirschClient(credentials, { fetch }); + + await expect(client.listDomains()).rejects.toBeInstanceOf(PirschError); + await expect(client.listDomains()).rejects.not.toThrow(/client-secret|access-token/); + }); + + it('does not invalidate a refreshed token when a concurrent stale request returns 401', async () => { + let tokenRequests = 0; + let apiRequests = 0; + let resolveSecondStaleResponse: ((response: Response) => void) | undefined; + const fetch = vi.fn((input) => { + if (String(input).endsWith('/token')) { + tokenRequests += 1; + return Promise.resolve(jsonResponse({ access_token: tokenRequests === 1 ? 'expired-token' : 'fresh-token', expires_at: futureIso() })); + } + + apiRequests += 1; + if (apiRequests === 1) return Promise.resolve(jsonResponse({}, 401)); + if (apiRequests === 2) return new Promise((resolve) => { resolveSecondStaleResponse = resolve; }); + return Promise.resolve(jsonResponse([])); + }); + const client = new PirschClient(credentials, { fetch }); + + const first = client.listDomains(); + const second = client.listDomains(); + await first; + resolveSecondStaleResponse?.(jsonResponse({}, 401)); + await second; + + expect(tokenRequests).toBe(2); + }); + + it('redacts malformed successful response bodies', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ access_token: 'access-token', expires_at: futureIso() })) + .mockResolvedValueOnce(new Response('client-secret access-token', { status: 200 })); + const client = new PirschClient(credentials, { fetch }); + + await expect(client.listDomains()).rejects.toThrow('Pirsch API response was not valid JSON.'); + }); + + it('rejects structurally invalid successful domain responses', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ access_token: 'access-token', expires_at: futureIso() })) + .mockResolvedValueOnce(jsonResponse([{ id: 'domain-1' }])); + const client = new PirschClient(credentials, { fetch }); + + await expect(client.listDomains()).rejects.toThrow('Pirsch domain response was invalid.'); + }); + + it('rejects invalid v1 filters before sending an upstream request', async () => { + const fetch = vi.fn(); + const client = new PirschClient(credentials, { fetch }); + + await expect(client.get('/statistics/total', 'domain-1', { limit: 101 })).rejects.toThrow('limit must be an integer from 1 to 100.'); + await expect(client.get('/statistics/total', 'domain-1', { from: '2026-08-02', to: '2026-08-01' })).rejects.toThrow('from must be on or before to.'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('redacts structurally invalid successful token payloads', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse(null)); + const client = new PirschClient(credentials, { fetch }); + + await expect(client.listDomains()).rejects.toThrow('Pirsch authentication returned an invalid token response.'); + }); +}); diff --git a/src/pirsch-client.ts b/src/pirsch-client.ts new file mode 100644 index 0000000..869f3af --- /dev/null +++ b/src/pirsch-client.ts @@ -0,0 +1,182 @@ +import { buildV1FilterParams, validateV1Filter } from './filters.js'; +import type { PirschCredentials, PirschFilter, SafeDomain } from './types.js'; + +const API_BASE_URL = 'https://api.pirsch.io/api/v1/'; +const DEFAULT_TIMEOUT_MS = 15_000; +const MAX_RETRY_DELAY_MS = 5_000; + +interface TokenResponse { + access_token?: unknown; + expires_at?: unknown; +} + +interface TokenCache { + accessToken?: string; + expiresAt: number; +} + +export interface PirschClientOptions { + fetch?: typeof globalThis.fetch; + sleep?: (milliseconds: number) => Promise; + timeoutMs?: number; + timezone?: string; +} + +export class PirschError extends Error { + constructor(message: string) { + super(message); + this.name = 'PirschError'; + } +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' ? (value as Record) : undefined; +} + +function projectDomain(value: unknown): SafeDomain | undefined { + const domain = asRecord(value); + if (!domain || typeof domain.id !== 'string' || typeof domain.hostname !== 'string') return undefined; + + return { + id: domain.id, + hostname: domain.hostname, + ...(typeof domain.display_name === 'string' ? { displayName: domain.display_name } : {}), + ...(typeof domain.timezone === 'string' ? { timezone: domain.timezone } : {}), + }; +} + +function retryDelay(response: Response): number { + const retryAfter = response.headers.get('retry-after'); + const seconds = retryAfter ? Number(retryAfter) : Number.NaN; + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.min(Math.round(seconds * 1_000), MAX_RETRY_DELAY_MS); + } + return 1_000; +} + +export class PirschClient { + private readonly fetchImpl: typeof globalThis.fetch; + private readonly sleep: (milliseconds: number) => Promise; + private readonly timeoutMs: number; + private readonly timezone?: string; + private token: TokenCache = { expiresAt: 0 }; + private refreshPromise?: Promise; + + constructor( + private readonly credentials: PirschCredentials, + options: PirschClientOptions = {} + ) { + this.fetchImpl = options.fetch ?? globalThis.fetch; + this.sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.timezone = options.timezone; + } + + async listDomains(): Promise { + const result = await this.request('/domain'); + const values = Array.isArray(result) ? result : [result]; + const domains: SafeDomain[] = []; + for (const value of values) { + const domain = projectDomain(value); + if (!domain) { + throw new PirschError('Pirsch domain response was invalid.'); + } + domains.push(domain); + } + return domains; + } + + async get(endpoint: string, domainId: string, filter: PirschFilter = {}): Promise { + validateV1Filter(filter); + return this.request(endpoint, buildV1FilterParams(filter, domainId, this.timezone)); + } + + private hasUsableToken(): boolean { + return Boolean(this.token.accessToken) && Date.now() + 60_000 < this.token.expiresAt; + } + + private async ensureToken(): Promise { + if (this.hasUsableToken() && this.token.accessToken) return this.token.accessToken; + + if (!this.refreshPromise) { + this.refreshPromise = this.refreshToken().finally(() => { + this.refreshPromise = undefined; + }); + } + await this.refreshPromise; + + if (!this.token.accessToken) { + throw new PirschError('Pirsch authentication did not return an access token.'); + } + return this.token.accessToken; + } + + private async refreshToken(): Promise { + if (!this.credentials.clientId || !this.credentials.clientSecret) { + throw new PirschError('Pirsch credentials are not configured. Set PIRSCH_CLIENT_ID and PIRSCH_CLIENT_SECRET.'); + } + + const response = await this.fetchImpl(`${API_BASE_URL}token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: this.credentials.clientId, client_secret: this.credentials.clientSecret }), + signal: AbortSignal.timeout(this.timeoutMs), + }); + if (!response.ok) { + throw new PirschError(`Pirsch authentication failed (${response.status}).`); + } + + let payload: TokenResponse; + try { + const parsed = asRecord(await response.json()); + if (!parsed) throw new Error('Invalid token payload'); + payload = parsed as TokenResponse; + } catch { + throw new PirschError('Pirsch authentication returned an invalid token response.'); + } + if (typeof payload.access_token !== 'string') { + throw new PirschError('Pirsch authentication returned an invalid token response.'); + } + const parsedExpiry = typeof payload.expires_at === 'string' ? Date.parse(payload.expires_at) : Number.NaN; + this.token = { + accessToken: payload.access_token, + expiresAt: Number.isFinite(parsedExpiry) ? parsedExpiry : Date.now() + 55 * 60_000, + }; + } + + private async request(endpoint: string, params?: URLSearchParams): Promise { + const url = new URL(endpoint.replace(/^\//, ''), API_BASE_URL); + if (params) url.search = params.toString(); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const accessToken = await this.ensureToken(); + const response = await this.fetchImpl(url, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + + if (response.status === 401 && attempt === 0) { + if (this.token.accessToken === accessToken) { + this.token = { expiresAt: 0 }; + } + continue; + } + if (response.status === 429 && attempt === 0) { + await this.sleep(retryDelay(response)); + continue; + } + if (!response.ok) { + throw new PirschError(`Pirsch API request failed (${response.status}).`); + } + if (response.status === 204) return {} as T; + try { + return (await response.json()) as T; + } catch { + throw new PirschError('Pirsch API response was not valid JSON.'); + } + } + + throw new PirschError('Pirsch API request failed after a retry.'); + } +} diff --git a/src/types.ts b/src/types.ts index 134f15f..4540bad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,3 +83,64 @@ export interface VisitorsPoint { bounce_rate: number; cr: number; } + +/** Credentials for the v1 read client. Kept separate from legacy tool inputs. */ +export interface PirschCredentials { + clientId?: string; + clientSecret?: string; +} + +/** The intentionally small, account-safe domain view exposed by MCP. */ +export interface SafeDomain { + id: string; + hostname?: string; + displayName?: string; + timezone?: string; +} + +/** Camel-cased v1 filters used by the new client and future MCP v2 tools. */ +export interface PirschFilter { + from?: string; + to?: string; + fromTime?: string; + toTime?: string; + timezone?: string; + start?: number; + scale?: 'day' | 'week' | 'month' | 'year'; + hostname?: string; + path?: string; + entryPath?: string; + exitPath?: string; + pattern?: string; + event?: string; + eventMetaKey?: string; + language?: string; + country?: string; + region?: string; + city?: string; + referrer?: string; + referrerName?: string; + channel?: string; + operatingSystem?: string; + browser?: string; + platform?: 'desktop' | 'mobile' | 'unknown'; + screenClass?: string; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + utmContent?: string; + utmTerm?: string; + customMetricType?: 'integer' | 'float'; + customMetricKey?: string; + tags?: string; + offset?: number; + limit?: number; + includeAverageTimeOnPage?: boolean; + includeTitle?: boolean; + sort?: string; + direction?: 'asc' | 'desc'; + search?: string; + keyword?: string; + visitorId?: string; + sessionId?: string; +} From 2ce09fe51c10bcde338d408b620d55d7716eb671 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Sun, 23 Aug 2026 16:18:12 +0200 Subject: [PATCH 04/12] refactor(server): add tested v2 four-tool factory --- .gitignore | 3 + .../2026-08-23-pirsch-mcp-modernization.md | 280 ------------------ ...6-08-23-pirsch-mcp-modernization-design.md | 69 ----- src/mcp.test.ts | 72 +++++ src/schemas.ts | 122 ++++++++ src/server.test.ts | 21 ++ src/server.ts | 213 +++++++++++++ src/utils.test.ts | 42 +-- src/utils.ts | 54 ++-- 9 files changed, 479 insertions(+), 397 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-23-pirsch-mcp-modernization.md delete mode 100644 docs/superpowers/specs/2026-08-23-pirsch-mcp-modernization-design.md create mode 100644 src/mcp.test.ts create mode 100644 src/schemas.ts create mode 100644 src/server.test.ts create mode 100644 src/server.ts diff --git a/.gitignore b/.gitignore index 5fac5f1..454224f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ yarn-debug.log* yarn-error.log* coverage +# Local agent-planning artifacts are not repository documentation. +docs/superpowers/ + .mcp.json dashboard.html /screenshots diff --git a/docs/superpowers/plans/2026-08-23-pirsch-mcp-modernization.md b/docs/superpowers/plans/2026-08-23-pirsch-mcp-modernization.md deleted file mode 100644 index 01ca55b..0000000 --- a/docs/superpowers/plans/2026-08-23-pirsch-mcp-modernization.md +++ /dev/null @@ -1,280 +0,0 @@ -# Pirsch MCP 1.0 Modernization Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Deliver a lean, safe, MCP 2026-compatible Pirsch Analytics v1 server and turn its reusable lessons into ecosystem standards. - -**Architecture:** Split the program into an importable MCP server factory, a focused native-fetch Pirsch v1 client, schema/metric definitions, and a direct stdio entry point. All retrievals are read-only, return structured content plus JSON text, and share one token-owning client. - -**Tech Stack:** Node.js 22.19+, TypeScript, `@modelcontextprotocol/server` 2.0.0, Zod v4, native `fetch`, Vitest, MCP Inspector, `mcp-publisher` 1.8.1. - -## Global Constraints - -- Ship exactly four default tools: `pirsch_list_domains`, `pirsch_query_statistics`, `pirsch_list_filter_options`, and `pirsch_compare_periods`. -- Remain stdio-only and read-only; no Pirsch tracking or configuration write endpoint may be registered. -- Require Node.js `>=22.19.0` and remove `@modelcontextprotocol/sdk` v1 and `node-fetch`. -- Use MCP SDK v2 Zod schemas, structured content, JSON text fallback, titles, and read-only annotations. -- Use `PIRSCH_DEFAULT_DOMAIN_ID` or require `domainId`; never silently choose an account-scoped domain. -- Never return Pirsch credentials or excess domain/account metadata. -- Do not publish to npm, the MCP Registry, GitHub, or open/close pull requests. - ---- - -### Task 1: Establish the v2 server seam and dependency baseline - -**Files:** - -- Create: `src/server.ts`, `src/server.test.ts` -- Modify: `src/index.ts`, `package.json`, `package-lock.json` - -**Interfaces:** `createPirschServer(options?: PirschServerOptions): McpServer` is importable by tests; `src/index.ts` invokes `serveStdio(() => createPirschServer())`. - -- [ ] **Step 1: Write the failing factory-seam test over the public MCP protocol.** - -```ts -import { describe, expect, it } from 'vitest'; -import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; -import { createPirschServer } from './server.js'; - -describe('createPirschServer', () => { - it('connects an empty factory before tool registration', async () => { - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const server = createPirschServer(); - await server.connect(serverTransport); - const client = new Client({ name: 'test', version: '0.0.0' }); - await client.connect(clientTransport); - expect((await client.listTools()).tools).toHaveLength(0); - }); -}); -``` - -- [ ] **Step 2: Run `npx vitest run src/server.test.ts`; expect a missing-module failure.** - -- [ ] **Step 3: Replace dependencies and add the minimal seam.** - -```json -"engines": { "node": ">=22.19.0" }, -"dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", - "@modelcontextprotocol/server": "^2.0.0", - "dotenv": "^17.4.2", - "node-fetch": "^3.3.2", - "zod": "^4.4.3" -} -``` - -Keep the legacy SDK and `node-fetch` in this transition commit because the legacy entry point and client still compile. Task 2 removes them together with their remaining callers. - -```ts -// src/index.ts -import { serveStdio } from '@modelcontextprotocol/server/stdio'; -import { createPirschServer } from './server.js'; -void serveStdio(() => createPirschServer()); -console.error('Pirsch MCP server running on stdio'); -``` - -- [ ] **Step 4: Run `npx vitest run src/server.test.ts && npm run build`; expect the factory seam to pass. The four-tool assertion belongs to Task 3, where registration is introduced.** -- [ ] **Step 5: Commit.** - -```bash -git add package.json package-lock.json src/index.ts src/server.ts src/server.test.ts -git commit -m "refactor: establish MCP v2 server factory" -``` - -### Task 2: Build a hardened Pirsch v1 read client and metric registry - -**Files:** - -- Create: `src/pirsch-client.ts`, `src/pirsch-client.test.ts`, `src/metrics.ts`, `src/metrics.test.ts` -- Modify: `src/types.ts`, `src/filters.ts`, `src/filters.test.ts` -- Delete: `src/pirsch-api.ts`, `src/pirsch-api.test.ts` - -**Interfaces:** `PirschClient.listDomains()` returns safe summaries; `PirschClient.get(endpoint, filter)` serves documented read endpoints; `statisticsMetrics` and `filterOptionMetrics` hold endpoint and input requirements. - -- [ ] **Step 1: Write failing tests for concurrent token refresh, projected domains, redacted errors, retry-after, and encoded `eventMeta`/`tags`.** - -```ts -it('shares an in-flight token refresh', async () => { - const fetch = vi.fn() - .mockResolvedValueOnce(jsonResponse({ access_token: 'token', expires_at: futureIso() })) - .mockResolvedValue(jsonResponse([])); - const client = new PirschClient(credentials, { fetch }); - await Promise.all([client.listDomains(), client.listDomains()]); - expect(fetch).toHaveBeenCalledTimes(3); -}); - -it('returns only safe domain fields', async () => { - await expect(client.listDomains()).resolves.toEqual([ - { id: 'domain-1', hostname: 'example.com', displayName: 'Example', timezone: 'UTC' }, - ]); -}); -``` - -- [ ] **Step 2: Run `npx vitest run src/pirsch-client.test.ts src/metrics.test.ts`; expect missing-module failures.** - -- [ ] **Step 3: Implement native-fetch client behavior and complete metric maps.** - -```ts -export const statisticsMetrics = { - overview: { endpoint: '/statistics/overview', dateRange: 'forbidden' }, - total: { endpoint: '/statistics/total', dateRange: 'required' }, - visitors: { endpoint: '/statistics/visitor', dateRange: 'required' }, - pages: { endpoint: '/statistics/page', dateRange: 'required' }, - entry_pages: { endpoint: '/statistics/page/entry', dateRange: 'required' }, - exit_pages: { endpoint: '/statistics/page/exit', dateRange: 'required' }, - sessions: { endpoint: '/statistics/session/list', dateRange: 'required' }, - session_details: { endpoint: '/statistics/session/details', dateRange: 'optional', requires: ['visitorId', 'sessionId'] }, - // Include documented durations, UTM, event, acquisition, device, geography, - // tag, keyword, funnel, hour, minute, weekday, growth, and active metrics. -} as const; -``` - -The client owns one refresh promise, `AbortSignal.timeout`, 401 refresh/retry, bounded 429 retry honoring numeric `Retry-After`, parsed redacted errors, and lazy credential validation. Add date, range, 1–100 limit, 0–3600 active-window, and session-ID validation in Zod-backed filter code. - -- [ ] **Step 4: Run `npx vitest run src/pirsch-client.test.ts src/metrics.test.ts src/filters.test.ts && npm run typecheck`; expect pass.** -- [ ] **Step 5: Commit.** - -```bash -git add src/pirsch-client.ts src/pirsch-client.test.ts src/metrics.ts src/metrics.test.ts src/types.ts src/filters.ts src/filters.test.ts -git rm src/pirsch-api.ts src/pirsch-api.test.ts -git commit -m "feat: add comprehensive Pirsch v1 read client" -``` - -### Task 3: Register the compact MCP API and structured results - -**Files:** - -- Modify: `src/server.ts`, `src/server.test.ts` -- Create: `src/mcp.test.ts` -- Delete: `src/index.test.ts`, `src/utils.ts`, `src/utils.test.ts` - -**Interfaces:** `createPirschServer({ clientFactory, defaultDomainId })` permits injected clients. Successful calls return `content` and `structuredContent`; failures return text plus `isError: true`. - -- [ ] **Step 1: Write failing in-memory MCP client tests.** - -```ts -it('returns safe domains as structured content', async () => { - const result = await client.callTool({ name: 'pirsch_list_domains', arguments: {} }); - expect(result.structuredContent).toEqual({ - domains: [{ id: 'domain-1', hostname: 'example.com', timezone: 'UTC' }], - }); - expect(JSON.parse(result.content[0].text)).toEqual(result.structuredContent); -}); - -it('marks invalid input as an MCP error result', async () => { - const result = await client.callTool({ name: 'pirsch_query_statistics', arguments: { metric: 'pages' } }); - expect(result.isError).toBe(true); -}); -``` - -- [ ] **Step 2: Run `npx vitest run src/server.test.ts src/mcp.test.ts`; expect no registered-tool failure.** - -- [ ] **Step 3: Register exact schemas and tool metadata.** - -```ts -server.registerTool('pirsch_query_statistics', { - title: 'Query Pirsch analytics', - description: 'Read one documented Pirsch Analytics API v1 metric for a selected domain and filter.', - inputSchema: statisticsQuerySchema, - outputSchema: z.object({ domainId: z.string(), metric: z.string(), data: z.unknown() }), - annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, -}, async (input) => toToolResult(() => queryStatistics(client(), input, defaultDomainId))); -``` - -Apply matching title/output-schema/annotation rules to the other three tools. `pirsch_compare_periods` must call totals endpoints for totals and visitor endpoints for chart series. Delete the 17-tool dispatcher and first-domain fallback. - -- [ ] **Step 4: Run `npm run build && npx vitest run src/server.test.ts src/mcp.test.ts && npx -y @modelcontextprotocol/inspector@latest --cli node dist/index.js --method tools/list --format json`; expect four tools and passing tests.** -- [ ] **Step 5: Commit.** - -```bash -git add src/server.ts src/server.test.ts src/mcp.test.ts src/index.ts -git rm src/index.test.ts src/utils.ts src/utils.test.ts -git commit -m "feat!: replace legacy Pirsch tool catalog" -``` - -### Task 4: Make release and consumer surfaces 1.0-ready - -**Files:** - -- Modify: `README.md`, `.env.example`, `server.json`, `CLAUDE.md`, `src/index.spawn.test.ts` -- Create: `AGENTS.md`, `scripts/release-workflow.test.mjs` -- Modify: `.github/workflows/ci.yml`, `.github/workflows/security.yml`, `.github/workflows/release-please.yml`, `.github/dependabot.yml`, `release-please-config.json`, `.release-please-manifest.json` - -**Interfaces:** Registry publication is downstream of npm publication and uses GitHub OIDC. The README maps each retired tool name to one of the four tools. The release hardening is delivered first by PR #35; this task consumes that verified baseline rather than duplicating it. - -- [x] **Step 1: Keep the workflow assertion from the release-hardening slice.** - -```js -test('registry publication follows npm publication and uses OIDC', () => { - const workflow = readFileSync('.github/workflows/release-please.yml', 'utf8'); - assert.match(workflow, /needs: \[release-please, npm-publish\]/); - assert.match(workflow, /id-token: write/); - assert.match(workflow, /mcp-publisher login github-oidc/); - assert.doesNotMatch(workflow, /continue-on-error/); -}); -``` - -- [x] **Step 2: Run `node --test scripts/release-workflow.node.mjs`; expect pass.** -- [ ] **Step 3: Document 1.0 and consume the existing release hardening.** - -Use Node 22.19 and Node 24 in CI, and Node 24 for release; make dependency audit blocking. Add a post-npm MCP Registry job using pinned/checksummed `mcp-publisher` 1.8.1, `login github-oidc`, and `publish server.json`. Document Node 22.19+, OAuth read-only credentials, four-tool API, date/filter schemas, migration table, Codex config, and safe defaults. Create canonical `AGENTS.md`; reduce `CLAUDE.md` to a compatibility pointer. Add `server.json` environment-variable metadata and retain the `2025-12-11` schema. - -- [ ] **Step 4: Restore direct and symlinked bin spawn coverage; then run the release test, build, package dry-run, and registry validation; expect pass.** - -On a Linux runner, validate with the same pinned publisher binary used by the release workflow: - -```bash -publisher_dir="$(mktemp -d)" -publisher_archive="$publisher_dir/mcp-publisher_linux_amd64.tar.gz" -curl --fail --location --show-error --silent --output "$publisher_archive" \ - "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.1/mcp-publisher_linux_amd64.tar.gz" -printf '%s %s\n' "a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc" "$publisher_archive" | sha256sum --check --strict -tar -xzf "$publisher_archive" -C "$publisher_dir" mcp-publisher -"$publisher_dir/mcp-publisher" validate server.json -``` -- [ ] **Step 5: Commit.** - -```bash -git add README.md .env.example server.json .github package.json package-lock.json release-please-config.json .release-please-manifest.json CLAUDE.md AGENTS.md src/index.spawn.test.ts scripts/release-workflow.node.mjs -git commit -m "chore: prepare Pirsch MCP 1.0 delivery" -``` - -### Task 5: Propagate reusable safeguards into mcp-ecosystem - -**Files:** - -- Modify: `../mcp-ecosystem/STANDARDS.md`, `../mcp-ecosystem/README.md`, `../mcp-ecosystem/scripts/audit-server.sh`, `../mcp-ecosystem/scripts/tests/ecosystem-policy.test.mjs` - -**Interfaces:** `audit-server.sh ` identifies generic v2 structured-result and release-coupling findings, without Pirsch-specific rules. - -- [ ] **Step 1: Write a failing ecosystem audit test.** - -```js -test('audit reports missing v2 structured-result safeguards', () => { - const output = runAudit(fixtureWithLegacySdkAndUnsafeToolResult); - assert.match(output, /Legacy @modelcontextprotocol\/sdk detected/); - assert.match(output, /structuredContent.*outputSchema/); -}); -``` - -- [ ] **Step 2: Run `cd ../mcp-ecosystem && node --test scripts/tests/ecosystem-policy.test.mjs`; expect the new assertion to fail.** -- [ ] **Step 3: Add generic guidance and audit checks.** - -Cover `McpServer`/`serveStdio`, output schema plus structured/text result parity, `isError`, read-only annotations, lazy secret validation, safe discovery projection, and registry publication only after package publication. Do not duplicate the ecosystem’s already-landed v2 dependency/OIDC template work. - -- [ ] **Step 4: Run `cd ../mcp-ecosystem && node --test scripts/tests/ecosystem-policy.test.mjs && ./scripts/audit-server.sh ../mcp-pirsch`; expect passing tests and no Pirsch legacy-SDK/registry/stdout finding.** -- [ ] **Step 5: Commit in the ecosystem repository.** - -```bash -git -C ../mcp-ecosystem add STANDARDS.md README.md scripts/audit-server.sh scripts/tests/ecosystem-policy.test.mjs -git -C ../mcp-ecosystem commit -m "feat: codify MCP v2 tool safety standards" -``` - -### Task 6: Verify the integrated result - -**Files:** Modify only if verification exposes a defect. - -- [ ] **Step 1: Run `npm run typecheck && npm run lint && npm test && npm run test:coverage && npm run build`; expect zero failures.** -- [ ] **Step 2: Run `npm pack --dry-run`, registry validation, and an Inspector `tools/list`; expect intended package contents, valid manifest, and exactly four tools.** -- [ ] **Step 3: With existing local credentials, run a bounded, explicit, read-only `pirsch_list_domains` and `pirsch_query_statistics` smoke test; verify structured result and safe domain projection.** -- [ ] **Step 4: Run `git diff --check` and status checks in both repositories; report command evidence, breaking migration, and both commit IDs in the final handoff.** diff --git a/docs/superpowers/specs/2026-08-23-pirsch-mcp-modernization-design.md b/docs/superpowers/specs/2026-08-23-pirsch-mcp-modernization-design.md deleted file mode 100644 index 66d4054..0000000 --- a/docs/superpowers/specs/2026-08-23-pirsch-mcp-modernization-design.md +++ /dev/null @@ -1,69 +0,0 @@ -# Pirsch MCP 1.0 modernization - -## Goal - -Turn `@verygoodplugins/mcp-pirsch` into a current, differentiated, read-only -Pirsch Analytics MCP server. It will support the 2026-07-28 MCP protocol and -the complete useful Pirsch API v1 analytics surface while keeping its -period-comparison and session-investigation advantages. - -## Public API - -Version 1.0 replaces the current 17-tool catalog with four model-friendly, -read-only tools: - -1. `pirsch_list_domains` returns only the safe fields needed to choose a - domain: ID, hostname, display name, and timezone. -2. `pirsch_query_statistics` takes an explicit metric and validated filter, - covering documented v1 read endpoints, including overview, traffic, - acquisition, event, device, geography, tags, funnels, and session data. -3. `pirsch_list_filter_options` discovers valid filter values for a date range. -4. `pirsch_compare_periods` retains the existing true-total comparison and - series output. - -Statistics requests use `PIRSCH_DEFAULT_DOMAIN_ID` when set. Without it, -requests must include a domain ID; the server must never silently select the -first account-scoped domain. Existing `pirsch_*` tool names are removed in 1.0 -and mapped in the migration guide; no legacy aliases are enabled by default. - -## Implementation - -- Migrate from `@modelcontextprotocol/sdk` v1 to - `@modelcontextprotocol/server` v2 and `serveStdio`, targeting MCP - 2026-07-28 while retaining legacy-client compatibility provided by the SDK. -- Use Zod v4 input/output schemas, structured content plus JSON text fallback, - readable titles, and read-only tool annotations. Tool errors return - `isError: true`. -- Keep a single native-`fetch` Pirsch API v1 client. It owns token caching, - concurrent refresh deduplication, timeout, retry-after handling, response - validation, and redacted error messages. Credentials are validated lazily so - discovery works without secrets. -- Make a direct stdio entry point and an importable server factory so symlinked - npm/npx execution needs no entrypoint guard. Preserve tested lifecycle - behavior that prevents orphaned stdio processes. -- Require Node.js 22.19 or newer; remove `node-fetch` and the vulnerable - monolithic MCP v1 SDK. - -## Delivery and propagation - -- Update package metadata, README, `.env.example`, registry manifest, CI, - security checks, and release automation. Registry publication runs only after - the npm package has published successfully and uses GitHub OIDC. -- Release as 1.0 with a migration section and a current Codex configuration - example. Do not publish or create a pull request during this implementation; - those remain post-review release actions. -- Feed reusable findings into `../mcp-ecosystem`: strengthen the TypeScript - standard/audit for v2 structured tool results, tool safety annotations, - lazy credential validation, safe discovery responses, and registry-version - publication verification. Use the existing modern template baseline rather - than duplicating its already-landed SDK v2 and OIDC work. - -## Validation - -- Unit-test filter translation, validation, token refresh/retry/timeout, safe - domain projection, metric routing, and comparison calculations. -- Add MCP client tests for tool discovery, structured results, errors, and - legacy/2026 protocol negotiation; keep an Inspector CLI smoke test for a - built npm-style entry point. -- Run the full local quality gate, package dry-run, registry validation, and - a safe live read-only Pirsch smoke test when credentials are available. diff --git a/src/mcp.test.ts b/src/mcp.test.ts new file mode 100644 index 0000000..0feac8f --- /dev/null +++ b/src/mcp.test.ts @@ -0,0 +1,72 @@ +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createPirschServer, type PirschReader } from './server.js'; +import { comparisonInputSchema, filterOptionsInputSchema, statisticsQuerySchema } from './schemas.js'; + +const servers: Array> = []; +const clients: Client[] = []; + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); + await Promise.all(servers.splice(0).map((server) => server.close())); +}); + +async function connect(clientFactory: () => PirschReader) { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createPirschServer({ clientFactory, defaultDomainId: 'default-domain' }); + const client = new Client({ name: 'test-client', version: '0.0.0' }); + servers.push(server); + clients.push(client); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +} + +describe('Pirsch MCP tool contracts', () => { + it('returns only safe domains as structured content with a JSON text fallback', async () => { + const listDomains = vi.fn().mockResolvedValue([{ id: 'domain-1', hostname: 'example.com', timezone: 'UTC' }]); + const client = await connect(() => ({ listDomains, get: vi.fn() })); + + const result = await client.callTool({ name: 'pirsch_list_domains', arguments: {} }); + const output = { domains: [{ id: 'domain-1', hostname: 'example.com', timezone: 'UTC' }] }; + + expect(result.structuredContent).toEqual(output); + expect(JSON.parse((result.content as Array<{ text: string }>)[0].text)).toEqual(output); + }); + + it('requires a date range for time-series statistics and marks the result as an MCP error', async () => { + const get = vi.fn(); + const client = await connect(() => ({ listDomains: vi.fn(), get })); + + const result = await client.callTool({ name: 'pirsch_query_statistics', arguments: { metric: 'pages' } }); + + expect(result.isError).toBe(true); + expect(get).not.toHaveBeenCalled(); + }); + + it('rejects invalid clock values and filters for the unfilterable overview metric', async () => { + expect(statisticsQuerySchema.safeParse({ metric: 'pages', fromTime: '99:99' }).success).toBe(false); + expect(statisticsQuerySchema.safeParse({ metric: 'pages', from: '2026-08-31', to: '2026-08-01' }).success).toBe(false); + expect(statisticsQuerySchema.safeParse({ metric: 'pages', from: '2026-08-01', to: '2026-08-01', fromTime: '18:00', toTime: '09:00' }).success).toBe(false); + expect(comparisonInputSchema.safeParse({ period: 'week', from: '2026-08-01', to: '2026-08-02' }).success).toBe(false); + expect(filterOptionsInputSchema.safeParse({ option: 'tagValue' }).success).toBe(false); + expect(filterOptionsInputSchema.safeParse({ option: 'metadata' }).success).toBe(false); + const get = vi.fn(); + const client = await connect(() => ({ listDomains: vi.fn(), get })); + + const result = await client.callTool({ name: 'pirsch_query_statistics', arguments: { metric: 'overview', country: 'US' } }); + + expect(result.isError).toBe(true); + expect(get).not.toHaveBeenCalled(); + }); + + it('uses the selected default domain and documented option endpoint', async () => { + const get = vi.fn().mockResolvedValue(['signup']); + const client = await connect(() => ({ listDomains: vi.fn(), get })); + + const result = await client.callTool({ name: 'pirsch_list_filter_options', arguments: { option: 'event' } }); + + expect(result.isError).toBeUndefined(); + expect(get).toHaveBeenCalledWith('/options/event', 'default-domain', {}); + }); +}); diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..eb25d81 --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,122 @@ +import { z } from 'zod'; +import { filterOptionMetrics, statisticsMetrics, type FilterOptionMetric, type StatisticsMetric } from './metrics.js'; + +const optionalString = z.string().trim().min(1).optional(); + +export const filterInputSchema = z.object({ + from: z.iso.date().optional(), + to: z.iso.date().optional(), + fromTime: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/).optional(), + toTime: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/).optional(), + timezone: optionalString, + start: z.number().int().min(0).max(3_600).optional(), + scale: z.enum(['day', 'week', 'month', 'year']).optional(), + hostname: optionalString, + path: optionalString, + entryPath: optionalString, + exitPath: optionalString, + pattern: optionalString, + event: optionalString, + eventMetaKey: optionalString, + language: optionalString, + country: optionalString, + region: optionalString, + city: optionalString, + referrer: optionalString, + referrerName: optionalString, + channel: optionalString, + operatingSystem: optionalString, + browser: optionalString, + platform: z.enum(['desktop', 'mobile', 'unknown']).optional(), + screenClass: optionalString, + utmSource: optionalString, + utmMedium: optionalString, + utmCampaign: optionalString, + utmContent: optionalString, + utmTerm: optionalString, + customMetricType: z.enum(['integer', 'float']).optional(), + customMetricKey: optionalString, + tags: optionalString, + offset: z.number().int().min(0).optional(), + limit: z.number().int().min(1).max(100).optional(), + includeAverageTimeOnPage: z.boolean().optional(), + includeTitle: z.boolean().optional(), + sort: optionalString, + direction: z.enum(['asc', 'desc']).optional(), + search: optionalString, + keyword: optionalString, + visitorId: optionalString, + sessionId: optionalString, +}); + +const selectedDomainSchema = z.object({ domainId: optionalString }); +const statisticsMetricValues = Object.keys(statisticsMetrics) as [StatisticsMetric, ...StatisticsMetric[]]; +const filterOptionValues = Object.keys(filterOptionMetrics) as [FilterOptionMetric, ...FilterOptionMetric[]]; + +type DateRangeInput = { from?: string; to?: string; fromTime?: string; toTime?: string; compareFrom?: string; compareTo?: string }; + +function validateChronologicalRanges(input: DateRangeInput, ctx: z.RefinementCtx): void { + for (const [fromKey, toKey] of [['from', 'to'], ['compareFrom', 'compareTo']] as const) { + const from = input[fromKey]; + const to = input[toKey]; + if (from && to && from > to) { + ctx.addIssue({ code: 'custom', path: [toKey], message: `${fromKey} must be on or before ${toKey}.` }); + } + } + if (input.from && input.to && input.from === input.to && input.fromTime && input.toTime && input.fromTime > input.toTime) { + ctx.addIssue({ code: 'custom', path: ['toTime'], message: 'toTime must be on or after fromTime for a same-day range.' }); + } +} + +export const listDomainsInputSchema = z.object({}); +export const statisticsQuerySchema = selectedDomainSchema + .extend({ metric: z.enum(statisticsMetricValues) }) + .extend(filterInputSchema.shape) + .superRefine(validateChronologicalRanges); +export const filterOptionsInputSchema = selectedDomainSchema + .extend({ option: z.enum(filterOptionValues) }) + .extend(filterInputSchema.shape) + .superRefine((input, ctx) => { + validateChronologicalRanges(input, ctx); + if (input.option === 'tagValue' && !input.tags) { + ctx.addIssue({ code: 'custom', path: ['tags'], message: 'tags is required when option is tagValue.' }); + } + if (input.option === 'metadata' && !input.eventMetaKey) { + ctx.addIssue({ code: 'custom', path: ['eventMetaKey'], message: 'eventMetaKey is required when option is metadata.' }); + } + }); +export const comparisonInputSchema = selectedDomainSchema + .extend({ + period: z.enum(['today', 'yesterday', 'week', 'lastWeek', 'month', 'lastMonth']).optional(), + compareFrom: z.iso.date().optional(), + compareTo: z.iso.date().optional(), + }) + .extend(filterInputSchema.shape) + .superRefine((input, ctx) => { + validateChronologicalRanges(input, ctx); + const hasExplicitRangeField = [input.from, input.to, input.compareFrom, input.compareTo].some((value) => value !== undefined); + if (input.period && hasExplicitRangeField) { + ctx.addIssue({ code: 'custom', path: ['period'], message: 'period cannot be combined with explicit comparison dates.' }); + } + }); + +export const safeDomainOutputSchema = z.object({ + id: z.string(), + hostname: z.string().optional(), + displayName: z.string().optional(), + timezone: z.string().optional(), +}); +export const domainsOutputSchema = z.object({ domains: z.array(safeDomainOutputSchema) }); +export const statisticsOutputSchema = z.object({ domainId: z.string(), metric: z.string(), data: z.unknown() }); +export const filterOptionsOutputSchema = z.object({ domainId: z.string(), option: z.string(), data: z.unknown() }); +export const comparisonOutputSchema = z.object({ + domainId: z.string(), + current: z.object({ from: z.string(), to: z.string() }), + previous: z.object({ from: z.string(), to: z.string() }), + totals: z.record(z.string(), z.object({ current: z.number(), previous: z.number(), change: z.number().nullable() })), + series: z.object({ current: z.unknown(), previous: z.unknown() }), +}); + +export type StatisticsQuery = z.infer; +export type FilterOptionsQuery = z.infer; +export type ComparisonQuery = z.infer; diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 0000000..bb53ec6 --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,21 @@ +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { describe, expect, it } from 'vitest'; +import { createPirschServer } from './server.js'; + +describe('createPirschServer', () => { + it('registers the four public tools', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createPirschServer(); + await server.connect(serverTransport); + + const client = new Client({ name: 'test-client', version: '0.0.0' }); + await client.connect(clientTransport); + + expect((await client.listTools()).tools.map((tool) => tool.name)).toEqual([ + 'pirsch_list_domains', + 'pirsch_query_statistics', + 'pirsch_list_filter_options', + 'pirsch_compare_periods', + ]); + }); +}); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..0823574 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,213 @@ +import { McpServer } from '@modelcontextprotocol/server'; +import { filterOptionMetrics, statisticsMetrics } from './metrics.js'; +import { PirschClient, type PirschClientOptions } from './pirsch-client.js'; +import { + comparisonInputSchema, + comparisonOutputSchema, + domainsOutputSchema, + filterOptionsInputSchema, + filterOptionsOutputSchema, + listDomainsInputSchema, + statisticsOutputSchema, + statisticsQuerySchema, + type ComparisonQuery, + type FilterOptionsQuery, + type StatisticsQuery, +} from './schemas.js'; +import type { PirschCredentials, PirschFilter, SafeDomain, StatisticsTotals, VisitorsPoint } from './types.js'; +import { getDateRange, isoDate, pctChange } from './utils.js'; + +export interface PirschReader { + listDomains(): Promise; + get(endpoint: string, domainId: string, filter?: PirschFilter): Promise; +} + +export interface PirschServerOptions { + clientFactory?: () => PirschReader; + credentials?: PirschCredentials; + clientOptions?: PirschClientOptions; + defaultDomainId?: string; +} + +const readOnlyAnnotations = { readOnlyHint: true, destructiveHint: false, openWorldHint: true } as const; + +function jsonResult(output: T) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(output, null, 2) }], + structuredContent: output, + }; +} + +function errorResult(error: unknown) { + const message = error instanceof Error ? error.message : 'Pirsch request failed.'; + return { content: [{ type: 'text' as const, text: message }], isError: true }; +} + +function resolveDomain(domainId: string | undefined, defaultDomainId: string | undefined): string { + const resolved = domainId ?? defaultDomainId; + if (!resolved) { + throw new Error('Specify domainId or configure PIRSCH_DEFAULT_DOMAIN_ID. This server never chooses a domain automatically.'); + } + return resolved; +} + +function validateStatisticQuery(input: StatisticsQuery): void { + const definition = statisticsMetrics[input.metric]; + if (definition.dateRange === 'required' && (!input.from || !input.to)) { + throw new Error(`metric '${input.metric}' requires both from and to dates.`); + } + const hasFilter = Object.entries(input).some(([key, value]) => key !== 'domainId' && key !== 'metric' && value !== undefined); + if (definition.dateRange === 'forbidden' && hasFilter) { + throw new Error(`metric '${input.metric}' does not accept filters.`); + } + const requirements = 'requires' in definition ? definition.requires : []; + for (const field of requirements) { + if (!(input as Record)[field]) { + throw new Error(`metric '${input.metric}' requires ${field}.`); + } + } +} + +function numberRecord(value: unknown): Record { + if (!value || typeof value !== 'object') return {}; + return Object.fromEntries(Object.entries(value as Record).filter(([, item]) => typeof item === 'number')) as Record; +} + +function previousRange(from: string, to: string): { from: string; to: string } { + const start = new Date(`${from}T00:00:00.000Z`); + const end = new Date(`${to}T00:00:00.000Z`); + const spanMs = end.getTime() - start.getTime() + 24 * 60 * 60 * 1_000; + return { + from: isoDate(new Date(start.getTime() - spanMs)), + to: isoDate(new Date(end.getTime() - spanMs)), + }; +} + +function resolveComparisonRanges(input: ComparisonQuery) { + if (input.from && input.to && input.compareFrom && input.compareTo) { + return { current: { from: input.from, to: input.to }, previous: { from: input.compareFrom, to: input.compareTo } }; + } + if (input.period) { + const range = getDateRange(input.period); + const current = { from: isoDate(range.start), to: isoDate(range.end) }; + return { current, previous: previousRange(current.from, current.to) }; + } + throw new Error('Provide period or from/to plus compareFrom/compareTo.'); +} + +async function comparePeriods(reader: PirschReader, domainId: string, input: ComparisonQuery) { + const { current, previous } = resolveComparisonRanges(input); + const { domainId: _domainId, period: _period, compareFrom: _compareFrom, compareTo: _compareTo, ...filters } = input; + const currentFilter = { ...filters, ...current }; + const previousFilter = { ...filters, ...previous }; + const [currentTotals, previousTotals, currentSeries, previousSeries] = await Promise.all([ + reader.get('/statistics/total', domainId, currentFilter), + reader.get('/statistics/total', domainId, previousFilter), + reader.get('/statistics/visitor', domainId, currentFilter), + reader.get('/statistics/visitor', domainId, previousFilter), + ]); + const currentNumbers = numberRecord(currentTotals); + const previousNumbers = numberRecord(previousTotals); + const totals = Object.fromEntries( + [...new Set([...Object.keys(currentNumbers), ...Object.keys(previousNumbers)])].map((key) => { + const currentValue = currentNumbers[key] ?? 0; + const previousValue = previousNumbers[key] ?? 0; + return [key, { current: currentValue, previous: previousValue, change: pctChange(currentValue, previousValue) }]; + }) + ); + return { domainId, current, previous, totals, series: { current: currentSeries, previous: previousSeries } }; +} + +export function createPirschServer(options: PirschServerOptions = {}): McpServer { + const defaultDomainId = options.defaultDomainId ?? process.env.PIRSCH_DEFAULT_DOMAIN_ID; + let reader: PirschReader | undefined; + const getReader = () => { + reader ??= options.clientFactory?.() ?? new PirschClient( + options.credentials ?? { clientId: process.env.PIRSCH_CLIENT_ID, clientSecret: process.env.PIRSCH_CLIENT_SECRET }, + options.clientOptions ?? { timezone: process.env.PIRSCH_TIMEZONE } + ); + return reader; + }; + + const server = new McpServer({ name: 'mcp-pirsch', version: '1.0.0' }); + + server.registerTool( + 'pirsch_list_domains', + { + title: 'List Pirsch domains', + description: 'List safe summaries of domains available to the configured read-only Pirsch OAuth client.', + inputSchema: listDomainsInputSchema, + outputSchema: domainsOutputSchema, + annotations: readOnlyAnnotations, + }, + async () => { + try { + return jsonResult({ domains: await getReader().listDomains() }); + } catch (error) { + return errorResult(error); + } + } + ); + + server.registerTool( + 'pirsch_query_statistics', + { + title: 'Query Pirsch analytics', + description: 'Read one documented Pirsch Analytics API v1 metric for an explicitly selected domain and filter.', + inputSchema: statisticsQuerySchema, + outputSchema: statisticsOutputSchema, + annotations: readOnlyAnnotations, + }, + async (input) => { + try { + validateStatisticQuery(input); + const domainId = resolveDomain(input.domainId, defaultDomainId); + const { domainId: _domainId, metric, ...filter } = input; + return jsonResult({ domainId, metric, data: await getReader().get(statisticsMetrics[metric].endpoint, domainId, filter) }); + } catch (error) { + return errorResult(error); + } + } + ); + + server.registerTool( + 'pirsch_list_filter_options', + { + title: 'List Pirsch filter options', + description: 'List supported values for one documented Pirsch Analytics API v1 filter dimension.', + inputSchema: filterOptionsInputSchema, + outputSchema: filterOptionsOutputSchema, + annotations: readOnlyAnnotations, + }, + async (input: FilterOptionsQuery) => { + try { + const domainId = resolveDomain(input.domainId, defaultDomainId); + const { domainId: _domainId, option, ...filter } = input; + return jsonResult({ domainId, option, data: await getReader().get(filterOptionMetrics[option], domainId, filter) }); + } catch (error) { + return errorResult(error); + } + } + ); + + server.registerTool( + 'pirsch_compare_periods', + { + title: 'Compare Pirsch periods', + description: 'Compare Pirsch totals and visitor series for a named or explicitly supplied pair of periods.', + inputSchema: comparisonInputSchema, + outputSchema: comparisonOutputSchema, + annotations: readOnlyAnnotations, + }, + async (input) => { + try { + const domainId = resolveDomain(input.domainId, defaultDomainId); + return jsonResult(await comparePeriods(getReader(), domainId, input)); + } catch (error) { + return errorResult(error); + } + } + ); + + return server; +} diff --git a/src/utils.test.ts b/src/utils.test.ts index 141f479..11e0e6f 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -88,46 +88,54 @@ describe('utils', () => { it('should return today range', () => { const { start, end } = getDateRange('today'); - expect(start.getFullYear()).toBe(2024); - expect(start.getMonth()).toBe(2); // March (0-indexed) - expect(start.getDate()).toBe(15); - expect(start.getHours()).toBe(0); - expect(end.getHours()).toBe(23); + expect(start.getUTCFullYear()).toBe(2024); + expect(start.getUTCMonth()).toBe(2); // March (0-indexed) + expect(start.getUTCDate()).toBe(15); + expect(start.getUTCHours()).toBe(0); + expect(end.getUTCHours()).toBe(23); }); it('should return yesterday range', () => { const { start, end } = getDateRange('yesterday'); - expect(start.getDate()).toBe(14); - expect(end.getDate()).toBe(14); + expect(start.getUTCDate()).toBe(14); + expect(end.getUTCDate()).toBe(14); }); it('should return current week range (Monday to Sunday)', () => { const { start, end } = getDateRange('week'); // March 15, 2024 is Friday, week starts Monday March 11 - expect(start.getDate()).toBe(11); - expect(end.getDate()).toBe(17); // Sunday + expect(start.getUTCDate()).toBe(11); + expect(end.getUTCDate()).toBe(17); // Sunday }); it('should return last week range', () => { const { start, end } = getDateRange('lastWeek'); // Previous week: March 4-10 - expect(start.getDate()).toBe(4); - expect(end.getDate()).toBe(10); + expect(start.getUTCDate()).toBe(4); + expect(end.getUTCDate()).toBe(10); }); it('should return current month range', () => { const { start, end } = getDateRange('month'); - expect(start.getDate()).toBe(1); - expect(end.getDate()).toBe(31); // March has 31 days + expect(start.getUTCDate()).toBe(1); + expect(end.getUTCDate()).toBe(31); // March has 31 days }); it('should return last month range', () => { const { start, end } = getDateRange('lastMonth'); // February 2024 (leap year, so 29 days) - expect(start.getMonth()).toBe(1); // February - expect(start.getDate()).toBe(1); - expect(end.getMonth()).toBe(1); - expect(end.getDate()).toBe(29); + expect(start.getUTCMonth()).toBe(1); // February + expect(start.getUTCDate()).toBe(1); + expect(end.getUTCMonth()).toBe(1); + expect(end.getUTCDate()).toBe(29); + }); + + it('uses UTC calendar dates at a local-day boundary', () => { + vi.setSystemTime(new Date('2024-03-15T23:30:00.000Z')); + const { start, end } = getDateRange('today'); + + expect(isoDate(start)).toBe('2024-03-15'); + expect(isoDate(end)).toBe('2024-03-15'); }); }); }); diff --git a/src/utils.ts b/src/utils.ts index a188cae..04853f1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,56 +2,49 @@ import type { VisitorsPoint } from './types.js'; export function getDateRange(period: 'today' | 'yesterday' | 'week' | 'lastWeek' | 'month' | 'lastMonth') { const now = new Date(); - const start = new Date(); - const end = new Date(); + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const end = new Date(start); switch (period) { case 'today': - start.setHours(0, 0, 0, 0); - break; + end.setUTCHours(23, 59, 59, 999); + return { start, end }; case 'yesterday': - start.setDate(start.getDate() - 1); - start.setHours(0, 0, 0, 0); - end.setDate(start.getDate()); - end.setHours(23, 59, 59, 999); + start.setUTCDate(start.getUTCDate() - 1); + end.setTime(start.getTime()); + end.setUTCHours(23, 59, 59, 999); return { start, end }; case 'week': { - const day = now.getDay(); - const diff = now.getDate() - day + (day === 0 ? -6 : 1); - start.setDate(diff); - start.setHours(0, 0, 0, 0); + const day = now.getUTCDay(); + start.setUTCDate(start.getUTCDate() - ((day + 6) % 7)); end.setTime(start.getTime()); - end.setDate(start.getDate() + 6); - end.setHours(23, 59, 59, 999); + end.setUTCDate(start.getUTCDate() + 6); + end.setUTCHours(23, 59, 59, 999); return { start, end }; } case 'lastWeek': { - const day = now.getDay(); - const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; - start.setDate(diff); - start.setHours(0, 0, 0, 0); + const day = now.getUTCDay(); + start.setUTCDate(start.getUTCDate() - ((day + 6) % 7) - 7); end.setTime(start.getTime()); - end.setDate(start.getDate() + 6); - end.setHours(23, 59, 59, 999); + end.setUTCDate(start.getUTCDate() + 6); + end.setUTCHours(23, 59, 59, 999); return { start, end }; } case 'month': { - start.setDate(1); - start.setHours(0, 0, 0, 0); - end.setMonth(start.getMonth() + 1, 0); - end.setHours(23, 59, 59, 999); + start.setUTCDate(1); + end.setUTCMonth(start.getUTCMonth() + 1, 0); + end.setUTCHours(23, 59, 59, 999); return { start, end }; } case 'lastMonth': { - start.setMonth(start.getMonth() - 1, 1); - start.setHours(0, 0, 0, 0); - end.setMonth(start.getMonth() + 1, 0); - end.setHours(23, 59, 59, 999); + start.setUTCMonth(start.getUTCMonth() - 1, 1); + end.setTime(start.getTime()); + end.setUTCMonth(start.getUTCMonth() + 1, 0); + end.setUTCHours(23, 59, 59, 999); return { start, end }; } } - // Default today - end.setHours(23, 59, 59, 999); + end.setUTCHours(23, 59, 59, 999); return { start, end }; } @@ -74,4 +67,3 @@ export function pctChange(curr: number, prev: number): number | null { if (prev === 0) return null; return (curr - prev) / prev; } - From 79d5c65c4c6e2a1dde060b0330cab83214af6e87 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Sun, 23 Aug 2026 21:28:57 +0200 Subject: [PATCH 05/12] fix(server): validate comparison ranges and timezone defaults --- src/mcp.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++-- src/schemas.ts | 6 +++--- src/server.ts | 2 +- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/mcp.test.ts b/src/mcp.test.ts index 0feac8f..b63e49e 100644 --- a/src/mcp.test.ts +++ b/src/mcp.test.ts @@ -11,9 +11,9 @@ afterEach(async () => { await Promise.all(servers.splice(0).map((server) => server.close())); }); -async function connect(clientFactory: () => PirschReader) { +async function connectOptions(options: Parameters[0]) { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const server = createPirschServer({ clientFactory, defaultDomainId: 'default-domain' }); + const server = createPirschServer(options); const client = new Client({ name: 'test-client', version: '0.0.0' }); servers.push(server); clients.push(client); @@ -22,6 +22,10 @@ async function connect(clientFactory: () => PirschReader) { return client; } +async function connect(clientFactory: () => PirschReader) { + return connectOptions({ clientFactory, defaultDomainId: 'default-domain' }); +} + describe('Pirsch MCP tool contracts', () => { it('returns only safe domains as structured content with a JSON text fallback', async () => { const listDomains = vi.fn().mockResolvedValue([{ id: 'domain-1', hostname: 'example.com', timezone: 'UTC' }]); @@ -48,6 +52,14 @@ describe('Pirsch MCP tool contracts', () => { expect(statisticsQuerySchema.safeParse({ metric: 'pages', fromTime: '99:99' }).success).toBe(false); expect(statisticsQuerySchema.safeParse({ metric: 'pages', from: '2026-08-31', to: '2026-08-01' }).success).toBe(false); expect(statisticsQuerySchema.safeParse({ metric: 'pages', from: '2026-08-01', to: '2026-08-01', fromTime: '18:00', toTime: '09:00' }).success).toBe(false); + expect(comparisonInputSchema.safeParse({ + from: '2026-08-01', + to: '2026-08-02', + compareFrom: '2026-07-31', + compareTo: '2026-07-31', + fromTime: '18:00', + toTime: '09:00', + }).success).toBe(false); expect(comparisonInputSchema.safeParse({ period: 'week', from: '2026-08-01', to: '2026-08-02' }).success).toBe(false); expect(filterOptionsInputSchema.safeParse({ option: 'tagValue' }).success).toBe(false); expect(filterOptionsInputSchema.safeParse({ option: 'metadata' }).success).toBe(false); @@ -69,4 +81,31 @@ describe('Pirsch MCP tool contracts', () => { expect(result.isError).toBeUndefined(); expect(get).toHaveBeenCalledWith('/options/event', 'default-domain', {}); }); + + it('keeps the environment timezone when custom client options are supplied', async () => { + const originalTimezone = process.env.PIRSCH_TIMEZONE; + process.env.PIRSCH_TIMEZONE = 'Europe/Berlin'; + const fetch = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'test-token', expires_at: '2099-01-01T00:00:00.000Z' }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ visitors: 1 }))); + + try { + const client = await connectOptions({ + credentials: { clientId: 'client-id', clientSecret: 'client-secret' }, + clientOptions: { fetch, timeoutMs: 1_000 }, + defaultDomainId: 'default-domain', + }); + + const result = await client.callTool({ + name: 'pirsch_query_statistics', + arguments: { metric: 'total', from: '2026-08-01', to: '2026-08-02' }, + }); + + expect(result.isError).toBeUndefined(); + expect(new URL(fetch.mock.calls[1][0] as URL).searchParams.get('tz')).toBe('Europe/Berlin'); + } finally { + if (originalTimezone === undefined) delete process.env.PIRSCH_TIMEZONE; + else process.env.PIRSCH_TIMEZONE = originalTimezone; + } + }); }); diff --git a/src/schemas.ts b/src/schemas.ts index eb25d81..5e886b2 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -62,9 +62,9 @@ function validateChronologicalRanges(input: DateRangeInput, ctx: z.RefinementCtx if (from && to && from > to) { ctx.addIssue({ code: 'custom', path: [toKey], message: `${fromKey} must be on or before ${toKey}.` }); } - } - if (input.from && input.to && input.from === input.to && input.fromTime && input.toTime && input.fromTime > input.toTime) { - ctx.addIssue({ code: 'custom', path: ['toTime'], message: 'toTime must be on or after fromTime for a same-day range.' }); + if (from && to && from === to && input.fromTime && input.toTime && input.fromTime > input.toTime) { + ctx.addIssue({ code: 'custom', path: ['toTime'], message: 'toTime must be on or after fromTime for a same-day range.' }); + } } } diff --git a/src/server.ts b/src/server.ts index 0823574..fb03617 100644 --- a/src/server.ts +++ b/src/server.ts @@ -124,7 +124,7 @@ export function createPirschServer(options: PirschServerOptions = {}): McpServer const getReader = () => { reader ??= options.clientFactory?.() ?? new PirschClient( options.credentials ?? { clientId: process.env.PIRSCH_CLIENT_ID, clientSecret: process.env.PIRSCH_CLIENT_SECRET }, - options.clientOptions ?? { timezone: process.env.PIRSCH_TIMEZONE } + { ...options.clientOptions, timezone: options.clientOptions?.timezone ?? process.env.PIRSCH_TIMEZONE } ); return reader; }; From 829a03bcb53a8d900cec6831da9cf46938a7c735 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Sun, 23 Aug 2026 21:48:26 +0200 Subject: [PATCH 06/12] test(index): retire legacy dispatcher contract --- src/index.test.ts | 203 ---------------------------------------------- 1 file changed, 203 deletions(-) delete mode 100644 src/index.test.ts diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index 8d5c509..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import type { StatisticsTotals, VisitorsPoint } from './types.js'; - -process.env.PIRSCH_CLIENT_ID = process.env.PIRSCH_CLIENT_ID || 'test-client-id'; -process.env.PIRSCH_CLIENT_SECRET = process.env.PIRSCH_CLIENT_SECRET || 'test-client-secret'; - -const { getComparisonResponse, getLocalPathPrefix, getPathFilteredStatistics, normalizeFilterArgs } = await import('./index.js'); - -describe('getComparisonResponse', () => { - it('uses statistics/total for totals and statistics/visitor for chart series', async () => { - const currentTotals: StatisticsTotals = { - visitors: 100, - views: 250, - sessions: 120, - bounces: 45, - bounce_rate: 0.375, - cr: 0.12, - custom_metric_avg: 14.5, - custom_metric_total: 1450, - }; - const previousTotals: StatisticsTotals = { - visitors: 80, - views: 200, - sessions: 110, - bounces: 50, - bounce_rate: 0.4545, - cr: 0.08, - custom_metric_avg: 10, - custom_metric_total: 800, - }; - const currentSeries: VisitorsPoint[] = [ - { day: '2024-01-01T00:00:00Z', visitors: 40, views: 100, sessions: 50, bounces: 20, bounce_rate: 0.4, cr: 0.1 }, - ]; - const previousSeries: VisitorsPoint[] = [ - { day: '2023-12-25T00:00:00Z', visitors: 30, views: 90, sessions: 45, bounces: 18, bounce_rate: 0.4, cr: 0.08 }, - ]; - - const getStatistics = vi - .fn() - .mockResolvedValueOnce(currentTotals) - .mockResolvedValueOnce(previousTotals) - .mockResolvedValueOnce(currentSeries) - .mockResolvedValueOnce(previousSeries); - - const result = await getComparisonResponse( - { getStatistics }, - 'domain-1', - { - compare: 'custom', - from: '2024-01-01', - to: '2024-01-07', - compare_from: '2023-12-25', - compare_to: '2023-12-31', - scale: 'week', - } - ); - - expect(getStatistics).toHaveBeenNthCalledWith(1, '/statistics/total', 'domain-1', { - from: '2024-01-01', - to: '2024-01-07', - }); - expect(getStatistics).toHaveBeenNthCalledWith(2, '/statistics/total', 'domain-1', { - from: '2023-12-25', - to: '2023-12-31', - }); - expect(getStatistics).toHaveBeenNthCalledWith(3, '/statistics/visitor', 'domain-1', { - from: '2024-01-01', - to: '2024-01-07', - scale: 'week', - }); - expect(getStatistics).toHaveBeenNthCalledWith(4, '/statistics/visitor', 'domain-1', { - from: '2023-12-25', - to: '2023-12-31', - scale: 'week', - }); - - expect(result.totals.visitors).toEqual({ current: 100, previous: 80, change: 0.25 }); - expect(result.totals.bounce_rate.current).toBe(0.375); - expect(result.totals.cr.current).toBe(0.12); - expect(result.totals.custom_metric_total.current).toBe(1450); - expect(result.series.current).toEqual(currentSeries); - expect(result.series.previous).toEqual(previousSeries); - }); - - it('rejects invalid compare input', async () => { - const getStatistics = vi.fn(); - - await expect(getComparisonResponse({ getStatistics }, 'domain-1', { compare: 'custom' })).rejects.toThrow( - 'Provide either period or custom from/to + compare_from/compare_to' - ); - expect(getStatistics).not.toHaveBeenCalled(); - }); -}); - -describe('normalizeFilterArgs', () => { - it('merges top-level filter args for callers that do not nest filter', () => { - expect( - normalizeFilterArgs({ - from: '2024-03-25', - to: '2026-03-25', - search: '/news/', - limit: 5, - sort: 'visitors', - direction: 'desc', - }) - ).toEqual({ - from: '2024-03-25', - to: '2026-03-25', - search: '/news/', - limit: 5, - sort: 'visitors', - direction: 'desc', - }); - }); - - it('prefers explicit nested filter values and supports event_name alias', () => { - expect( - normalizeFilterArgs({ - event: 'Top Level Event', - event_name: 'Order', - filter: { - search: '/tutorials/', - event_name: 'Live Demo Signup', - limit: 10, - }, - }) - ).toEqual({ - search: '/tutorials/', - event: 'Top Level Event', - limit: 10, - }); - - expect( - normalizeFilterArgs({ - event_name: 'Order', - filter: { - from: '2024-03-25', - to: '2026-03-25', - }, - }) - ).toEqual({ - from: '2024-03-25', - to: '2026-03-25', - event: 'Order', - }); - }); -}); - -describe('getLocalPathPrefix', () => { - it('derives a root-prefix matcher from path-shaped search and operator filters', () => { - expect(getLocalPathPrefix({ search: '/tutorials/' })).toBe('/tutorials/'); - expect(getLocalPathPrefix({ path: '~/tutorials/' })).toBe('/tutorials/'); - expect(getLocalPathPrefix({ pattern: '/tutorials/*' })).toBe('/tutorials/'); - expect(getLocalPathPrefix({ path_prefix: '/news/' })).toBe('/news/'); - }); - - it('leaves exact path filters alone', () => { - expect(getLocalPathPrefix({ path: '/tutorials/' })).toBeUndefined(); - expect(getLocalPathPrefix({ search: 'tutorials' })).toBeUndefined(); - }); -}); - -describe('getPathFilteredStatistics', () => { - it('fetches additional batches until it has enough prefix matches', async () => { - const firstBatch = Array.from({ length: 99 }, (_, index) => ({ - path: `/documentation/tutorials/article-${index}/`, - })); - firstBatch.push({ path: '/tutorials/root-one/' }); - - const secondBatch = [ - { path: '/tutorials/root-two/' }, - { path: '/tutorials/root-three/' }, - ]; - - const getStatistics = vi - .fn() - .mockResolvedValueOnce(firstBatch) - .mockResolvedValueOnce(secondBatch); - - const result = await getPathFilteredStatistics( - { getStatistics }, - '/statistics/page', - 'domain-1', - { search: '/tutorials/', limit: 2 }, - '/tutorials/' - ); - - expect(getStatistics).toHaveBeenNthCalledWith(1, '/statistics/page', 'domain-1', { - search: '/tutorials/', - limit: 100, - offset: 0, - }); - expect(getStatistics).toHaveBeenNthCalledWith(2, '/statistics/page', 'domain-1', { - search: '/tutorials/', - limit: 100, - offset: 100, - }); - expect(result).toEqual([ - { path: '/tutorials/root-one/' }, - { path: '/tutorials/root-two/' }, - ]); - }); -}); From e2ee3ae7c8fc19e4d46904a05ff887aeadef913e Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Sun, 23 Aug 2026 21:50:37 +0200 Subject: [PATCH 07/12] feat(server): cut over stdio entrypoint to v2 tools --- src/index.ts | 668 +-------------------------------------------------- 1 file changed, 4 insertions(+), 664 deletions(-) diff --git a/src/index.ts b/src/index.ts index d2d12ff..7047e07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,668 +1,8 @@ #!/usr/bin/env node -import { realpathSync } from 'fs'; -import { resolve } from 'path'; -import { pathToFileURL } from 'url'; -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { installStdioLifecycle } from './lifecycle.js'; -import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'; import { config } from 'dotenv'; -import { PirschAPI } from './pirsch-api.js'; -import type { Domain, FilterInput, StatisticsTotals, VisitorsPoint } from './types.js'; -import { getDateRange, isoDate, pctChange } from './utils.js'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { createPirschServer } from './server.js'; config({ quiet: true }); - -const CLIENT_ID = process.env.PIRSCH_CLIENT_ID; -const CLIENT_SECRET = process.env.PIRSCH_CLIENT_SECRET; -const DEFAULT_DOMAIN_ID = process.env.PIRSCH_DEFAULT_DOMAIN_ID; - -if (!CLIENT_ID || !CLIENT_SECRET) { - console.error('Missing required env: PIRSCH_CLIENT_ID or PIRSCH_CLIENT_SECRET'); - process.exit(1); -} - -const api = new PirschAPI(CLIENT_ID, CLIENT_SECRET); - -type ToolArguments = Record; -type PeriodName = 'today' | 'yesterday' | 'week' | 'lastWeek' | 'month' | 'lastMonth'; -type ScaleName = 'day' | 'week' | 'month' | 'year'; -type CompareMode = 'previous' | 'year' | 'custom'; -type SchemaProperty = { [key: string]: unknown }; -type ToolInputSchema = { - type: 'object'; - properties: Record; - required?: string[]; -}; - -interface StatisticsToolConfig { - name: string; - description: string; - endpoint: string; - resultKey: string; - supportsLocalPathPrefix?: boolean; - validateFilter?: (filter: FilterInput) => void; -} - -interface StatisticsReader { - getStatistics(endpoint: string, domainId: string, filter?: FilterInput): Promise; -} - -interface PathRow { - path?: string | null; -} - -const DEFAULT_LOCAL_FILTER_BATCH_SIZE = 100; -const MAX_LOCAL_FILTER_BATCHES = 20; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function isDomain(value: unknown): value is Domain { - return isRecord(value) && typeof value.id === 'string'; -} - -function formatResponse(payload: unknown) { - return { - content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }], - }; -} - -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'An error occurred'; -} - -function readArgs(value: unknown): ToolArguments | undefined { - return isRecord(value) ? value : undefined; -} - -function readOptionalString(args: ToolArguments | undefined, key: string): string | undefined { - const value = args?.[key]; - return typeof value === 'string' && value.trim() !== '' ? value : undefined; -} - -function readOptionalNumber(args: ToolArguments | undefined, key: string): number | undefined { - const value = args?.[key]; - return typeof value === 'number' ? value : undefined; -} - -function readFilter(args: ToolArguments | undefined): FilterInput { - const filter = args?.filter; - return isRecord(filter) ? (filter as FilterInput) : {}; -} - -function readFilterAlias( - args: ToolArguments | undefined, - nestedFilter: FilterInput, - key: 'event_name' -): string | undefined { - const nestedValue = nestedFilter[key]; - if (typeof nestedValue === 'string' && nestedValue.trim() !== '') { - return nestedValue; - } - - const topLevelValue = args?.[key]; - return typeof topLevelValue === 'string' && topLevelValue.trim() !== '' ? topLevelValue : undefined; -} - -export function normalizeFilterArgs(args: ToolArguments | undefined): FilterInput { - const nestedFilter = readFilter(args); - const mergedFilter: FilterInput = { ...nestedFilter }; - const mergedFilterRecord = mergedFilter as Record; - - for (const key of Object.keys(filterSchemaProperties) as Array) { - const value = args?.[key]; - if (value !== undefined && mergedFilter[key] === undefined) { - mergedFilterRecord[key] = value; - } - } - - if (!mergedFilter.event) { - const eventAlias = readFilterAlias(args, nestedFilter, 'event_name'); - if (eventAlias) { - mergedFilter.event = eventAlias; - } - } - - delete mergedFilter.event_name; - - return mergedFilter; -} - -function readTrimmedString(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; -} - -function normalizePathPrefix(value: string | undefined): string | undefined { - if (!value || !value.startsWith('/')) { - return undefined; - } - - return value; -} - -function extractPatternPrefix(pattern: string | undefined): string | undefined { - if (!pattern || !pattern.startsWith('/') || !pattern.endsWith('*')) { - return undefined; - } - - const prefix = pattern.slice(0, -1); - return prefix.endsWith('/') ? prefix : undefined; -} - -export function getLocalPathPrefix(filter: FilterInput): string | undefined { - const explicitPrefix = normalizePathPrefix(readTrimmedString(filter.path_prefix)); - if (explicitPrefix) { - return explicitPrefix; - } - - const searchPrefix = normalizePathPrefix(readTrimmedString(filter.search)); - if (searchPrefix) { - return searchPrefix; - } - - const path = readTrimmedString(filter.path); - if (path?.startsWith('~/')) { - return normalizePathPrefix(path.slice(1)); - } - - return extractPatternPrefix(readTrimmedString(filter.pattern)); -} - -function filterRowsByPathPrefix(rows: T[], prefix: string): T[] { - return rows.filter((row) => isRecord(row) && typeof row.path === 'string' && row.path.startsWith(prefix)); -} - -function buildApiFilterForLocalPathPrefix( - filter: FilterInput, - prefix: string, - offset: number, - limit: number -): FilterInput { - const apiFilter: FilterInput = { - ...filter, - offset, - limit, - }; - - delete apiFilter.path_prefix; - - if (!apiFilter.search && !apiFilter.path && !apiFilter.pattern) { - apiFilter.search = prefix; - } - - return apiFilter; -} - -export async function getPathFilteredStatistics( - client: StatisticsReader, - endpoint: string, - domainId: string, - filter: FilterInput, - prefix: string -): Promise { - const requestedOffset = filter.offset ?? 0; - const requestedLimit = filter.limit ?? DEFAULT_LOCAL_FILTER_BATCH_SIZE; - const targetCount = requestedOffset + requestedLimit; - const batchSize = Math.max(requestedLimit, DEFAULT_LOCAL_FILTER_BATCH_SIZE); - const matches: PathRow[] = []; - - for (let batchIndex = 0; batchIndex < MAX_LOCAL_FILTER_BATCHES && matches.length < targetCount; batchIndex += 1) { - const data = await client.getStatistics( - endpoint, - domainId, - buildApiFilterForLocalPathPrefix(filter, prefix, batchIndex * batchSize, batchSize) - ); - - if (!Array.isArray(data)) { - return data; - } - - matches.push(...filterRowsByPathPrefix(data, prefix)); - - if (data.length < batchSize) { - break; - } - } - - return matches.slice(requestedOffset, targetCount); -} - -function requireFilterString(filter: FilterInput, key: 'event' | 'visitor_id' | 'session_id', toolName: string): string { - const value = filter[key]; - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`filter.${key} is required for ${toolName}`); - } - return value; -} - -function buildFilterCapableInputSchema( - extraProperties: Record = {}, - required?: string[] -): ToolInputSchema { - return { - type: 'object', - properties: { - domain_id: domainIdSchema, - filter: filterSchema, - ...filterSchemaProperties, - ...extraProperties, - }, - ...(required ? { required } : {}), - }; -} - -function compareMetric(current: number, previous: number) { - return { current, previous, change: pctChange(current, previous) }; -} - -export function buildComparisonTotals(current: StatisticsTotals, previous: StatisticsTotals) { - return { - visitors: compareMetric(current.visitors, previous.visitors), - views: compareMetric(current.views, previous.views), - sessions: compareMetric(current.sessions, previous.sessions), - bounces: compareMetric(current.bounces, previous.bounces), - bounce_rate: compareMetric(current.bounce_rate, previous.bounce_rate), - cr: compareMetric(current.cr, previous.cr), - custom_metric_avg: compareMetric(current.custom_metric_avg, previous.custom_metric_avg), - custom_metric_total: compareMetric(current.custom_metric_total, previous.custom_metric_total), - }; -} - -export async function getComparisonResponse( - client: StatisticsReader, - domainId: string, - args: ToolArguments | undefined -) { - const scale = (readOptionalString(args, 'scale') as ScaleName | undefined) || 'day'; - const compareMode = (readOptionalString(args, 'compare') as CompareMode | undefined) || 'previous'; - const period = readOptionalString(args, 'period') as PeriodName | undefined; - - let currentFrom: string; - let currentTo: string; - let previousFrom: string; - let previousTo: string; - - if (period) { - const range = getDateRange(period); - currentFrom = isoDate(range.start); - currentTo = isoDate(range.end); - - if (compareMode === 'year') { - const previousStart = new Date(range.start); - const previousEnd = new Date(range.end); - previousStart.setFullYear(previousStart.getFullYear() - 1); - previousEnd.setFullYear(previousEnd.getFullYear() - 1); - previousFrom = isoDate(previousStart); - previousTo = isoDate(previousEnd); - } else { - const lengthInDays = - Math.ceil((range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)) + 1; - const previousEnd = new Date(range.start); - previousEnd.setDate(previousEnd.getDate() - 1); - const previousStart = new Date(previousEnd); - previousStart.setDate(previousEnd.getDate() - (lengthInDays - 1)); - previousFrom = isoDate(previousStart); - previousTo = isoDate(previousEnd); - } - } else if ( - compareMode === 'custom' && - readOptionalString(args, 'from') && - readOptionalString(args, 'to') && - readOptionalString(args, 'compare_from') && - readOptionalString(args, 'compare_to') - ) { - currentFrom = readOptionalString(args, 'from')!; - currentTo = readOptionalString(args, 'to')!; - previousFrom = readOptionalString(args, 'compare_from')!; - previousTo = readOptionalString(args, 'compare_to')!; - } else { - throw new Error('Provide either period or custom from/to + compare_from/compare_to'); - } - - const [currentTotals, previousTotals, currentSeries, previousSeries] = await Promise.all([ - client.getStatistics('/statistics/total', domainId, { - from: currentFrom, - to: currentTo, - }), - client.getStatistics('/statistics/total', domainId, { - from: previousFrom, - to: previousTo, - }), - client.getStatistics('/statistics/visitor', domainId, { - from: currentFrom, - to: currentTo, - scale, - }), - client.getStatistics('/statistics/visitor', domainId, { - from: previousFrom, - to: previousTo, - scale, - }), - ]); - - return { - period: { from: currentFrom, to: currentTo }, - compare_to: { from: previousFrom, to: previousTo }, - totals: buildComparisonTotals(currentTotals, previousTotals), - series: { current: currentSeries, previous: previousSeries }, - }; -} - -async function resolveDomainId(argId?: string): Promise { - if (argId) return argId; - if (DEFAULT_DOMAIN_ID) return DEFAULT_DOMAIN_ID; - const res = await api.listDomains(); - if (Array.isArray(res) && res.length > 0) return res[0].id; - if (isDomain(res)) return res.id; - throw new Error('No domain found. Set PIRSCH_DEFAULT_DOMAIN_ID or provide domain_id'); -} - -const server = new Server( - { name: 'mcp-pirsch', version: '0.1.0' }, - { capabilities: { tools: {} } } -); - -const filterSchemaProperties = { - from: { type: 'string', description: 'YYYY-MM-DD' }, - to: { type: 'string', description: 'YYYY-MM-DD' }, - from_time: { type: 'string', description: 'HH:MM (same-day only)' }, - to_time: { type: 'string', description: 'HH:MM (same-day only)' }, - tz: { type: 'string' }, - start: { type: 'number', description: 'Past seconds for active view' }, - scale: { type: 'string', enum: ['day', 'week', 'month', 'year'] }, - hostname: { type: 'string' }, - path: { type: 'string', description: 'Supports Pirsch operators like ~contains, !not, and ^does-not-contain' }, - path_prefix: { type: 'string', description: 'MCP-local path prefix filter for page-style tools, e.g. /tutorials/' }, - entry_path: { type: 'string' }, - exit_path: { type: 'string' }, - pattern: { type: 'string' }, - event: { type: 'string' }, - event_name: { type: 'string', description: 'Alias for event when callers use event_name instead of event' }, - event_meta_key: { type: 'string' }, - language: { type: 'string' }, - country: { type: 'string' }, - city: { type: 'string' }, - referrer: { type: 'string' }, - referrer_name: { type: 'string' }, - channel: { type: 'string' }, - os: { type: 'string' }, - browser: { type: 'string' }, - platform: { type: 'string', enum: ['desktop', 'mobile', 'unknown'] }, - screen_class: { type: 'string' }, - utm_source: { type: 'string' }, - utm_medium: { type: 'string' }, - utm_campaign: { type: 'string' }, - utm_content: { type: 'string' }, - utm_term: { type: 'string' }, - custom_metric_type: { type: 'string', enum: ['integer', 'float'] }, - custom_metric_key: { type: 'string' }, - tag: { type: 'string' }, - offset: { type: 'number' }, - limit: { type: 'number' }, - include_avg_time_on_page: { type: 'boolean' }, - include_title: { type: 'boolean' }, - sort: { type: 'string' }, - direction: { type: 'string', enum: ['asc', 'desc'] }, - search: { type: 'string', description: 'Contains search on the primary field, e.g. page path for page endpoints' }, - keyword: { type: 'string', description: 'Google Search Console keyword filter for keyword page lookups' }, - visitor_id: { type: 'string' }, - session_id: { type: 'string' }, -} as const; - -const filterSchema: ToolInputSchema = { - type: 'object', - properties: filterSchemaProperties, -}; - -const domainIdSchema = { type: 'string' } as const; - -const filterToolInputSchema = buildFilterCapableInputSchema(); - -const statisticsToolConfigs: StatisticsToolConfig[] = [ - { - name: 'pirsch_total', - description: 'Get totals for visitors, views, sessions, bounces, bounce_rate, cr, and custom metrics with filters', - endpoint: '/statistics/total', - resultKey: 'total', - }, - { - name: 'pirsch_visitors', - description: 'Get visitors time series with optional scale and filters', - endpoint: '/statistics/visitor', - resultKey: 'series', - }, - { - name: 'pirsch_pages', - description: 'Get page stats with sorting, search, and optional average time on page', - endpoint: '/statistics/page', - resultKey: 'pages', - supportsLocalPathPrefix: true, - }, - { - name: 'pirsch_entry_pages', - description: 'Get entry page stats with sorting, search, and optional average time on page', - endpoint: '/statistics/page/entry', - resultKey: 'entry_pages', - supportsLocalPathPrefix: true, - }, - { - name: 'pirsch_exit_pages', - description: 'Get exit page stats with sorting and search', - endpoint: '/statistics/page/exit', - resultKey: 'exit_pages', - supportsLocalPathPrefix: true, - }, - { - name: 'pirsch_referrers', - description: 'Get referrer statistics with filters and sorting', - endpoint: '/statistics/referrer', - resultKey: 'referrers', - }, - { - name: 'pirsch_goals', - description: 'Get conversion goals and their performance stats', - endpoint: '/statistics/goals', - resultKey: 'goals', - }, - { - name: 'pirsch_events', - description: 'Get event statistics with counts, visitors, conversion rate, and metadata keys', - endpoint: '/statistics/events', - resultKey: 'events', - }, - { - name: 'pirsch_event_pages', - description: 'Get pages on which a specific event fired. Requires filter.event', - endpoint: '/statistics/event/page', - resultKey: 'event_pages', - supportsLocalPathPrefix: true, - validateFilter: (filter) => { - requireFilterString(filter, 'event', 'pirsch_event_pages'); - }, - }, - { - name: 'pirsch_growth', - description: 'Get growth rates across core metrics for the selected period', - endpoint: '/statistics/growth', - resultKey: 'growth', - }, - { - name: 'pirsch_sessions', - description: 'Get session list with entry/exit pages, duration, device, and traffic source details', - endpoint: '/statistics/session/list', - resultKey: 'sessions', - }, - { - name: 'pirsch_session_details', - description: 'Get chronological page views and events for a single session. Requires filter.visitor_id and filter.session_id', - endpoint: '/statistics/session/details', - resultKey: 'session_details', - validateFilter: (filter) => { - requireFilterString(filter, 'visitor_id', 'pirsch_session_details'); - requireFilterString(filter, 'session_id', 'pirsch_session_details'); - }, - }, -]; - -const statisticsToolMap = new Map(statisticsToolConfigs.map((config) => [config.name, config])); - -const tools: Tool[] = [ - { - name: 'pirsch_list_domains', - description: 'List accessible Pirsch domains to discover domain IDs', - inputSchema: { type: 'object', properties: { search: { type: 'string' } } }, - }, - { - name: 'pirsch_overview', - description: 'Get cached overview statistics for a domain. Filters do not apply to this endpoint', - inputSchema: { type: 'object', properties: { domain_id: domainIdSchema } }, - }, - ...statisticsToolConfigs.map((config) => ({ - name: config.name, - description: config.description, - inputSchema: filterToolInputSchema, - })), - { - name: 'pirsch_utm', - description: 'Get UTM stats by dimension (source, medium, campaign, content, term)', - inputSchema: buildFilterCapableInputSchema( - { type: { type: 'string', enum: ['source', 'medium', 'campaign', 'content', 'term'] } }, - ['type'] - ), - }, - { - name: 'pirsch_active', - description: 'Get active visitors and pages for the past N seconds (default 600)', - inputSchema: { type: 'object', properties: { domain_id: domainIdSchema, start: { type: 'number' } } }, - }, - { - name: 'pirsch_compare', - description: 'Compare totals and visitor series between two periods using true period totals', - inputSchema: { - type: 'object', - properties: { - domain_id: domainIdSchema, - period: { type: 'string', enum: ['today', 'yesterday', 'week', 'lastWeek', 'month', 'lastMonth'] }, - compare: { - type: 'string', - enum: ['previous', 'year', 'custom'], - description: 'Compare to the previous period, same period last year, or a custom range', - }, - from: { type: 'string' }, - to: { type: 'string' }, - compare_from: { type: 'string' }, - compare_to: { type: 'string' }, - scale: { type: 'string', enum: ['day', 'week', 'month', 'year'] }, - }, - }, - }, -]; - -server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })); - -server.setRequestHandler(CallToolRequestSchema, async (req) => { - const { name, arguments: rawArgs } = req.params; - const args = readArgs(rawArgs); - - try { - if (name === 'pirsch_list_domains') { - const search = readOptionalString(args, 'search'); - const res = await api.listDomains(search ? { search } : undefined); - const arr = Array.isArray(res) ? res : [res]; - return formatResponse({ count: arr.length, domains: arr }); - } - - if (name === 'pirsch_overview') { - const domainId = await resolveDomainId(readOptionalString(args, 'domain_id')); - const data = await api.getOverview(domainId); - return formatResponse({ domain_id: domainId, overview: data }); - } - - const statisticsTool = statisticsToolMap.get(name); - if (statisticsTool) { - const domainId = await resolveDomainId(readOptionalString(args, 'domain_id')); - const filter = normalizeFilterArgs(args); - statisticsTool.validateFilter?.(filter); - const localPathPrefix = statisticsTool.supportsLocalPathPrefix ? getLocalPathPrefix(filter) : undefined; - const data = localPathPrefix - ? await getPathFilteredStatistics(api, statisticsTool.endpoint, domainId, filter, localPathPrefix) - : await api.getStatistics(statisticsTool.endpoint, domainId, filter); - return formatResponse({ domain_id: domainId, [statisticsTool.resultKey]: data }); - } - - if (name === 'pirsch_utm') { - const domainId = await resolveDomainId(readOptionalString(args, 'domain_id')); - const filter = normalizeFilterArgs(args); - const type = readOptionalString(args, 'type'); - if (!type) { - throw new Error('type is required for pirsch_utm'); - } - const endpoint = `/statistics/utm/${type}`; - const data = await api.getStatistics(endpoint, domainId, filter); - return formatResponse({ domain_id: domainId, type, utm: data }); - } - - if (name === 'pirsch_active') { - const domainId = await resolveDomainId(readOptionalString(args, 'domain_id')); - const start = readOptionalNumber(args, 'start') ?? 600; - const data = await api.getActive(domainId, start); - return formatResponse({ domain_id: domainId, start, active: data }); - } - - if (name === 'pirsch_compare') { - const domainId = await resolveDomainId(readOptionalString(args, 'domain_id')); - const result = await getComparisonResponse(api, domainId, args); - return formatResponse(result); - } - - throw new Error(`Unknown tool: ${name}`); - } catch (error) { - return formatResponse({ error: true, message: getErrorMessage(error) }); - } -}); - -async function main() { - // Capture before any await — process.ppid is dynamic. - const parentPid = process.ppid; - const transport = new StdioServerTransport(); - installStdioLifecycle({ - transport, - onCloseAssignable: server, - envName: 'PIRSCH_PARENT_WATCHDOG_MS', - parentPid, - }); - await server.connect(transport); - console.error('Pirsch MCP server running'); -} - -function isDirectExecution(): boolean { - if (typeof process.argv[1] !== 'string') { - return false; - } - - try { - // npm/npx invoke this file through a symlinked bin (e.g. node_modules/.bin/mcp-pirsch), - // so process.argv[1] is the symlink path while import.meta.url is already resolved to - // the real target. Resolve the symlink before comparing or this always evaluates false - // under npx, main() never runs, and the process exits cleanly with no output. - const entrypointPath = resolve(process.argv[1]); - const entrypointUrls = [entrypointPath, realpathSync(entrypointPath)].map((path) => - pathToFileURL(path).href - ); - - // `--preserve-symlinks-main` keeps the symlink in import.meta.url, while - // the default Node behavior resolves it. Accept both representations. - return entrypointUrls.includes(import.meta.url); - } catch { - return false; - } -} - -if (isDirectExecution()) { - main().catch((error: unknown) => { - console.error('Server error:', error); - process.exit(1); - }); -} +serveStdio(() => createPirschServer({ defaultDomainId: process.env.PIRSCH_DEFAULT_DOMAIN_ID })); +console.error('Pirsch MCP server running on stdio'); From 3097ee9089b636091a5a6fdb715609ead60db0ea Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 26 Aug 2026 00:21:22 +0200 Subject: [PATCH 08/12] fix(server): preserve v2 stdio lifecycle and manifest --- server.json | 66 +++++------------------------------------ src/index.spawn.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 24 ++++++++++++--- src/mcp.test.ts | 11 +++++++ 4 files changed, 103 insertions(+), 63 deletions(-) diff --git a/server.json b/server.json index 9a2261c..8747ced 100644 --- a/server.json +++ b/server.json @@ -22,71 +22,19 @@ "tools": [ { "name": "pirsch_list_domains", - "description": "List all domains in your Pirsch account" + "description": "List safe summaries of Pirsch domains available to the configured read-only client" }, { - "name": "pirsch_overview", - "description": "Get the cached overview snapshot for a domain; filters do not apply" + "name": "pirsch_query_statistics", + "description": "Read one documented Pirsch Analytics API v1 metric for an explicitly selected domain and filter" }, { - "name": "pirsch_total", - "description": "Get total visitors, views, sessions, bounce rate, conversion rate, and custom metrics" + "name": "pirsch_list_filter_options", + "description": "List supported values for one documented Pirsch Analytics API v1 filter dimension" }, { - "name": "pirsch_visitors", - "description": "Get visitor time-series data" - }, - { - "name": "pirsch_pages", - "description": "Get page-level analytics" - }, - { - "name": "pirsch_entry_pages", - "description": "Get entry page analytics" - }, - { - "name": "pirsch_exit_pages", - "description": "Get exit page analytics" - }, - { - "name": "pirsch_referrers", - "description": "Get referrer statistics" - }, - { - "name": "pirsch_goals", - "description": "Get conversion goals and their performance" - }, - { - "name": "pirsch_events", - "description": "Get event statistics" - }, - { - "name": "pirsch_event_pages", - "description": "Get pages on which a specific event fired" - }, - { - "name": "pirsch_utm", - "description": "Get UTM campaign statistics" - }, - { - "name": "pirsch_growth", - "description": "Get growth rates for key metrics" - }, - { - "name": "pirsch_active", - "description": "Get currently active visitors" - }, - { - "name": "pirsch_sessions", - "description": "Get session list with entry, exit, and device/source details" - }, - { - "name": "pirsch_session_details", - "description": "Get the full page-view and event timeline for a single session" - }, - { - "name": "pirsch_compare", - "description": "Compare true period totals and visitor series between two time periods" + "name": "pirsch_compare_periods", + "description": "Compare Pirsch totals and visitor series for a named or explicitly supplied pair of periods" } ] } diff --git a/src/index.spawn.test.ts b/src/index.spawn.test.ts index 3c52e78..f0a8714 100644 --- a/src/index.spawn.test.ts +++ b/src/index.spawn.test.ts @@ -55,6 +55,67 @@ function runViaPath(entryPath: string, flags: string[] = []): Promise { }); } +function discoverModernProtocol(entryPath: string): Promise<{ supportedVersions: string[] }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [entryPath], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + PIRSCH_CLIENT_ID: 'test-client-id', + PIRSCH_CLIENT_SECRET: 'test-client-secret', + }, + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill(); + callback(); + }; + const timer = setTimeout(() => { + finish(() => reject(new Error(`Timed out waiting for modern discovery. stderr so far: ${stderr}`))); + }, 8_000); + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + const newline = stdout.indexOf('\n'); + if (newline === -1) return; + try { + const response = JSON.parse(stdout.slice(0, newline)) as { result?: { supportedVersions?: string[] } }; + const supportedVersions = response.result?.supportedVersions; + if (!supportedVersions) throw new Error(`Unexpected modern discovery response: ${stdout}`); + finish(() => resolve({ supportedVersions })); + } catch (error) { + finish(() => reject(error)); + } + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('error', (error) => { + finish(() => reject(error)); + }); + child.on('exit', (code) => { + finish(() => reject(new Error(`Server process exited early with code ${code}. stderr: ${stderr}`))); + }); + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities': {}, + }, + }, + })}\n`); + }); +} + describe('CLI entry-point detection', () => { const tempDirs: string[] = []; @@ -98,4 +159,8 @@ describe('CLI entry-point detection', () => { 'Pirsch MCP server running' ); }); + + it('serves modern protocol discovery over stdio', async () => { + await expect(discoverModernProtocol(distEntry)).resolves.toEqual({ supportedVersions: ['2026-07-28'] }); + }); }); diff --git a/src/index.ts b/src/index.ts index 7047e07..b196082 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,24 @@ #!/usr/bin/env node import { config } from 'dotenv'; -import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { StdioServerTransport, serveStdio } from '@modelcontextprotocol/server/stdio'; +import { installStdioLifecycle } from './lifecycle.js'; import { createPirschServer } from './server.js'; -config({ quiet: true }); -serveStdio(() => createPirschServer({ defaultDomainId: process.env.PIRSCH_DEFAULT_DOMAIN_ID })); -console.error('Pirsch MCP server running on stdio'); +function main(): void { + const parentPid = process.ppid; + config({ quiet: true }); + const transport = new StdioServerTransport(); + installStdioLifecycle({ transport, parentPid }); + serveStdio( + () => createPirschServer({ defaultDomainId: process.env.PIRSCH_DEFAULT_DOMAIN_ID }), + { transport, onerror: (error) => console.error('Server error:', error) } + ); + console.error('Pirsch MCP server running on stdio'); +} + +try { + main(); +} catch (error) { + console.error('Server error:', error); + process.exit(1); +} diff --git a/src/mcp.test.ts b/src/mcp.test.ts index e2af172..3c58ff1 100644 --- a/src/mcp.test.ts +++ b/src/mcp.test.ts @@ -1,4 +1,6 @@ import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createPirschServer, type PirschReader } from './server.js'; import { comparisonInputSchema, filterOptionsInputSchema, statisticsQuerySchema } from './schemas.js'; @@ -28,6 +30,15 @@ async function connect(clientFactory: () => PirschReader) { } describe('Pirsch MCP tool contracts', () => { + it('keeps the published manifest aligned with the runtime tool catalog', async () => { + const client = await connect(() => ({ listDomains: vi.fn(), get: vi.fn() })); + const manifest = JSON.parse(readFileSync(fileURLToPath(new URL('../server.json', import.meta.url)), 'utf8')) as { + tools: Array<{ name: string }>; + }; + + expect(manifest.tools.map((tool) => tool.name)).toEqual((await client.listTools()).tools.map((tool) => tool.name)); + }); + it('returns only safe domains as structured content with a JSON text fallback', async () => { const listDomains = vi.fn().mockResolvedValue([{ id: 'domain-1', hostname: 'example.com', timezone: 'UTC' }]); const client = await connect(() => ({ listDomains, get: vi.fn() })); From dc9b9dcf04ab9765fe4846e80fb615d1440bd03d Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 26 Aug 2026 00:29:20 +0200 Subject: [PATCH 09/12] docs(readme): document the four-tool v2 API --- README.md | 518 ++++++------------------------------------------------ 1 file changed, 58 insertions(+), 460 deletions(-) diff --git a/README.md b/README.md index 12dee48..8177dfa 100644 --- a/README.md +++ b/README.md @@ -1,514 +1,112 @@ -# MCP Pirsch Server +# Pirsch MCP Server -[![Version](https://img.shields.io/npm/v/@verygoodplugins/mcp-pirsch)](https://www.npmjs.com/package/@verygoodplugins/mcp-pirsch) -[![License](https://img.shields.io/npm/l/@verygoodplugins/mcp-pirsch)](LICENSE) +[![npm](https://img.shields.io/npm/v/@verygoodplugins/mcp-pirsch)](https://www.npmjs.com/package/@verygoodplugins/mcp-pirsch) -A Model Context Protocol (MCP) server for Pirsch Analytics, enabling natural language analytics queries, period comparisons, and trend analysis for your website traffic. +A focused, read-only [Model Context Protocol](https://modelcontextprotocol.io) server for [Pirsch Analytics API v1](https://docs.pirsch.io/api-sdks/api-v1). It uses MCP SDK v2 and returns both structured results and JSON text for every successful tool call. -## Features +## Requirements -- 🔐 **Smart Authentication** - OAuth client credentials with automatic token caching and refresh -- 📊 **Core Analytics** - Comprehensive stats including visitors, page views, bounce rates, and conversion rates -- 📈 **Time Series Data** - Flexible visitor trends with day/week/month/year granularity -- 🔄 **Period Comparisons** - Compare metrics across different time periods with calculated deltas -- 🎯 **Goals & Events** - Read conversion goals, event activity, and event-specific page performance -- 🧭 **Session Drilldown** - Inspect entry pages, exit pages, session lists, and per-session timelines -- ⚡ **Real-time Insights** - Active visitor tracking with configurable time windows -- 🎯 **Advanced Filtering** - Full support for Pirsch query parameters including UTM, referrers, and dimensions -- 🌍 **Multi-domain Support** - Manage analytics across multiple websites from one interface +- Node.js **22.19.0 or later** +- A Pirsch OAuth API client with read access. Do not use a write-only access key. -## Quick Start - -### Installation Methods - -#### Option 1: Using NPX (No Installation Required) - -The simplest way - no need to install anything globally: - -```bash -# For Claude Desktop -npx @verygoodplugins/mcp-pirsch - -# For Claude Code -claude mcp add pirsch "npx @verygoodplugins/mcp-pirsch" -``` - -#### Option 2: Global Installation - -Install once, use anywhere: - -```bash -# Install globally -npm install -g @verygoodplugins/mcp-pirsch - -# For Claude Code -claude mcp add pirsch "mcp-pirsch" -``` - -#### Option 3: Local Development - -For contributing or customization: - -```bash -# Clone and install -git clone https://github.com/verygoodplugins/mcp-pirsch.git -cd mcp-pirsch -npm install -npm run build -``` - -## Configuration - -### 1. Get Pirsch API Credentials - -1. Log into your [Pirsch Analytics Dashboard](https://pirsch.io) -2. Navigate to Settings → API Clients -3. Create a new client with appropriate permissions -4. Copy your Client ID and Client Secret - -### 2. Configure Your Client - -
-Claude Desktop Configuration - -**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +## Configure ```json { "mcpServers": { "pirsch": { "command": "npx", - "args": ["@verygoodplugins/mcp-pirsch"], + "args": ["-y", "@verygoodplugins/mcp-pirsch@latest"], "env": { - "PIRSCH_CLIENT_ID": "your_client_id", - "PIRSCH_CLIENT_SECRET": "your_client_secret", - "PIRSCH_DEFAULT_DOMAIN_ID": "your_domain_id", - "PIRSCH_TIMEZONE": "America/New_York" + "PIRSCH_CLIENT_ID": "your-read-only-oauth-client-id", + "PIRSCH_CLIENT_SECRET": "your-oauth-client-secret", + "PIRSCH_DEFAULT_DOMAIN_ID": "optional-default-domain-id" } } } } ``` -**Or** if installed globally: -```json -{ - "mcpServers": { - "pirsch": { - "command": "mcp-pirsch", - "env": { - "PIRSCH_CLIENT_ID": "your_client_id", - "PIRSCH_CLIENT_SECRET": "your_client_secret" - } - } - } -} -``` +`PIRSCH_DEFAULT_DOMAIN_ID` is optional. When it is unset, every query tool requires `domainId`; the server never picks the first accessible domain. Use `pirsch_list_domains` to discover IDs safely. -
+Optional `PIRSCH_TIMEZONE` supplies the default timezone for requests that do not explicitly include `timezone`. -
-Claude Code Configuration +## Tools -```bash -claude mcp add pirsch "npx @verygoodplugins/mcp-pirsch" \ - --env PIRSCH_CLIENT_ID=your_client_id \ - --env PIRSCH_CLIENT_SECRET=your_client_secret \ - --env PIRSCH_DEFAULT_DOMAIN_ID=your_domain_id -``` +| Tool | Purpose | +| --- | --- | +| `pirsch_list_domains` | Lists only `id`, hostname, display name, and timezone. | +| `pirsch_query_statistics` | Reads one documented v1 metric, with dates and filters. | +| `pirsch_list_filter_options` | Lists allowed values for a documented filter dimension. | +| `pirsch_compare_periods` | Compares actual totals and visitor series for two periods. | -
+All tools are read-only. They return `structuredContent` matching their output schema as well as an equivalent JSON text block. Input or API failures use MCP `isError: true` and do not expose credentials or raw upstream bodies. -
-Cursor IDE Configuration +### Querying statistics -Add to `.mcp.json` in your project: +`pirsch_query_statistics` accepts a `metric`, optional `domainId`, and flat camel-case filters. Most metrics require ISO dates: ```json { - "mcpServers": { - "pirsch": { - "command": "node", - "args": ["./node_modules/@verygoodplugins/mcp-pirsch/dist/index.js"], - "env": { - "PIRSCH_CLIENT_ID": "your_client_id", - "PIRSCH_CLIENT_SECRET": "your_client_secret" - } - } - } + "metric": "pages", + "domainId": "your-domain-id", + "from": "2026-08-01", + "to": "2026-08-23", + "limit": 20, + "sort": "visitors", + "direction": "desc" } ``` -
- -### 3. Environment Variables - -Create a `.env` file for local development: - -```env -# Required -PIRSCH_CLIENT_ID=your_client_id -PIRSCH_CLIENT_SECRET=your_client_secret - -# Optional -PIRSCH_DEFAULT_DOMAIN_ID=your_domain_id # Auto-detected if not set -PIRSCH_TIMEZONE=America/New_York # Default: UTC -PIRSCH_TOKEN_SKEW_MS=60000 # Token refresh buffer (default: 60 seconds) -``` - -## Available Tools - -### Discovery & Setup - -#### `pirsch_list_domains` -List all accessible domains to discover domain IDs. - -**Parameters:** -- `search` (optional): Filter domains by name - -**Example:** -``` -List all my Pirsch domains -``` - -### Core Statistics - -#### `pirsch_overview` -Get cached overview statistics for a domain. - -**Parameters:** -- `domain_id` (optional): Target domain ID - -**Returns:** Visitors, page views, and member counts - -**Note:** This is the Pirsch cached overview endpoint. Filters do not apply, and it should not be used as a substitute for `pirsch_total` over a custom date range. - -#### `pirsch_total` -Get total metrics for a specific period. - -**Parameters:** -- `domain_id` (optional): Target domain ID -- `filter` (optional): Filter object with date range, dimensions, etc. - -Most analytics tools accept filter fields either inside `filter` or as top-level arguments. Both forms are supported for MCP client compatibility. - -**Returns:** Total visitors, views, sessions, bounces, bounce rate, conversion rate, and custom metric aggregates - -#### `pirsch_visitors` -Get visitor time series data. - -**Parameters:** -- `domain_id` (optional): Target domain ID -- `filter` (optional): Including `scale` (day/week/month/year) - -**Example:** -``` -Show me daily visitor trends for the last month -``` - -#### `pirsch_pages` -Get top pages with performance metrics. - -**Parameters:** -- `domain_id` (optional): Target domain ID -- `filter` (optional): Including: - - `sort`: Sort field - - `direction`: asc/desc - - `search`: Search in page paths - - `include_avg_time_on_page`: Include time metrics - - `include_title`: Include page titles - -**Tip:** Exact `path: "/news/"` still matches only that URL. For section queries on page-style tools, path-shaped values such as `search: "/news/"`, `path: "~/news/"`, or `pattern: "/news/*"` are narrowed again inside the MCP so `/documentation/news/...` does not leak into `/news/...` results. `path_prefix` is also available when you want an explicit root-prefix filter. -Top-level `search`, `path`, and `path_prefix` arguments are also accepted. - -#### `pirsch_entry_pages` -Get entry page analytics. - -#### `pirsch_exit_pages` -Get exit page analytics. - -#### `pirsch_referrers` -Analyze traffic sources and referrers. - -**Parameters:** -- `domain_id` (optional): Target domain ID -- `filter` (optional): Standard filter parameters - -#### `pirsch_goals` -Get conversion goals together with their observed stats. - -**Parameters:** -- `domain_id` (optional): Target domain ID -- `filter` (optional): Standard filter parameters - -#### `pirsch_events` -Get aggregated event statistics including counts, visitors, conversion rate, and metadata keys. - -**Parameters:** -- `domain_id` (optional): Target domain ID -- `filter` (optional): Standard filter parameters - -#### `pirsch_event_pages` -Get pages on which a specific event fired. - -**Parameters:** -- `domain_id` (optional): Target domain ID -- `filter` (required): Standard filter parameters, including `event` - -The event can also be passed as a top-level `event` argument. If your client uses goal payload field names, `event_name` is accepted as an alias and normalized to `event`. The same path-prefix narrowing described for `pirsch_pages` also applies here. - -#### `pirsch_utm` -Analyze UTM campaign parameters. - -**Parameters:** -- `type` (required): source | medium | campaign | content | term -- `domain_id` (optional): Target domain ID -- `filter` (optional): Standard filter parameters - -**Example:** -``` -Show me UTM source breakdown for this week -``` - -#### `pirsch_growth` -Calculate growth rates across metrics. - -**Parameters:** -- `domain_id` (optional): Target domain ID -- `filter` (optional): Date range for growth calculation - -### Real-time Analytics - -#### `pirsch_active` -Get currently active visitors and pages. - -**Parameters:** -- `domain_id` (optional): Target domain ID -- `start` (optional): Seconds to look back (default: 600) - -**Example:** -``` -Show me active visitors in the last 5 minutes -``` +The server maps public camel-case fields such as `eventMetaKey`, `entryPath`, `operatingSystem`, and `utmCampaign` to Pirsch's documented API-v1 parameter names. `limit` is constrained to 1–100 and active visitor `start` to 0–3600 seconds. -### Session Analytics +Metrics include totals, visitors, pages and entry/exit pages, session and page duration, goals, events and event metadata, growth, active visitors, time breakdowns, acquisition, browser/device, geographic, UTM, tags, keywords, funnels, sessions, and session details. `session_details` requires both `visitorId` and `sessionId`; event-specific metrics require `event`. -#### `pirsch_sessions` -Get session list data including entry/exit pages, duration, geography, device, and traffic source context. +### Comparing periods -**Parameters:** -- `domain_id` (optional): Target domain ID -- `filter` (optional): Standard filter parameters +Provide a named `period` (`today`, `yesterday`, `week`, `lastWeek`, `month`, or `lastMonth`) or both explicit date pairs: -#### `pirsch_session_details` -Get the chronological page-view and event timeline for a single session. - -**Parameters:** -- `domain_id` (optional): Target domain ID -- `filter` (required): Must include both `visitor_id` and `session_id` - -### Comparative Analytics - -#### `pirsch_compare` -Compare metrics between two time periods using true period totals from Pirsch totals, plus the matching visitor series for charts. - -**Parameters:** -- `domain_id` (optional): Target domain ID -- `period` (optional): today | yesterday | week | lastWeek | month | lastMonth -- `compare` (optional): previous | year | custom -- `from`, `to` (optional): Custom date range (YYYY-MM-DD) -- `compare_from`, `compare_to` (optional): Custom comparison range -- `scale` (optional): day | week | month | year - -**Example:** -``` -Compare this week's traffic to last week -``` - -## Filter Parameters - -Most tools accept a `filter` object that maps to Pirsch query parameters: - -```javascript +```json { - // Date/Time - "from": "2024-01-01", // Start date (YYYY-MM-DD) - "to": "2024-01-31", // End date (YYYY-MM-DD) - "from_time": "09:00", // Start time (HH:MM) - "to_time": "17:00", // End time (HH:MM) - "tz": "America/New_York", // Timezone - - // Dimensions - "path": "~/news/", // Exact or operator-based path filter (~ contains, ! not, ^ does-not-contain) - "entry_path": "/landing", // Entry page - "exit_path": "/checkout", // Exit page - "pattern": "*.pdf", // URL pattern - - // Traffic Sources - "referrer": "google.com", // Referrer domain - "referrer_name": "Google", // Referrer name - "channel": "organic", // Traffic channel - - // UTM Parameters - "utm_source": "newsletter", - "utm_medium": "email", - "utm_campaign": "summer-sale", - "utm_content": "header-cta", - "utm_term": "analytics", - - // Device/Browser - "os": "Windows", - "browser": "Chrome", - "platform": "desktop", // desktop | mobile | unknown - "screen_class": "xxl", - - // Location - "country": "US", - "city": "New York", - "language": "en", - - // Pagination/Sorting - "offset": 0, - "limit": 100, - "sort": "visitors", - "direction": "desc", // asc | desc - "search": "/news/", // Path-shaped searches are narrowed to root-prefix matches on page-style tools - "path_prefix": "/news/", // Optional explicit MCP-local prefix matcher for page-style tools - "keyword": "wordpress crm", // Google Search Console keyword filter - - // Advanced - "event": "signup", - "event_meta_key": "plan", - "tag": "premium", - "visitor_id": "12345...", // Required together with session_id for pirsch_session_details - "session_id": "67890", - "custom_metric_key": "revenue", - "custom_metric_type": "float" + "domainId": "your-domain-id", + "from": "2026-08-01", + "to": "2026-08-07", + "compareFrom": "2026-07-25", + "compareTo": "2026-07-31", + "scale": "day" } ``` -## Usage Examples - -### Basic Analytics Query -``` -Show me the visitor statistics for last week -``` - -### Page Performance Analysis -``` -What are my top 10 /news/ posts by traffic this month? -``` - -### Campaign Tracking -``` -Analyze UTM campaign performance for the summer sale -``` - -### Traffic Sources -``` -Show me referrer breakdown excluding direct traffic -``` +The response compares `/statistics/total` and retains the two `/statistics/visitor` series; it does not estimate totals by summing charts. -### Period Comparison -``` -Compare this month's metrics to the same period last year -``` +## 1.0 migration -### Real-time Monitoring -``` -How many people are on my site right now? -``` +Version 1.0 intentionally replaces the former 17-tool interface. There are no default aliases because aliases would keep unsafe domain-selection and ambiguous input behavior alive. -### Goals and Events -``` -Show me conversion goals and top event-driven pages for the last 90 days -``` +| Previous tools | Replacement | +| --- | --- | +| `pirsch_overview`, `pirsch_total`, `pirsch_pages`, `pirsch_events`, and other statistic tools | `pirsch_query_statistics` with `metric` | +| `pirsch_utm` | `pirsch_query_statistics` with one of the `utm_*` metrics | +| `pirsch_compare` | `pirsch_compare_periods` | +| Domain discovery | `pirsch_list_domains` | -### Session Investigation -``` -List recent sessions that entered on /news/ and inspect one session in detail -``` +Input names are now camel-case and flat (`domainId`, `compareFrom`, `eventMetaKey`), not `domain_id`, nested `filter`, or compatibility aliases. ## Development -### Building from Source - ```bash npm install -npm run build -``` - -### Development Mode - -```bash -npm run dev # Watch mode with auto-reload -``` - -### Testing - -```bash +npm run typecheck +npm run lint npm test +npm run build +npx -y @modelcontextprotocol/inspector@latest --cli node dist/index.js --method tools/list --format json ``` -## Troubleshooting - -### Authentication Issues - -#### Invalid credentials error -- Verify your Client ID and Secret are correct -- Check that your API client has appropriate permissions in Pirsch -- Ensure credentials are properly set in environment variables - -#### Token refresh failures -- The server automatically refreshes tokens 60 seconds before expiry -- Check network connectivity to Pirsch API -- Verify `PIRSCH_TOKEN_SKEW_MS` is not set too low - -### Domain Issues - -#### Domain not found -- Run `pirsch_list_domains` to see available domains -- Verify `PIRSCH_DEFAULT_DOMAIN_ID` is correct -- Check API client has access to the domain - -#### No data returned -- Verify the date range contains data -- Check timezone settings match your Pirsch configuration -- Ensure proper filtering parameters -- Use `pirsch_total` for custom date range totals; `pirsch_overview` is cached and not filterable -- For page-style tools, path-shaped `search`, `~/path/`, and `/path/*` filters are narrowed to root-prefix matches inside the MCP -- Use `path_prefix` when you want explicit prefix behavior without relying on Pirsch operators - -### Performance - -#### Slow responses -- Token caching reduces authentication overhead -- Consider adjusting `PIRSCH_TOKEN_SKEW_MS` for your use case -- Check network latency to Pirsch API endpoints - -## Contributing - -Contributions are welcome! Please: - -1. Fork the repository -2. Create a feature branch -3. Make your changes with tests -4. Submit a pull request - -## License - -MIT - See [LICENSE](LICENSE) file for details. +The release workflow publishes to npm with trusted publishing and then publishes the same tagged manifest to the MCP Registry through GitHub OIDC. Local development and CI never publish anything. ## Support -- **Issues**: [GitHub Issues](https://github.com/verygoodplugins/mcp-pirsch/issues) -- **Documentation**: [Pirsch API Docs](https://docs.pirsch.io/api-sdks/api) - -## Credits - -Built by [Jack Arturo](https://x.com/verygoodplugins) 🧡 +For bugs and feature requests, open an issue in this repository. Pirsch questions are best answered through the [Pirsch documentation](https://docs.pirsch.io/api-sdks/api-v1); package support is maintained by [Very Good Plugins](https://verygoodplugins.com/?utm_source=github). -- Powered by [Pirsch Analytics](https://pirsch.io) -- Built with [Model Context Protocol SDK](https://github.com/anthropics/model-context-protocol) -- Part of the [Very Good Plugins](https://verygoodplugins.com?utm_source=github) MCP ecosystem +Built with 🧡 by Very Good Plugins. From 772b30ef00e3c3ec5abf1d2f89f3e94386db28de Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Tue, 25 Aug 2026 18:48:52 -0400 Subject: [PATCH 10/12] refactor(client): remove retired legacy API client (#54) * refactor(client): remove retired legacy API client * refactor(client): remove obsolete node-fetch dependency * docs(contributing): describe the v2 client architecture --- .env.example | 5 +- AGENTS.md | 17 ++ CLAUDE.md | 103 +---------- package-lock.json | 79 --------- package.json | 1 - src/pirsch-api.test.ts | 384 ----------------------------------------- src/pirsch-api.ts | 149 ---------------- 7 files changed, 20 insertions(+), 718 deletions(-) create mode 100644 AGENTS.md delete mode 100644 src/pirsch-api.test.ts delete mode 100644 src/pirsch-api.ts diff --git a/.env.example b/.env.example index a94ee34..5dc873c 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,7 @@ PIRSCH_CLIENT_ID= PIRSCH_CLIENT_SECRET= -# Optional defaults +# Optional defaults. Without PIRSCH_DEFAULT_DOMAIN_ID, every query requires domainId. # PIRSCH_DEFAULT_DOMAIN_ID= # PIRSCH_TIMEZONE=Europe/Berlin -# Optional cache TTLs in ms -# PIRSCH_TOKEN_SKEW_MS=60000 # Refresh if expiring in < 60s - diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7c2d3a5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# MCP Pirsch contributor guide + +## Commands + +Run `npm run typecheck`, `npm run lint`, `npm test`, and `npm run build` before handing off changes. Use `npx -y @modelcontextprotocol/inspector@latest --cli node dist/index.js --method tools/list --format json` for an MCP surface check. + +## Design constraints + +- Keep the server stdio-only and read-only. +- Keep exactly four public tools unless the maintainers approve an API expansion. +- Tool handlers must declare Zod input/output schemas, read-only annotations, structured content, matching JSON text, and `isError: true` for expected failures. +- Do not validate credentials at process start, select a domain automatically, return raw domain/account metadata, log secrets, or include raw upstream error bodies. +- Use `PIRSCH_DEFAULT_DOMAIN_ID` only as an explicit configured default; otherwise require `domainId`. + +## Delivery + +Do not publish packages or registry entries from local work. The release workflow publishes a release tag to npm first, then the MCP Registry through GitHub OIDC. diff --git a/CLAUDE.md b/CLAUDE.md index 76d9571..257f35c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,102 +1,3 @@ -# CLAUDE.md +# Compatibility note -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -MCP Pirsch Server - A Model Context Protocol server that provides analytics tools for Pirsch Analytics. It enables natural language queries, comparisons, and trend analysis of website traffic through an MCP interface. - -## Development Commands - -```bash -# Install dependencies -npm install - -# Build TypeScript to JavaScript (dist/) -npm run build - -# Development mode with auto-reload -npm run dev - -# Start production server -npm start - -# Quick test (runs help command) -npm test -``` - -## Architecture - -### Core Components - -- **src/index.ts**: MCP server implementation that registers tools and handles requests -- **src/pirsch-api.ts**: Pirsch API client with token caching and auto-refresh -- **src/filters.ts**: Builds URL parameters from filter objects for API queries -- **src/types.ts**: TypeScript interfaces for Pirsch data structures -- **src/utils.ts**: Date range helpers and data aggregation utilities - -### Token Management - -The PirschAPI class implements intelligent token caching: -- Tokens are cached with expiration tracking -- Auto-refreshes 60 seconds before expiry (configurable via PIRSCH_TOKEN_SKEW_MS) -- Handles 401 errors with automatic retry after refresh -- Rate limiting with exponential backoff for 429 responses - -### MCP Tools Pattern - -Each tool follows this structure: -1. Resolve domain ID (from args, env, or auto-detect) -2. Build filter parameters using buildFilterParams() -3. Call appropriate PirschAPI method -4. Return formatted response - -## Environment Configuration - -Required environment variables: -- `PIRSCH_CLIENT_ID`: OAuth client ID from Pirsch -- `PIRSCH_CLIENT_SECRET`: OAuth client secret from Pirsch - -Optional: -- `PIRSCH_DEFAULT_DOMAIN_ID`: Default domain to query (auto-detects if not set) -- `PIRSCH_TIMEZONE`: Default timezone for queries (e.g., 'Europe/Berlin') -- `PIRSCH_TOKEN_SKEW_MS`: Token refresh buffer in ms (default: 60000) - -## Testing the MCP Server - -### Local Testing -```bash -# Test with environment variables -PIRSCH_CLIENT_ID=xxx PIRSCH_CLIENT_SECRET=yyy npm run dev - -# The server expects stdio transport, so testing requires an MCP client -``` - -### Integration Testing -1. Build the project: `npm run build` -2. Configure in `.mcp.json` or Claude Desktop config -3. Restart the MCP client to load the server -4. Test tools like `pirsch_list_domains` to verify connection - -## Key Implementation Details - -### Filter System -All statistics endpoints accept a FilterInput object that maps directly to Pirsch API query parameters. The buildFilterParams() function handles: -- Date/time ranges with timezone support -- Dimensions (path, referrer, browser, OS, etc.) -- UTM parameters -- Pagination and sorting -- Custom metrics and tags - -### Comparison Logic -The `pirsch_compare` tool implements period comparison by: -1. Fetching two visitor series (current and comparison period) -2. Computing totals using sumSeries() -3. Calculating percentage changes with pctChange() -4. Returning both series and delta metrics - -### Error Handling -- Network errors trigger retries with backoff -- 401 errors trigger token refresh -- 429 rate limits respect Retry-After headers -- Domain resolution fails gracefully with helpful messages \ No newline at end of file +Repository instructions are maintained in [AGENTS.md](AGENTS.md). Keep this file as a pointer for Claude Code users. diff --git a/package-lock.json b/package-lock.json index 1f1b318..b68f46f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/server": "^2.0.0", "dotenv": "^17.4.2", - "node-fetch": "^3.3.2", "zod": "^4.4.3" }, "bin": { @@ -2079,13 +2078,6 @@ "node": ">= 8" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/debug": { "version": "4.4.3", "license": "MIT", @@ -2585,27 +2577,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "dev": true, @@ -2683,16 +2654,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "license": "MIT", @@ -3321,39 +3282,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/object-assign": { "version": "4.1.1", "license": "MIT", @@ -4909,13 +4837,6 @@ } } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/which": { "version": "2.0.2", "license": "ISC", diff --git a/package.json b/package.json index b5cc8a7..888ba5b 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "@modelcontextprotocol/server": "^2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "dotenv": "^17.4.2", - "node-fetch": "^3.3.2", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/pirsch-api.test.ts b/src/pirsch-api.test.ts deleted file mode 100644 index fa33a5f..0000000 --- a/src/pirsch-api.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { PirschAPI } from './pirsch-api.js'; -import type { Response, Headers } from 'node-fetch'; - -// Mock node-fetch -vi.mock('node-fetch', () => ({ - default: vi.fn(), -})); - -import fetch from 'node-fetch'; -const mockFetch = vi.mocked(fetch); - -// Helper to create mock response objects -const mockResponse = (data: Partial): Response => data as Response; - -describe('PirschAPI', () => { - const clientId = 'test-client-id'; - const clientSecret = 'test-client-secret'; - let api: PirschAPI; - - const mockTokenResponse = { - access_token: 'test-token-123', - expires_at: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now - }; - - beforeEach(() => { - vi.clearAllMocks(); - api = new PirschAPI(clientId, clientSecret); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('authentication', () => { - it('should fetch a new token when none exists', async () => { - mockFetch - .mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => mockTokenResponse, - }) - ) - .mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => [{ id: 'domain-1', hostname: 'example.com' }], - }) - ); - - await api.listDomains(); - - expect(mockFetch).toHaveBeenCalledTimes(2); - expect(mockFetch).toHaveBeenNthCalledWith( - 1, - 'https://api.pirsch.io/api/v1/token', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - client_id: clientId, - client_secret: clientSecret, - }), - }) - ); - }); - - it('should reuse cached token for subsequent requests', async () => { - mockFetch - .mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => mockTokenResponse, - }) - ) - .mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => [{ id: 'domain-1' }], - }) - ) - .mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => [{ id: 'domain-1' }], - }) - ); - - await api.listDomains(); - await api.listDomains(); - - expect(mockFetch).toHaveBeenCalledTimes(3); - }); - - it('should throw error on auth failure', async () => { - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: false, - status: 401, - text: async () => 'Invalid credentials', - }) - ); - - await expect(api.listDomains()).rejects.toThrow('Pirsch auth failed (401)'); - }); - }); - - describe('listDomains', () => { - beforeEach(() => { - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => mockTokenResponse, - }) - ); - }); - - it('should list domains without filters', async () => { - const domains = [ - { id: 'domain-1', hostname: 'example.com' }, - { id: 'domain-2', hostname: 'test.com' }, - ]; - - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => domains, - }) - ); - - const result = await api.listDomains(); - - expect(result).toEqual(domains); - expect(mockFetch).toHaveBeenLastCalledWith( - 'https://api.pirsch.io/api/v1/domain?', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: `Bearer ${mockTokenResponse.access_token}`, - }), - }) - ); - }); - - it('should pass search parameter', async () => { - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => [{ id: 'domain-1' }], - }) - ); - - await api.listDomains({ search: 'example' }); - - expect(mockFetch).toHaveBeenLastCalledWith( - expect.stringContaining('search=example'), - expect.anything() - ); - }); - }); - - describe('getOverview', () => { - beforeEach(() => { - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => mockTokenResponse, - }) - ); - }); - - it('should fetch overview for a domain', async () => { - const overview = { visitors: 1000, views: 5000 }; - - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => overview, - }) - ); - - const result = await api.getOverview('domain-1'); - - expect(result).toEqual(overview); - expect(mockFetch).toHaveBeenLastCalledWith( - 'https://api.pirsch.io/api/v1/statistics/overview?id=domain-1', - expect.anything() - ); - }); - }); - - describe('getStatistics', () => { - beforeEach(() => { - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => mockTokenResponse, - }) - ); - }); - - it('should fetch statistics with filters', async () => { - const stats = { visitors: 500, views: 1500 }; - - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => stats, - }) - ); - - const result = await api.getStatistics('/statistics/total', 'domain-1', { - from: '2024-01-01', - to: '2024-01-31', - }); - - expect(result).toEqual(stats); - expect(mockFetch).toHaveBeenLastCalledWith( - expect.stringContaining('from=2024-01-01'), - expect.anything() - ); - }); - }); - - describe('getActive', () => { - beforeEach(() => { - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => mockTokenResponse, - }) - ); - }); - - it('should fetch active visitors with default time window', async () => { - const active = { visitors: 10, pages: [] }; - - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => active, - }) - ); - - const result = await api.getActive('domain-1'); - - expect(result).toEqual(active); - }); - - it('should fetch active visitors with custom time window', async () => { - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => ({ visitors: 5 }), - }) - ); - - await api.getActive('domain-1', 300); - - expect(mockFetch).toHaveBeenLastCalledWith( - expect.stringContaining('start=300'), - expect.anything() - ); - }); - }); - - describe('retry logic', () => { - it('should retry on 401 and refresh token', async () => { - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => mockTokenResponse, - }) - ); - - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: false, - status: 401, - text: async () => 'Token expired', - }) - ); - - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => ({ - ...mockTokenResponse, - access_token: 'new-token-456', - }), - }) - ); - - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => [{ id: 'domain-1' }], - }) - ); - - const result = await api.listDomains(); - - expect(result).toEqual([{ id: 'domain-1' }]); - expect(mockFetch).toHaveBeenCalledTimes(4); - }); - - it('should retry on 429 with backoff', async () => { - vi.useFakeTimers(); - - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => mockTokenResponse, - }) - ); - - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: false, - status: 429, - headers: { get: () => '2', raw: () => ({}) } as unknown as Headers, - text: async () => 'Rate limited', - }) - ); - - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => [{ id: 'domain-1' }], - }) - ); - - const resultPromise = api.listDomains(); - - await vi.advanceTimersByTimeAsync(2500); - - const result = await resultPromise; - - expect(result).toEqual([{ id: 'domain-1' }]); - }); - - it('should handle 204 No Content response', async () => { - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => mockTokenResponse, - }) - ); - - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - status: 204, - }) - ); - - const result = await api.getStatistics('/statistics/total', 'domain-1', {}); - - expect(result).toEqual({}); - }); - }); - - describe('error handling', () => { - beforeEach(() => { - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: true, - json: async () => mockTokenResponse, - }) - ); - }); - - it('should throw on API error', async () => { - mockFetch.mockResolvedValueOnce( - mockResponse({ - ok: false, - status: 400, - text: async () => 'Bad request', - }) - ); - - await expect(api.listDomains()).rejects.toThrow('Pirsch API error (400)'); - }); - }); -}); diff --git a/src/pirsch-api.ts b/src/pirsch-api.ts deleted file mode 100644 index f59a7ca..0000000 --- a/src/pirsch-api.ts +++ /dev/null @@ -1,149 +0,0 @@ -import fetch from 'node-fetch'; -import type { PirschTokenResponse, Domain, FilterInput } from './types.js'; -import { buildFilterParams } from './filters.js'; - -const BASE_URL = 'https://api.pirsch.io/api/v1'; - -class AuthError extends Error { - constructor(message: string) { - super(message); - this.name = 'AuthError'; - } -} - -interface TokenCache { - token: string | null; - expiresAt: number; // epoch ms -} - -export class PirschAPI { - private clientId: string; - private clientSecret: string; - private token: TokenCache = { token: null, expiresAt: 0 }; - private tokenSkewMs: number; - - constructor(clientId: string, clientSecret: string) { - this.clientId = clientId; - this.clientSecret = clientSecret; - this.tokenSkewMs = parseInt(process.env.PIRSCH_TOKEN_SKEW_MS || '60000', 10); - } - - private isTokenValid(): boolean { - if (!this.token.token) return false; - const now = Date.now(); - return now + this.tokenSkewMs < this.token.expiresAt; - } - - private async refreshToken(): Promise { - if (!this.clientId || !this.clientSecret) { - throw new AuthError( - 'Pirsch credentials missing. PIRSCH_CLIENT_ID and PIRSCH_CLIENT_SECRET must be set.' - ); - } - const url = `${BASE_URL}/token`; - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - client_id: this.clientId, - client_secret: this.clientSecret, - }), - }); - if (!res.ok) { - const body = await res.text(); - const masked = this.clientId.slice(0, 6) + '***'; - throw new AuthError( - `Pirsch auth failed (${res.status}) for client_id=${masked}. ` + - `Verify PIRSCH_CLIENT_ID and PIRSCH_CLIENT_SECRET are valid in the Pirsch dashboard. ` + - `Response: ${body}` - ); - } - const data = (await res.json()) as PirschTokenResponse; - this.token.token = data.access_token; - this.token.expiresAt = Date.parse(data.expires_at); - } - - private async ensureToken(): Promise { - if (!this.isTokenValid()) { - await this.refreshToken(); - } - } - - private async request( - method: string, - endpoint: string, - options?: { params?: URLSearchParams; body?: unknown }, - retries = 2 - ): Promise { - await this.ensureToken(); - - const url = `${BASE_URL}${endpoint}${options?.params ? `?${options.params.toString()}` : ''}`; - const headers: Record = { - 'Authorization': `Bearer ${this.token.token}`, - 'Content-Type': 'application/json' - }; - - for (let i = 0; i <= retries; i++) { - const res = await fetch(url, { - method, - headers, - body: options?.body ? JSON.stringify(options.body) : undefined, - }); - - if (res.status === 401 && i < retries) { - // Refresh token and retry - await this.refreshToken(); - headers['Authorization'] = `Bearer ${this.token.token}`; - continue; - } - if (res.status === 429 && i < retries) { - const retryAfter = res.headers.get('Retry-After'); - const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : (i + 1) * 1500; - await new Promise(r => setTimeout(r, delay)); - continue; - } - if (!res.ok) { - const text = await res.text(); - throw new Error(`Pirsch API error (${res.status}): ${text}`); - } - if (res.status === 204) return {} as T; - return (await res.json()) as T; - } - throw new Error('Max retries exceeded'); - } - - // Domains - async listDomains(query?: { search?: string; id?: string; subdomain?: string; domain?: string; access?: string; }): Promise { - const params = new URLSearchParams(); - if (query?.search) params.set('search', query.search); - if (query?.id) params.set('id', query.id); - if (query?.subdomain) params.set('subdomain', query.subdomain); - if (query?.domain) params.set('domain', query.domain); - if (query?.access) params.set('access', query.access); - - const result = await this.request('GET', '/domain', { params }); - return result; - } - - // Overview (cached totals and members) - async getOverview(domainId: string): Promise { - const params = new URLSearchParams({ id: domainId }); - return this.request('GET', '/statistics/overview', { params }); - } - - // Generic statistics endpoint helper using filters - async getStatistics( - endpoint: string, - domainId: string, - filter: FilterInput = {} - ): Promise { - const params = buildFilterParams(filter, domainId, { tz: process.env.PIRSCH_TIMEZONE }); - return this.request('GET', endpoint, { params }); - } - - // Active visitors - async getActive(domainId: string, startSeconds?: number): Promise { - const params = buildFilterParams({ start: startSeconds }, domainId, { tz: process.env.PIRSCH_TIMEZONE }); - return this.request('GET', '/statistics/active', { params }); - } -} From edaaba848a195f2d9c854a8fc46dd9b83f91b30c Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 26 Aug 2026 00:53:55 +0200 Subject: [PATCH 11/12] fix(server): derive runtime version from package metadata --- src/mcp.test.ts | 6 ++++++ src/server.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/mcp.test.ts b/src/mcp.test.ts index 3c58ff1..d1386b5 100644 --- a/src/mcp.test.ts +++ b/src/mcp.test.ts @@ -1,6 +1,7 @@ import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import packageJson from '../package.json' with { type: 'json' }; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createPirschServer, type PirschReader } from './server.js'; import { comparisonInputSchema, filterOptionsInputSchema, statisticsQuerySchema } from './schemas.js'; @@ -33,10 +34,15 @@ describe('Pirsch MCP tool contracts', () => { it('keeps the published manifest aligned with the runtime tool catalog', async () => { const client = await connect(() => ({ listDomains: vi.fn(), get: vi.fn() })); const manifest = JSON.parse(readFileSync(fileURLToPath(new URL('../server.json', import.meta.url)), 'utf8')) as { + version: string; + packages: Array<{ version: string }>; tools: Array<{ name: string }>; }; expect(manifest.tools.map((tool) => tool.name)).toEqual((await client.listTools()).tools.map((tool) => tool.name)); + expect(manifest.version).toBe(packageJson.version); + expect(manifest.packages[0].version).toBe(packageJson.version); + expect(client.getServerVersion()).toMatchObject({ name: 'mcp-pirsch', version: packageJson.version }); }); it('returns only safe domains as structured content with a JSON text fallback', async () => { diff --git a/src/server.ts b/src/server.ts index 14d451d..0e07932 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,5 @@ import { McpServer } from '@modelcontextprotocol/server'; +import packageJson from '../package.json' with { type: 'json' }; import { filterOptionMetrics, statisticsMetrics } from './metrics.js'; import { PirschClient, type PirschClientOptions } from './pirsch-client.js'; import { @@ -140,7 +141,7 @@ export function createPirschServer(options: PirschServerOptions = {}): McpServer return reader; }; - const server = new McpServer({ name: 'mcp-pirsch', version: '1.0.0' }); + const server = new McpServer({ name: 'mcp-pirsch', version: packageJson.version }); server.registerTool( 'pirsch_list_domains', From 5993ee3a51858107d6fd6746e0e0a3cc0b235386 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 26 Aug 2026 01:00:39 +0200 Subject: [PATCH 12/12] fix(server): structure MCP error responses --- src/mcp.test.ts | 5 +++++ src/server.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mcp.test.ts b/src/mcp.test.ts index d1386b5..fc6a1d9 100644 --- a/src/mcp.test.ts +++ b/src/mcp.test.ts @@ -63,6 +63,11 @@ describe('Pirsch MCP tool contracts', () => { const result = await client.callTool({ name: 'pirsch_query_statistics', arguments: { metric: 'pages' } }); expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ + error: true, + message: "metric 'pages' requires both from and to dates.", + }); + expect(JSON.parse((result.content as Array<{ text: string }>)[0].text)).toEqual(result.structuredContent); expect(get).not.toHaveBeenCalled(); }); diff --git a/src/server.ts b/src/server.ts index 0e07932..65366cc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,7 +41,7 @@ function jsonResult(output: T) { function errorResult(error: unknown) { const message = error instanceof Error ? error.message : 'Pirsch request failed.'; - return { content: [{ type: 'text' as const, text: message }], isError: true }; + return { ...jsonResult({ error: true, message }), isError: true }; } function resolveDomain(domainId: string | undefined, defaultDomainId: string | undefined): string {