From 49d942014a649be0ea3208765a66853a77169cf5 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Sun, 23 Aug 2026 16:11:17 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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/' }, - ]); - }); -});