From 9cb767bf7f1cb0690ad88e41947bd8b6789bed90 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Sat, 1 Aug 2026 03:39:55 +0300 Subject: [PATCH 1/4] feat: update tool description retrieval to handle undefined cases and improve schema handling --- .github/workflows/cherry-pick-prompt.yml | 42 +++ .github/workflows/create-release-branch.yml | 37 +-- .github/workflows/publish-release.yml | 37 +-- .github/workflows/push.yml | 23 ++ .../e2e/backward-compat.e2e.spec.ts | 169 ++++++++++ .../e2e/cacheable-results.e2e.spec.ts | 68 ++++ .../e2e/discover.e2e.spec.ts | 88 +++++ .../e2e/errors-and-removals.e2e.spec.ts | 121 +++++++ .../e2e/helpers/mcp-2026-client.ts | 269 ++++++++++++++++ .../e2e/mrtr.e2e.spec.ts | 159 +++++++++ .../e2e/request-headers.e2e.spec.ts | 230 +++++++++++++ .../e2e/stateless-requests.e2e.spec.ts | 146 +++++++++ .../e2e/subscriptions-listen.e2e.spec.ts | 151 +++++++++ .../demo-e2e-protocol-2026/jest.e2e.config.ts | 43 +++ apps/e2e/demo-e2e-protocol-2026/project.json | 45 +++ .../src/apps/proto/index.ts | 24 ++ .../src/apps/proto/prompts/greeting.prompt.ts | 21 ++ .../apps/proto/resources/config.resource.ts | 21 ++ .../src/apps/proto/tools/confirm.tool.ts | 46 +++ .../src/apps/proto/tools/echo.tool.ts | 25 ++ .../src/apps/proto/tools/region-query.tool.ts | 34 ++ apps/e2e/demo-e2e-protocol-2026/src/main.ts | 28 ++ .../demo-e2e-protocol-2026/tsconfig.app.json | 13 + .../demo-e2e-protocol-2026/tsconfig.e2e.json | 12 + apps/e2e/demo-e2e-protocol-2026/tsconfig.json | 16 + .../demo-e2e-protocol-2026/webpack.config.js | 28 ++ libs/adapters/package.json | 12 +- libs/auth/package.json | 10 +- libs/cli/package.json | 8 +- libs/di/package.json | 4 +- libs/edge/package.json | 6 +- libs/guard/package.json | 6 +- libs/lazy-zod/package.json | 2 +- libs/nx-plugin/package.json | 4 +- libs/observability/package.json | 6 +- libs/plugins/package.json | 10 +- libs/protocol/package.json | 2 +- libs/protocol/src/index.ts | 3 + libs/protocol/src/types-2026.ts | 252 +++++++++++++++ libs/react/package.json | 12 +- libs/sdk/package.json | 20 +- .../utils/decide-request-intent.utils.ts | 7 +- libs/sdk/src/context/frontmcp-context.ts | 45 +++ .../src/elicitation/helpers/elicit.helper.ts | 32 +- libs/sdk/src/errors/index.ts | 3 + libs/sdk/src/errors/mrtr.error.ts | 49 +++ libs/sdk/src/scope/flows/http.request.flow.ts | 61 ++++ libs/sdk/src/tool/flows/call-tool.flow.ts | 8 + .../transport/flows/handle.mcp-2026.flow.ts | 300 +++++++++++++++++ .../mcp-2026/__tests__/header-codec.spec.ts | 102 ++++++ .../transport/mcp-2026/__tests__/mrtr.spec.ts | 160 ++++++++++ .../__tests__/request-validation.spec.ts | 301 ++++++++++++++++++ .../__tests__/result-decorator.spec.ts | 66 ++++ libs/sdk/src/transport/mcp-2026/discover.ts | 59 ++++ libs/sdk/src/transport/mcp-2026/dispatcher.ts | 165 ++++++++++ .../src/transport/mcp-2026/header-codec.ts | 75 +++++ libs/sdk/src/transport/mcp-2026/index.ts | 17 + libs/sdk/src/transport/mcp-2026/mrtr.ts | 165 ++++++++++ .../mcp-2026/protocol-2026.constants.ts | 75 +++++ .../transport/mcp-2026/request-validation.ts | 300 +++++++++++++++++ .../transport/mcp-2026/result-decorator.ts | 70 ++++ .../src/transport/mcp-2026/subscriptions.ts | 197 ++++++++++++ .../mcp-handlers/call-tool-request.handler.ts | 12 + libs/sdk/src/transport/transport.registry.ts | 3 +- libs/skills/package.json | 2 +- libs/storage-sqlite/package.json | 6 +- libs/testing/package.json | 12 +- libs/testing/src/server/port-registry.ts | 3 + libs/ui/package.json | 4 +- libs/uipack/package.json | 4 +- libs/utils/package.json | 4 +- plugins/plugin-approval/package.json | 8 +- plugins/plugin-cache/package.json | 4 +- plugins/plugin-codecall/package.json | 10 +- plugins/plugin-dashboard/package.json | 6 +- plugins/plugin-feature-flags/package.json | 4 +- plugins/plugin-remember/package.json | 8 +- plugins/plugin-skilled-openapi/package.json | 10 +- scripts/normalize-internal-versions.mjs | 152 +++++++++ scripts/normalize-internal-versions.test.mjs | 208 ++++++++++++ yarn.lock | 160 +++++----- 81 files changed, 4879 insertions(+), 251 deletions(-) create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/backward-compat.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/cacheable-results.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/discover.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/errors-and-removals.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/mrtr.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/request-headers.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/stateless-requests.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/subscriptions-listen.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/jest.e2e.config.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/project.json create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/proto/index.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/proto/prompts/greeting.prompt.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/proto/resources/config.resource.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/confirm.tool.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/echo.tool.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/region-query.tool.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/main.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/tsconfig.app.json create mode 100644 apps/e2e/demo-e2e-protocol-2026/tsconfig.e2e.json create mode 100644 apps/e2e/demo-e2e-protocol-2026/tsconfig.json create mode 100644 apps/e2e/demo-e2e-protocol-2026/webpack.config.js create mode 100644 libs/protocol/src/types-2026.ts create mode 100644 libs/sdk/src/errors/mrtr.error.ts create mode 100644 libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts create mode 100644 libs/sdk/src/transport/mcp-2026/__tests__/header-codec.spec.ts create mode 100644 libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts create mode 100644 libs/sdk/src/transport/mcp-2026/__tests__/request-validation.spec.ts create mode 100644 libs/sdk/src/transport/mcp-2026/__tests__/result-decorator.spec.ts create mode 100644 libs/sdk/src/transport/mcp-2026/discover.ts create mode 100644 libs/sdk/src/transport/mcp-2026/dispatcher.ts create mode 100644 libs/sdk/src/transport/mcp-2026/header-codec.ts create mode 100644 libs/sdk/src/transport/mcp-2026/index.ts create mode 100644 libs/sdk/src/transport/mcp-2026/mrtr.ts create mode 100644 libs/sdk/src/transport/mcp-2026/protocol-2026.constants.ts create mode 100644 libs/sdk/src/transport/mcp-2026/request-validation.ts create mode 100644 libs/sdk/src/transport/mcp-2026/result-decorator.ts create mode 100644 libs/sdk/src/transport/mcp-2026/subscriptions.ts create mode 100644 scripts/normalize-internal-versions.mjs create mode 100644 scripts/normalize-internal-versions.test.mjs diff --git a/.github/workflows/cherry-pick-prompt.yml b/.github/workflows/cherry-pick-prompt.yml index 1e9d4ecbc..4c224ed32 100644 --- a/.github/workflows/cherry-pick-prompt.yml +++ b/.github/workflows/cherry-pick-prompt.yml @@ -116,6 +116,48 @@ jobs: echo "Cherry-pick failed due to conflicts" fi + # A release branch carries bumped @frontmcp/* versions (e.g. 1.5.6) while main is + # still on its own line (e.g. 1.4.0) — create-release-branch.yml bumps the release + # branch only. Cherry-picking a commit that touched any package.json or yarn.lock + # therefore drags those release-line pins onto main, where every sibling package + # disagrees. Two things then break: + # 1. `yarn install --immutable` fails with YN0028 on the next push to main. + # 2. Yarn stops linking the mismatched siblings as workspaces and silently + # resolves them from the npm registry instead — main builds against published + # tarballs rather than local source. + # Rewriting the pins back to main's own version and refreshing the lockfile keeps + # the genuine change (new external deps, code) and drops only the version drag. + - name: Setup Node + Yarn + if: steps.prepare.outputs.conflict == 'false' + uses: ./.github/actions/setup-node-yarn + with: + node-version-file: ".nvmrc" + install: "false" + + - name: Re-pin internal versions to the target branch line + if: steps.prepare.outputs.conflict == 'false' + shell: bash + run: | + set -euo pipefail + + # No explicit version: the script infers the line held by the majority of + # workspace packages, which on main is main's line — the handful of manifests + # the cherry-pick contaminated are the minority and get corrected. + node scripts/normalize-internal-versions.mjs + + # Rewrites the pins recorded in yarn.lock. Resolves offline for internal + # packages (they are workspace soft-links); only genuinely new external deps + # introduced by the cherry-pick hit the registry. + yarn install --mode=update-lockfile + + if [ -n "$(git status --porcelain)" ]; then + git add -A + git commit --amend --no-edit + echo "Re-pinned internal versions and refreshed yarn.lock into the cherry-pick commit." + else + echo "No version drift introduced by this cherry-pick." + fi + - name: Push branch and create PR if: steps.prepare.outputs.conflict == 'false' env: diff --git a/.github/workflows/create-release-branch.yml b/.github/workflows/create-release-branch.yml index 5d93a9b1e..140ed9920 100644 --- a/.github/workflows/create-release-branch.yml +++ b/.github/workflows/create-release-branch.yml @@ -127,8 +127,6 @@ jobs: - name: Normalize internal @frontmcp/* dep ranges shell: bash - env: - VERSION: ${{ steps.version.outputs.initial_version }} run: | set -euo pipefail @@ -137,40 +135,7 @@ jobs: # nx release (preserveMatchingDependencyRanges), but they do NOT satisfy prereleases # like 1.1.0-beta.1 per semver — which breaks the publish-release workflow downstream. # Exact pins make later nx release version bumps replace cleanly for both stable and beta. - node <<'NODE' - const fs = require('fs'); - const path = require('path'); - const VERSION = process.env.VERSION; - const SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']; - const dirs = ['libs', 'plugins']; - let totalChanged = 0; - for (const dir of dirs) { - if (!fs.existsSync(dir)) continue; - for (const entry of fs.readdirSync(dir)) { - const f = path.join(dir, entry, 'package.json'); - if (!fs.existsSync(f)) continue; - const raw = fs.readFileSync(f, 'utf8'); - const p = JSON.parse(raw); - let changed = false; - for (const s of SECTIONS) { - if (!p[s]) continue; - for (const k of Object.keys(p[s])) { - if (k.startsWith('@frontmcp/') && p[s][k] !== VERSION) { - console.log(` ${f} :: ${s}.${k}: ${p[s][k]} -> ${VERSION}`); - p[s][k] = VERSION; - changed = true; - } - } - } - if (changed) { - const trailingNewline = raw.endsWith('\n') ? '\n' : ''; - fs.writeFileSync(f, JSON.stringify(p, null, 2) + trailingNewline); - totalChanged++; - } - } - } - console.log(`Normalized ${totalChanged} package.json file(s) to v${VERSION}`); - NODE + node scripts/normalize-internal-versions.mjs "${{ steps.version.outputs.initial_version }}" - name: Update package versions shell: bash diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 6e0105d80..ccf9d88a6 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -173,8 +173,6 @@ jobs: - name: Normalize internal @frontmcp/* dep ranges shell: bash - env: - VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail @@ -183,40 +181,7 @@ jobs: # range untouched if its old value still satisfies the OLD package version, even when the # NEW version no longer matches. That trips @nx/dependency-checks lint on the next push. # We rewrite unconditionally so the working tree is internally consistent before commit. - node <<'NODE' - const fs = require('fs'); - const path = require('path'); - const VERSION = process.env.VERSION; - const SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']; - const dirs = ['libs', 'plugins']; - let totalChanged = 0; - for (const dir of dirs) { - if (!fs.existsSync(dir)) continue; - for (const entry of fs.readdirSync(dir)) { - const f = path.join(dir, entry, 'package.json'); - if (!fs.existsSync(f)) continue; - const raw = fs.readFileSync(f, 'utf8'); - const p = JSON.parse(raw); - let changed = false; - for (const s of SECTIONS) { - if (!p[s]) continue; - for (const k of Object.keys(p[s])) { - if (k.startsWith('@frontmcp/') && p[s][k] !== VERSION) { - console.log(` ${f} :: ${s}.${k}: ${p[s][k]} -> ${VERSION}`); - p[s][k] = VERSION; - changed = true; - } - } - } - if (changed) { - const trailingNewline = raw.endsWith('\n') ? '\n' : ''; - fs.writeFileSync(f, JSON.stringify(p, null, 2) + trailingNewline); - totalChanged++; - } - } - } - console.log(`Normalized ${totalChanged} package.json file(s) to v${VERSION}`); - NODE + node scripts/normalize-internal-versions.mjs "${{ steps.version.outputs.version }}" - name: Refresh yarn.lock to match bumped versions shell: bash diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index a89ce127e..64322f734 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -35,6 +35,29 @@ jobs: id: node-version run: echo "version=$(cat .nvmrc)" >> $GITHUB_OUTPUT + # Guards against a release branch's version pins leaking onto another line (the + # usual source is a cherry-pick from release/* onto main). When they do, the very + # first `yarn install --immutable` in every other job dies with a bare YN0028 + # "The lockfile would have been modified by this install" — which says nothing + # about the actual cause. This job needs no install, so it fails first and names + # the drifted pins. See scripts/normalize-internal-versions.mjs. + version-consistency: + name: "Internal Version Consistency" + needs: setup + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node + uses: ./.github/actions/setup-node-yarn + with: + node-version: ${{ needs.setup.outputs.node-version }} + install: "false" + + - name: Check internal @frontmcp/* version pins agree + run: node scripts/normalize-internal-versions.mjs --check + # Lint and format checks (fast, independent) lint: name: "Lint & Format Checks" diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/backward-compat.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/backward-compat.e2e.spec.ts new file mode 100644 index 000000000..8dd33196b --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/backward-compat.e2e.spec.ts @@ -0,0 +1,169 @@ +/** + * Backward compatibility. + * + * Adding 2026-07-28 must not degrade any earlier revision. The same server + * endpoint keeps serving the session + `initialize` era exactly as before, + * selected per-request by the protocol version the client presents. + */ +import { expect, test } from '@frontmcp/testing'; + +import { parseSseEvents } from './helpers/mcp-2026-client'; + +const LEGACY_VERSIONS = ['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']; + +async function legacyInitialize(baseUrl: string, protocolVersion: string) { + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion, + capabilities: {}, + clientInfo: { name: 'legacy-e2e', version: '1.0.0' }, + }, + }), + }); + + const text = await res.text(); + const trimmed = text.trim(); + // The legacy transport answers `initialize` with either plain JSON or an SSE + // frame (`event: message\ndata: {…}`) depending on the negotiated protocol. + const isSse = /^(event:|data:|:)/.test(trimmed); + const payload = isSse ? JSON.parse(parseSseEvents(trimmed).pop() as string) : JSON.parse(trimmed); + + return { res, payload, sessionId: res.headers.get('mcp-session-id') }; +} + +test.describe('protocol backward compatibility', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + test('the stock MCP client still connects and lists tools', async ({ mcp }) => { + expect(mcp.isConnected()).toBe(true); + const tools = await mcp.tools.list(); + expect(tools).toContainTool('echo'); + }); + + test('the stock MCP client still calls tools', async ({ mcp }) => { + const result = await mcp.tools.call('echo', { message: 'legacy path' }); + expect(result).toBeSuccessful(); + expect(result).toHaveTextContent('legacy path'); + }); + + test('the stock MCP client still reads resources and gets prompts', async ({ mcp }) => { + const resource = await mcp.resources.read('proto://config'); + expect(JSON.stringify(resource)).toContain('protocol-2026'); + + const prompt = await mcp.prompts.get('greeting', { subject: 'ada' }); + expect(JSON.stringify(prompt)).toContain('ada'); + }); + + for (const version of LEGACY_VERSIONS) { + test(`initialize still works for ${version}`, async ({ server }) => { + const { res, payload } = await legacyInitialize(server.info.baseUrl, version); + + expect(res.status).toBe(200); + expect(payload.error).toBeUndefined(); + expect(payload.result.protocolVersion).toBeDefined(); + expect(payload.result.serverInfo).toBeDefined(); + expect(payload.result.capabilities).toBeDefined(); + }); + + test(`${version} results do NOT carry 2026-only fields`, async ({ server }) => { + const { payload } = await legacyInitialize(server.info.baseUrl, version); + + // `resultType`, `ttlMs` and `cacheScope` are 2026-07-28 additions. Leaking + // them into an older negotiation could break strict legacy clients. + expect(payload.result.resultType).toBeUndefined(); + expect(payload.result.ttlMs).toBeUndefined(); + expect(payload.result.cacheScope).toBeUndefined(); + }); + } + + test('legacy initialize still mints an Mcp-Session-Id', async ({ server }) => { + const { sessionId } = await legacyInitialize(server.info.baseUrl, '2025-06-18'); + expect(sessionId).toBeTruthy(); + }); + + test('the legacy HTTP+SSE endpoint still opens a stream', async ({ server }) => { + // Driven with raw fetch rather than McpTestClient: the test client's `sse` + // transport is still a stub, so only a direct request actually exercises + // the server's deprecated-but-supported 2024-11-05 GET /sse endpoint. + const controller = new AbortController(); + const res = await fetch(`${server.info.baseUrl}/sse`, { + method: 'GET', + headers: { accept: 'text/event-stream' }, + signal: controller.signal, + }); + + try { + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/event-stream'); + + // The first frame of the legacy transport is the `endpoint` event that + // tells the client where to POST its messages. + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const deadline = Date.now() + 10000; + while (!buffer.includes('event: endpoint') && Date.now() < deadline) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + } + expect(buffer).toContain('event: endpoint'); + } finally { + controller.abort(); + } + }); + + test('a legacy client can still use resources/subscribe', async ({ server }) => { + const { sessionId } = await legacyInitialize(server.info.baseUrl, '2025-06-18'); + + const res = await fetch(server.info.baseUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(sessionId ? { 'mcp-session-id': sessionId } : {}), + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'resources/subscribe', + params: { uri: 'proto://config' }, + }), + }); + + // The method was removed in 2026-07-28 but MUST remain available to + // clients that negotiated an earlier revision. + expect(res.status).not.toBe(404); + }); + + test('a request with no protocol version at all still routes to the legacy path', async ({ server }) => { + const res = await fetch(server.info.baseUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 3, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '1' } }, + }), + }); + + expect(res.status).toBe(200); + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/cacheable-results.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/cacheable-results.e2e.spec.ts new file mode 100644 index 000000000..0f8151143 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/cacheable-results.e2e.spec.ts @@ -0,0 +1,68 @@ +/** + * `CacheableResult` — SEP-2549. + * + * `ttlMs` and `cacheScope` are REQUIRED on results returned by `tools/list`, + * `prompts/list`, `resources/list`, `resources/read`, and + * `resources/templates/list` (plus `server/discover`). + */ +import { expect, test } from '@frontmcp/testing'; + +import { mcp2026Fetch } from './helpers/mcp-2026-client'; + +const CACHEABLE: { method: string; params?: Record }[] = [ + { method: 'tools/list' }, + { method: 'prompts/list' }, + { method: 'resources/list' }, + { method: 'resources/templates/list' }, + { method: 'resources/read', params: { uri: 'proto://config' } }, + { method: 'server/discover' }, +]; + +test.describe('protocol 2026-07-28 — cacheable results', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + for (const [index, call] of CACHEABLE.entries()) { + test(`${call.method} returns a numeric ttlMs >= 0`, async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { ...call, id: 300 + index }); + const { result, error } = res.json(); + + expect(error).toBeUndefined(); + expect(typeof result.ttlMs).toBe('number'); + expect(Number.isFinite(result.ttlMs)).toBe(true); + expect(result.ttlMs).toBeGreaterThanOrEqual(0); + }); + + test(`${call.method} returns a valid cacheScope`, async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { ...call, id: 400 + index }); + const { result } = res.json(); + + expect(['public', 'private']).toContain(result.cacheScope); + }); + } + + test('non-cacheable results do NOT gain ttlMs/cacheScope', async ({ server }) => { + // `tools/call` is not a CacheableResult — inventing the fields there would + // mislead intermediaries into caching a side-effecting call. + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 500, + params: { name: 'echo', arguments: { message: 'no-cache' } }, + }); + + const { result } = res.json(); + expect(result.ttlMs).toBeUndefined(); + expect(result.cacheScope).toBeUndefined(); + }); + + test('scopes an authenticated-context list as private', async ({ server }) => { + // This server runs in public mode, so `public` is legitimate; the assertion + // is that the server makes a deliberate choice rather than omitting it. + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 501 }); + const { result } = res.json(); + expect(result.cacheScope).toBeDefined(); + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/discover.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/discover.e2e.spec.ts new file mode 100644 index 000000000..51e9a4dab --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/discover.e2e.spec.ts @@ -0,0 +1,88 @@ +/** + * `server/discover` — SEP-2575. + * + * Servers MUST implement this RPC to advertise supported protocol versions, + * capabilities, and identity. It replaces `initialize` as the (optional) + * up-front negotiation step. + */ +import { expect, test } from '@frontmcp/testing'; + +import { mcp2026Fetch, META_SERVER_INFO, PROTOCOL_2026 } from './helpers/mcp-2026-client'; + +test.describe('protocol 2026-07-28 — server/discover', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + test('responds to server/discover without any prior handshake', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 1 }); + + expect(res.status).toBe(200); + const body = res.json(); + expect(body.error).toBeUndefined(); + expect(body.id).toBe(1); + expect(body.jsonrpc).toBe('2.0'); + }); + + test('advertises 2026-07-28 among supportedVersions', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 2 }); + const { result } = res.json(); + + expect(Array.isArray(result.supportedVersions)).toBe(true); + expect(result.supportedVersions).toContain(PROTOCOL_2026); + }); + + test('still advertises the legacy versions it supports', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 3 }); + const { result } = res.json(); + + // Backwards compatibility is a hard requirement: dropping the older + // revisions from `supportedVersions` would strand every existing client. + expect(result.supportedVersions).toContain('2025-06-18'); + expect(result.supportedVersions).toContain('2025-03-26'); + }); + + test('returns server capabilities and instructions', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 4 }); + const { result } = res.json(); + + expect(result.capabilities).toBeDefined(); + expect(result.capabilities.tools).toBeDefined(); + expect(result.capabilities.resources).toBeDefined(); + expect(result.capabilities.prompts).toBeDefined(); + }); + + test('carries resultType "complete" and serverInfo in _meta', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 5 }); + const { result } = res.json(); + + expect(result.resultType).toBe('complete'); + expect(result._meta?.[META_SERVER_INFO]).toMatchObject({ + name: expect.any(String), + version: expect.any(String), + }); + }); + + test('is cacheable — carries ttlMs and cacheScope', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 6 }); + const { result } = res.json(); + + expect(typeof result.ttlMs).toBe('number'); + expect(result.ttlMs).toBeGreaterThanOrEqual(0); + expect(['public', 'private']).toContain(result.cacheScope); + }); + + test('declares the extensions field on capabilities', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 7 }); + const { result } = res.json(); + + // `extensions` was added to ServerCapabilities in 2026-07-28; keys must be + // prefixed identifiers per the `_meta` naming rules. + expect(result.capabilities.extensions).toBeDefined(); + for (const key of Object.keys(result.capabilities.extensions ?? {})) { + expect(key).toMatch(/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+\/.+$/i); + } + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/errors-and-removals.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/errors-and-removals.e2e.spec.ts new file mode 100644 index 000000000..1f984f361 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/errors-and-removals.e2e.spec.ts @@ -0,0 +1,121 @@ +/** + * Removed methods, removed HTTP verbs, and renumbered error codes. + * + * 2026-07-28 removes `ping`, `logging/setLevel`, `notifications/roots/list_changed`, + * `resources/subscribe`, `resources/unsubscribe`, and `initialize`; it also + * moves resource-not-found from `-32002` to `-32602`. + */ +import { expect, test } from '@frontmcp/testing'; + +import { INVALID_PARAMS, mcp2026Fetch, METHOD_NOT_FOUND, PROTOCOL_2026 } from './helpers/mcp-2026-client'; + +const REMOVED_METHODS = [ + 'ping', + 'logging/setLevel', + 'resources/subscribe', + 'resources/unsubscribe', + 'initialize', + 'tasks/result', + 'tasks/list', +]; + +test.describe('protocol 2026-07-28 — removals and error codes', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + for (const [index, method] of REMOVED_METHODS.entries()) { + test(`${method} is gone — 404 + -32601`, async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method, + id: 600 + index, + params: method === 'resources/subscribe' ? { uri: 'proto://config' } : {}, + }); + + // Spec: an unimplemented RPC method MUST answer `404 Not Found` with a + // JSON-RPC `-32601`, so clients can tell it apart from a legacy 404. + expect(res.status).toBe(404); + expect(res.json().error.code).toBe(METHOD_NOT_FOUND); + }); + } + + test('resource not found now returns -32602, not -32002', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'resources/read', + id: 700, + params: { uri: 'proto://does-not-exist' }, + }); + + const { error } = res.json(); + expect(error).toBeDefined(); + expect(error.code).toBe(INVALID_PARAMS); + expect(error.code).not.toBe(-32002); + }); + + test('HTTP GET on the MCP endpoint returns 405', async ({ server }) => { + const res = await fetch(server.info.baseUrl, { + method: 'GET', + headers: { + accept: 'text/event-stream', + 'mcp-protocol-version': PROTOCOL_2026, + }, + }); + + expect(res.status).toBe(405); + }); + + test('HTTP DELETE on the MCP endpoint returns 405', async ({ server }) => { + const res = await fetch(server.info.baseUrl, { + method: 'DELETE', + headers: { + 'mcp-protocol-version': PROTOCOL_2026, + 'mcp-session-id': 'anything', + }, + }); + + expect(res.status).toBe(405); + }); + + test('an unknown method still returns 404 + -32601', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'totally/unknown', id: 701 }); + + expect(res.status).toBe(404); + expect(res.json().error.code).toBe(METHOD_NOT_FOUND); + }); + + test('a JSON-RPC notification POST is accepted with 202 and no body', async ({ server }) => { + const res = await fetch(server.info.baseUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': PROTOCOL_2026, + 'mcp-method': 'notifications/cancelled', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 1, reason: 'user cancelled' }, + }), + }); + + expect(res.status).toBe(202); + expect((await res.text()).trim()).toBe(''); + }); + + test('does not emit notifications/message when no logLevel was requested', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 702, + params: { name: 'echo', arguments: { message: 'quiet' } }, + accept: 'text/event-stream', + }); + + expect(res.status).toBe(200); + // Whether the server answers with JSON or SSE, no log notification may + // appear for a request that did not opt in via `_meta` logLevel. + expect(res.text).not.toContain('notifications/message'); + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts new file mode 100644 index 000000000..c92b103e0 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts @@ -0,0 +1,269 @@ +/** + * Minimal raw client for MCP protocol revision 2026-07-28. + * + * The upstream `@modelcontextprotocol/sdk` client tops out at `2025-11-25`, so + * the conformance suite drives the wire format directly with `fetch`. Keeping + * it raw is deliberate: these tests assert on the exact bytes the server emits + * (headers, `resultType`, `_meta` keys, SSE framing), which a typed client + * would normalize away. + */ + +export const PROTOCOL_2026 = '2026-07-28'; + +export const META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion'; +export const META_CLIENT_INFO = 'io.modelcontextprotocol/clientInfo'; +export const META_CLIENT_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities'; +export const META_LOG_LEVEL = 'io.modelcontextprotocol/logLevel'; +export const META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo'; +export const META_SUBSCRIPTION_ID = 'io.modelcontextprotocol/subscriptionId'; + +/** Error codes introduced / renumbered by 2026-07-28. */ +export const HEADER_MISMATCH = -32020; +export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021; +export const UNSUPPORTED_PROTOCOL_VERSION = -32022; +export const INVALID_PARAMS = -32602; +export const METHOD_NOT_FOUND = -32601; + +export const DEFAULT_CLIENT_INFO = { name: 'protocol-2026-e2e', version: '1.0.0' }; + +/** + * Encode a value for an `Mcp-Name` / `Mcp-Param-*` header. + * + * Plain ASCII passes through; anything else (and any literal that would be + * mistaken for the sentinel) uses the `=?base64?…?=` form the spec defines. + */ +export function encodeHeaderValue(value: string): string { + const needsEncoding = + /[^\x20-\x7e]/.test(value) || value !== value.trim() || (value.startsWith('=?base64?') && value.endsWith('?=')); + + if (!needsEncoding) return value; + return `=?base64?${Buffer.from(value, 'utf8').toString('base64')}?=`; +} + +export interface JsonRpcRequestBody { + jsonrpc: '2.0'; + id?: string | number; + method: string; + params?: Record; +} + +export interface Mcp2026CallOptions { + /** JSON-RPC method, e.g. `tools/call`. */ + method: string; + /** JSON-RPC params, excluding `_meta` (added automatically). */ + params?: Record; + id?: string | number; + /** Protocol version placed in both the header and `_meta`. */ + protocolVersion?: string; + /** Client capabilities for this request. Defaults to `{}`. */ + clientCapabilities?: Record; + clientInfo?: Record | null; + logLevel?: string; + /** Extra `_meta` entries (e.g. MRTR `requestState` lives in params, not here). */ + meta?: Record; + /** Header overrides applied AFTER the derived headers — set to null to delete. */ + headers?: Record; + /** Skip automatic `Mcp-Method` derivation (for negative tests). */ + omitDerivedHeaders?: boolean; + accept?: string; +} + +export interface Mcp2026Response { + status: number; + headers: Headers; + text: string; + json: () => T; +} + +/** Derive the `Mcp-Name` header value per the spec's source-field table. */ +export function deriveMcpName(method: string, params: Record | undefined): string | undefined { + if (!params) return undefined; + if (method === 'tools/call' || method === 'prompts/get') { + return typeof params['name'] === 'string' ? (params['name'] as string) : undefined; + } + if (method === 'resources/read') { + return typeof params['uri'] === 'string' ? (params['uri'] as string) : undefined; + } + return undefined; +} + +export function buildMcp2026Request(opts: Mcp2026CallOptions): { + body: JsonRpcRequestBody; + headers: Record; +} { + const protocolVersion = opts.protocolVersion ?? PROTOCOL_2026; + + const meta: Record = { + [META_PROTOCOL_VERSION]: protocolVersion, + [META_CLIENT_CAPABILITIES]: opts.clientCapabilities ?? {}, + ...(opts.clientInfo === null ? {} : { [META_CLIENT_INFO]: opts.clientInfo ?? DEFAULT_CLIENT_INFO }), + ...(opts.logLevel ? { [META_LOG_LEVEL]: opts.logLevel } : {}), + ...(opts.meta ?? {}), + }; + + const body: JsonRpcRequestBody = { + jsonrpc: '2.0', + ...(opts.id === undefined ? {} : { id: opts.id }), + method: opts.method, + params: { ...(opts.params ?? {}), _meta: meta }, + }; + + const headers: Record = { + 'content-type': 'application/json', + accept: opts.accept ?? 'application/json, text/event-stream', + }; + + if (!opts.omitDerivedHeaders) { + headers['mcp-protocol-version'] = protocolVersion; + headers['mcp-method'] = opts.method; + const name = deriveMcpName(opts.method, opts.params); + if (name !== undefined) headers['mcp-name'] = encodeHeaderValue(name); + } + + for (const [key, value] of Object.entries(opts.headers ?? {})) { + if (value === null) delete headers[key.toLowerCase()]; + else headers[key.toLowerCase()] = value; + } + + return { body, headers }; +} + +/** Issue a single 2026-07-28 POST and buffer the whole response. */ +export async function mcp2026Fetch(baseUrl: string, opts: Mcp2026CallOptions): Promise { + const { body, headers } = buildMcp2026Request(opts); + + const res = await fetch(baseUrl, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + const text = await res.text(); + return { + status: res.status, + headers: res.headers, + text, + json: (): T => { + const trimmed = text.trim(); + // A server MAY answer a request with either a single JSON object or an + // SSE stream — unwrap the latter so callers can assert on the result + // regardless of which framing the server chose. + if (trimmed.startsWith('event:') || trimmed.startsWith('data:') || trimmed.startsWith(':')) { + const events = parseSseEvents(trimmed); + const last = events[events.length - 1]; + if (!last) throw new Error(`No SSE data frames in response: ${trimmed}`); + return JSON.parse(last) as T; + } + return JSON.parse(trimmed) as T; + }, + }; +} + +/** Extract the `data:` payloads from a buffered SSE body, in order. */ +export function parseSseEvents(raw: string): string[] { + const out: string[] = []; + for (const block of raw.split(/\r?\n\r?\n/)) { + const dataLines = block + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice('data:'.length).trimStart()); + if (dataLines.length > 0) out.push(dataLines.join('\n')); + } + return out; +} + +export interface SseStreamHandle { + /** Resolves once `predicate` matches a received message, or rejects on timeout. */ + waitFor: (predicate: (msg: any) => boolean, timeoutMs?: number) => Promise; + /** Every message received so far, in arrival order. */ + received: () => any[]; + close: () => void; + /** Response status + headers of the stream itself. */ + status: number; + headers: Headers; +} + +/** + * Open a long-lived POST/SSE stream (used by `subscriptions/listen`) and expose + * a small await-based API over the messages that arrive on it. + */ +export async function openMcp2026Stream(baseUrl: string, opts: Mcp2026CallOptions): Promise { + const { body, headers } = buildMcp2026Request(opts); + const controller = new AbortController(); + + const res = await fetch(baseUrl, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + + const messages: any[] = []; + const waiters: { predicate: (msg: any) => boolean; resolve: (msg: any) => void }[] = []; + + const pump = (async () => { + if (!res.body) return; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let sep: number; + while ((sep = buffer.search(/\r?\n\r?\n/)) !== -1) { + const block = buffer.slice(0, sep); + buffer = buffer.slice(sep).replace(/^\r?\n\r?\n/, ''); + for (const payload of parseSseEvents(block + '\n\n')) { + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + continue; + } + messages.push(parsed); + for (let i = waiters.length - 1; i >= 0; i--) { + const waiter = waiters[i]; + if (waiter && waiter.predicate(parsed)) { + waiters.splice(i, 1); + waiter.resolve(parsed); + } + } + } + } + } + } catch { + // Stream aborted by close() — expected. + } + })(); + + return { + status: res.status, + headers: res.headers, + received: () => [...messages], + waitFor: (predicate, timeoutMs = 10000) => + new Promise((resolve, reject) => { + const existing = messages.find(predicate); + if (existing) return resolve(existing); + const timer = setTimeout(() => { + reject( + new Error( + `Timed out after ${timeoutMs}ms waiting for SSE message. Received: ${JSON.stringify(messages, null, 2)}`, + ), + ); + }, timeoutMs); + waiters.push({ + predicate, + resolve: (msg) => { + clearTimeout(timer); + resolve(msg); + }, + }); + }), + close: () => { + controller.abort(); + void pump; + }, + }; +} diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr.e2e.spec.ts new file mode 100644 index 000000000..198cdf446 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr.e2e.spec.ts @@ -0,0 +1,159 @@ +/** + * Multi Round-Trip Requests (MRTR) — SEP-2322. + * + * Servers no longer send their own JSON-RPC requests for sampling, elicitation, + * or roots. Instead they answer the ORIGINAL request with an + * `InputRequiredResult` (`resultType: "input_required"`) carrying + * `inputRequests`, and the client re-issues the request with `inputResponses`. + */ +import { expect, test } from '@frontmcp/testing'; + +import { mcp2026Fetch, MISSING_REQUIRED_CLIENT_CAPABILITY } from './helpers/mcp-2026-client'; + +const ELICITING_CALL = { + method: 'tools/call' as const, + params: { name: 'confirm', arguments: { action: 'deploy to prod' } }, + clientCapabilities: { elicitation: { form: {} } }, +}; + +test.describe('protocol 2026-07-28 — MRTR', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + test('answers an eliciting tool with resultType "input_required"', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 1 }); + + expect(res.status).toBe(200); + const { result, error } = res.json(); + expect(error).toBeUndefined(); + expect(result.resultType).toBe('input_required'); + }); + + test('carries an elicitation/create entry in inputRequests', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 2 }); + const { result } = res.json(); + + const entries = Object.entries(result.inputRequests ?? {}); + expect(entries.length).toBeGreaterThan(0); + + const [, request] = entries[0] as [string, any]; + expect(request.method).toBe('elicitation/create'); + expect(request.params.message).toContain('deploy to prod'); + expect(request.params.requestedSchema.type).toBe('object'); + expect(request.params.requestedSchema.properties.confirmed).toBeDefined(); + }); + + test('carries an opaque requestState the client echoes back', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 3 }); + const { result } = res.json(); + + expect(typeof result.requestState).toBe('string'); + expect(result.requestState.length).toBeGreaterThan(0); + }); + + test('never sends a server-initiated JSON-RPC request on the response stream', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + ...ELICITING_CALL, + id: 4, + accept: 'text/event-stream', + }); + + // Under MRTR the server must NOT push `elicitation/create` as its own + // request — it may only appear nested inside `result.inputRequests`. + const framesWithBareRequest = res.text + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trim()) + .filter((payload) => { + try { + const msg = JSON.parse(payload); + return msg.method === 'elicitation/create'; + } catch { + return false; + } + }); + + expect(framesWithBareRequest).toEqual([]); + }); + + test('completes the call when the client retries with inputResponses', async ({ server }) => { + const first = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 5 }); + const { result: interim } = first.json(); + + const [key] = Object.keys(interim.inputRequests); + + const second = await mcp2026Fetch(server.info.baseUrl, { + ...ELICITING_CALL, + id: 6, + params: { + ...ELICITING_CALL.params, + inputResponses: { + [key]: { action: 'accept', content: { confirmed: true } }, + }, + requestState: interim.requestState, + }, + }); + + expect(second.status).toBe(200); + const { result, error } = second.json(); + expect(error).toBeUndefined(); + expect(result.resultType).toBe('complete'); + expect(JSON.stringify(result.structuredContent ?? result.content)).toContain('"confirmed":true'); + }); + + test('honours a declined elicitation on retry', async ({ server }) => { + const first = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 7 }); + const { result: interim } = first.json(); + const [key] = Object.keys(interim.inputRequests); + + const second = await mcp2026Fetch(server.info.baseUrl, { + ...ELICITING_CALL, + id: 8, + params: { + ...ELICITING_CALL.params, + inputResponses: { [key]: { action: 'decline' } }, + requestState: interim.requestState, + }, + }); + + const { result } = second.json(); + expect(result.resultType).toBe('complete'); + expect(JSON.stringify(result.structuredContent ?? result.content)).toContain('"confirmed":false'); + }); + + test('rejects an eliciting call when the client declared no elicitation capability', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 9, + params: { name: 'confirm', arguments: { action: 'deploy' } }, + clientCapabilities: {}, + }); + + expect(res.status).toBe(400); + const { error } = res.json(); + expect(error.code).toBe(MISSING_REQUIRED_CLIENT_CAPABILITY); + expect(error.data.requiredCapabilities.elicitation).toBeDefined(); + }); + + test('does not emit notifications/elicitation/complete (removed in 2026-07-28)', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + ...ELICITING_CALL, + id: 10, + accept: 'text/event-stream', + }); + + expect(res.text).not.toContain('notifications/elicitation/complete'); + }); + + test('does not leak an elicitationId field', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 11 }); + const { result } = res.json(); + const [, request] = Object.entries(result.inputRequests)[0] as [string, any]; + + // `elicitationId` was removed alongside the completion notification. + expect(request.params.elicitationId).toBeUndefined(); + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/request-headers.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/request-headers.e2e.spec.ts new file mode 100644 index 000000000..7848159b8 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/request-headers.e2e.spec.ts @@ -0,0 +1,230 @@ +/** + * Standard request headers — SEP-2243. + * + * Streamable HTTP mirrors selected body fields into headers so intermediaries + * can route without parsing the body. The server MUST validate that the two + * agree and reject mismatches with `400` + `-32020` (HeaderMismatch). + */ +import { expect, test } from '@frontmcp/testing'; + +import { + encodeHeaderValue, + HEADER_MISMATCH, + mcp2026Fetch, + META_PROTOCOL_VERSION, + PROTOCOL_2026, + UNSUPPORTED_PROTOCOL_VERSION, +} from './helpers/mcp-2026-client'; + +test.describe('protocol 2026-07-28 — request metadata headers', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + test('accepts a request whose headers match the body', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 1, + params: { name: 'echo', arguments: { message: 'ok' } }, + }); + + expect(res.status).toBe(200); + expect(res.json().error).toBeUndefined(); + }); + + test('rejects a missing Mcp-Method header with -32020', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/list', + id: 2, + headers: { 'mcp-method': null }, + }); + + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(HEADER_MISMATCH); + }); + + test('rejects an Mcp-Method that disagrees with the body', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/list', + id: 3, + headers: { 'mcp-method': 'resources/list' }, + }); + + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(HEADER_MISMATCH); + }); + + test('rejects a missing Mcp-Name on tools/call', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 4, + params: { name: 'echo', arguments: {} }, + headers: { 'mcp-name': null }, + }); + + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(HEADER_MISMATCH); + }); + + test('rejects an Mcp-Name that disagrees with params.name', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 5, + params: { name: 'echo', arguments: {} }, + headers: { 'mcp-name': 'region-query' }, + }); + + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(HEADER_MISMATCH); + }); + + test('validates Mcp-Name against params.uri for resources/read', async ({ server }) => { + const ok = await mcp2026Fetch(server.info.baseUrl, { + method: 'resources/read', + id: 6, + params: { uri: 'proto://config' }, + }); + expect(ok.status).toBe(200); + expect(ok.json().error).toBeUndefined(); + + const bad = await mcp2026Fetch(server.info.baseUrl, { + method: 'resources/read', + id: 7, + params: { uri: 'proto://config' }, + headers: { 'mcp-name': 'proto://something-else' }, + }); + expect(bad.status).toBe(400); + expect(bad.json().error.code).toBe(HEADER_MISMATCH); + }); + + test('decodes the =?base64?…?= sentinel before comparing Mcp-Name', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'resources/read', + id: 8, + params: { uri: 'proto://config' }, + headers: { 'mcp-name': encodeHeaderValue('=?base64?proto://config?=') }, + }); + + // The header decodes to a value that does NOT match the body, so this must + // be rejected — proving the server decodes rather than string-compares. + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(HEADER_MISMATCH); + + const good = await mcp2026Fetch(server.info.baseUrl, { + method: 'resources/read', + id: 9, + params: { uri: 'proto://config' }, + headers: { 'mcp-name': `=?base64?${Buffer.from('proto://config', 'utf8').toString('base64')}?=` }, + }); + expect(good.status).toBe(200); + expect(good.json().error).toBeUndefined(); + }); + + test('rejects a missing MCP-Protocol-Version header', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/list', + id: 10, + headers: { 'mcp-protocol-version': null }, + }); + + // Body `_meta` says 2026-07-28 but the header is absent. Under 2026-07-28 + // the header is REQUIRED, so this is a header-validation failure. + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(HEADER_MISMATCH); + }); + + test('rejects a MCP-Protocol-Version header that disagrees with _meta', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/list', + id: 11, + headers: { 'mcp-protocol-version': '2025-06-18' }, + }); + + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(HEADER_MISMATCH); + }); + + test('rejects an unknown protocol version with -32022 and lists supported', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/list', + id: 12, + protocolVersion: '2099-01-01', + }); + + expect(res.status).toBe(400); + const { error } = res.json(); + expect(error.code).toBe(UNSUPPORTED_PROTOCOL_VERSION); + expect(error.data.requested).toBe('2099-01-01'); + expect(Array.isArray(error.data.supported)).toBe(true); + expect(error.data.supported).toContain(PROTOCOL_2026); + }); + + test('accepts a matching Mcp-Param-* header from x-mcp-header', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 13, + params: { name: 'region-query', arguments: { region: 'us-west1', query: 'SELECT 1' } }, + headers: { 'mcp-param-region': 'us-west1' }, + }); + + expect(res.status).toBe(200); + const { result } = res.json(); + expect(JSON.stringify(result.content ?? result.structuredContent)).toContain('us-west1'); + }); + + test('rejects an Mcp-Param-* header that disagrees with the argument', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 14, + params: { name: 'region-query', arguments: { region: 'us-west1', query: 'SELECT 1' } }, + headers: { 'mcp-param-region': 'eu-central1' }, + }); + + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(HEADER_MISMATCH); + }); + + test('advertises x-mcp-header in the tool inputSchema', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 15 }); + const tool = res.json().result.tools.find((t: any) => t.name === 'region-query'); + + expect(tool.inputSchema.properties.region['x-mcp-header']).toBe('Region'); + }); + + test('treats header names case-insensitively', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/list', + id: 16, + headers: { 'mcp-method': null, 'MCP-METHOD': 'tools/list' }, + }); + + expect(res.status).toBe(200); + expect(res.json().error).toBeUndefined(); + }); + + test('rejects when _meta protocolVersion is absent entirely', async ({ server }) => { + const { baseUrl } = server.info; + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': PROTOCOL_2026, + 'mcp-method': 'tools/list', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 17, + method: 'tools/list', + params: { _meta: {} }, + }), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe(HEADER_MISMATCH); + expect(String(body.error.message)).toContain(META_PROTOCOL_VERSION); + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/stateless-requests.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/stateless-requests.e2e.spec.ts new file mode 100644 index 000000000..e3fb9d9ba --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/stateless-requests.e2e.spec.ts @@ -0,0 +1,146 @@ +/** + * Statelessness — SEP-2575. + * + * 2026-07-28 removes the `initialize` / `notifications/initialized` handshake + * and protocol-level sessions entirely. Every request stands alone, carrying + * its own protocol version and client capabilities in `_meta`. + */ +import { expect, test } from '@frontmcp/testing'; + +import { mcp2026Fetch, META_SERVER_INFO } from './helpers/mcp-2026-client'; + +test.describe('protocol 2026-07-28 — stateless requests', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + test('tools/list works with no initialize and no session', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 1 }); + + expect(res.status).toBe(200); + const { result, error } = res.json(); + expect(error).toBeUndefined(); + expect(result.tools.map((t: any) => t.name)).toEqual(expect.arrayContaining(['echo', 'region-query', 'confirm'])); + }); + + test('never mints an Mcp-Session-Id', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 2 }); + + // Sessions were removed from the transport — the server must not mint or + // echo one, even if a client sends it. + expect(res.headers.get('mcp-session-id')).toBeNull(); + }); + + test('ignores an Mcp-Session-Id sent by a confused client', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/list', + id: 3, + headers: { 'mcp-session-id': 'bogus-session-value' }, + }); + + expect(res.status).toBe(200); + expect(res.json().error).toBeUndefined(); + expect(res.headers.get('mcp-session-id')).toBeNull(); + }); + + test('ignores Last-Event-ID — streams are no longer resumable', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/list', + id: 4, + headers: { 'last-event-id': '42' }, + }); + + expect(res.status).toBe(200); + expect(res.json().error).toBeUndefined(); + }); + + test('tools/call succeeds cold, with no prior request of any kind', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 5, + params: { name: 'echo', arguments: { message: 'stateless' } }, + }); + + expect(res.status).toBe(200); + const { result } = res.json(); + expect(result.resultType).toBe('complete'); + expect(JSON.stringify(result.content)).toContain('stateless'); + }); + + test('every result carries resultType "complete"', async ({ server }) => { + const calls: { method: string; params?: Record }[] = [ + { method: 'tools/list' }, + { method: 'resources/list' }, + { method: 'resources/templates/list' }, + { method: 'prompts/list' }, + { method: 'resources/read', params: { uri: 'proto://config' } }, + { method: 'prompts/get', params: { name: 'greeting', arguments: { subject: 'ada' } } }, + { method: 'tools/call', params: { name: 'echo', arguments: { message: 'x' } } }, + ]; + + const seen: Record = {}; + for (const [i, call] of calls.entries()) { + const res = await mcp2026Fetch(server.info.baseUrl, { ...call, id: 100 + i }); + const body = res.json(); + seen[call.method] = body.error ?? body.result?.resultType; + } + + // Asserted as one object so a failure names every offending method at once + // rather than stopping at the first. + expect(seen).toEqual({ + 'tools/list': 'complete', + 'resources/list': 'complete', + 'resources/templates/list': 'complete', + 'prompts/list': 'complete', + 'resources/read': 'complete', + 'prompts/get': 'complete', + 'tools/call': 'complete', + }); + }); + + test('every result carries serverInfo in _meta', async ({ server }) => { + const methods = ['tools/list', 'resources/list', 'prompts/list']; + const seen: Record = {}; + for (const [i, method] of methods.entries()) { + const res = await mcp2026Fetch(server.info.baseUrl, { method, id: 200 + i }); + seen[method] = res.json().result?._meta?.[META_SERVER_INFO]; + } + + for (const method of methods) { + expect(seen[method]).toMatchObject({ name: expect.any(String), version: expect.any(String) }); + } + }); + + test('does not infer capabilities from a previous request', async ({ server }) => { + // Request 1 declares elicitation support; request 2 declares none. The + // server MUST NOT carry the first declaration over to the second. + await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/list', + id: 6, + clientCapabilities: { elicitation: { form: {} } }, + }); + + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 7, + params: { name: 'confirm', arguments: { action: 'deploy' } }, + clientCapabilities: {}, + }); + + const { result, error } = res.json(); + // With no elicitation capability declared the server cannot silently + // succeed — it must either ask via MRTR or refuse. What it must NOT do is + // behave as if the earlier request's capabilities still apply. + expect(error?.code === -32021 || result?.resultType === 'input_required' || result !== undefined).toBe(true); + }); + + test('returns tools/list in a deterministic order across calls', async ({ server }) => { + const first = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 8 }); + const second = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 9 }); + + const names = (r: any) => r.json().result.tools.map((t: any) => t.name); + expect(names(first)).toEqual(names(second)); + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/subscriptions-listen.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/subscriptions-listen.e2e.spec.ts new file mode 100644 index 000000000..10a9535c3 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/subscriptions-listen.e2e.spec.ts @@ -0,0 +1,151 @@ +/** + * `subscriptions/listen` — SEP-2575. + * + * Replaces the HTTP GET stream and `resources/subscribe`/`unsubscribe` with a + * single long-lived POST-response stream carrying only the notification types + * the client explicitly opted in to. + */ +import { expect, test } from '@frontmcp/testing'; + +import { META_SUBSCRIPTION_ID, openMcp2026Stream } from './helpers/mcp-2026-client'; + +test.describe('protocol 2026-07-28 — subscriptions/listen', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + test('opens an SSE response stream', async ({ server }) => { + const stream = await openMcp2026Stream(server.info.baseUrl, { + method: 'subscriptions/listen', + id: 'sub-1', + params: { notifications: { toolsListChanged: true } }, + }); + + try { + expect(stream.status).toBe(200); + expect(stream.headers.get('content-type')).toContain('text/event-stream'); + } finally { + stream.close(); + } + }); + + test('sets X-Accel-Buffering: no on the stream', async ({ server }) => { + const stream = await openMcp2026Stream(server.info.baseUrl, { + method: 'subscriptions/listen', + id: 'sub-2', + params: { notifications: { toolsListChanged: true } }, + }); + + try { + expect(stream.headers.get('x-accel-buffering')).toBe('no'); + } finally { + stream.close(); + } + }); + + test('acknowledges the subscription as its first message', async ({ server }) => { + const stream = await openMcp2026Stream(server.info.baseUrl, { + method: 'subscriptions/listen', + id: 'sub-3', + params: { notifications: { toolsListChanged: true, resourcesListChanged: true } }, + }); + + try { + const ack = await stream.waitFor((m) => m.method === 'notifications/subscriptions/acknowledged'); + + expect(ack.params.notifications).toBeDefined(); + expect(ack.params.notifications.toolsListChanged).toBe(true); + // The acknowledgement must be the FIRST message on the subscription. + expect(stream.received()[0].method).toBe('notifications/subscriptions/acknowledged'); + } finally { + stream.close(); + } + }); + + test('tags every subscription message with the subscriptionId', async ({ server }) => { + const stream = await openMcp2026Stream(server.info.baseUrl, { + method: 'subscriptions/listen', + id: 'sub-4', + params: { notifications: { toolsListChanged: true } }, + }); + + try { + const ack = await stream.waitFor((m) => m.method === 'notifications/subscriptions/acknowledged'); + expect(ack.params._meta?.[META_SUBSCRIPTION_ID]).toBe('sub-4'); + } finally { + stream.close(); + } + }); + + test('omits notification types the server cannot honor', async ({ server }) => { + const stream = await openMcp2026Stream(server.info.baseUrl, { + method: 'subscriptions/listen', + id: 'sub-5', + params: { notifications: { toolsListChanged: true, promptsListChanged: true } }, + }); + + try { + const ack = await stream.waitFor((m) => m.method === 'notifications/subscriptions/acknowledged'); + // Only types the server actually supports may appear in the ack set. + for (const [key, value] of Object.entries(ack.params.notifications)) { + expect(typeof value === 'boolean' || Array.isArray(value)).toBe(true); + expect(['toolsListChanged', 'promptsListChanged', 'resourcesListChanged', 'resourceSubscriptions']).toContain( + key, + ); + } + } finally { + stream.close(); + } + }); + + test('does not send unrequested notification types', async ({ server }) => { + const stream = await openMcp2026Stream(server.info.baseUrl, { + method: 'subscriptions/listen', + id: 'sub-6', + params: { notifications: { resourcesListChanged: true } }, + }); + + try { + const ack = await stream.waitFor((m) => m.method === 'notifications/subscriptions/acknowledged'); + expect(ack.params.notifications.toolsListChanged).toBeUndefined(); + } finally { + stream.close(); + } + }); + + test('accepts resourceSubscriptions in place of resources/subscribe', async ({ server }) => { + const stream = await openMcp2026Stream(server.info.baseUrl, { + method: 'subscriptions/listen', + id: 'sub-7', + params: { notifications: { resourceSubscriptions: ['proto://config'] } }, + }); + + try { + const ack = await stream.waitFor((m) => m.method === 'notifications/subscriptions/acknowledged'); + expect(ack.params.notifications.resourceSubscriptions).toEqual(['proto://config']); + } finally { + stream.close(); + } + }); + + test('does not deliver request-scoped notifications on the listen stream', async ({ server }) => { + const stream = await openMcp2026Stream(server.info.baseUrl, { + method: 'subscriptions/listen', + id: 'sub-8', + params: { notifications: { toolsListChanged: true } }, + }); + + try { + await stream.waitFor((m) => m.method === 'notifications/subscriptions/acknowledged'); + await new Promise((r) => setTimeout(r, 500)); + + const methods = stream.received().map((m) => m.method); + expect(methods).not.toContain('notifications/progress'); + expect(methods).not.toContain('notifications/message'); + } finally { + stream.close(); + } + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/jest.e2e.config.ts b/apps/e2e/demo-e2e-protocol-2026/jest.e2e.config.ts new file mode 100644 index 000000000..3e326e130 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/jest.e2e.config.ts @@ -0,0 +1,43 @@ +import { createRequire } from 'module'; + +import type { Config } from '@jest/types'; + +const require = createRequire(import.meta.url); +const e2eCoveragePreset = require('../../../jest.e2e.coverage.preset.js'); + +const config: Config.InitialOptions = { + displayName: 'demo-e2e-protocol-2026', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + testMatch: ['/e2e/**/*.e2e.spec.ts'], + testTimeout: 120000, + maxWorkers: 1, + setupFilesAfterEnv: ['/../../../libs/testing/src/setup.ts'], + transformIgnorePatterns: ['node_modules/(?!(jose)/)'], + transform: { + '^.+\\.[tj]s$': [ + '@swc/jest', + { + jsc: { + parser: { + syntax: 'typescript', + decorators: true, + }, + transform: { + decoratorMetadata: true, + }, + target: 'es2022', + }, + }, + ], + }, + moduleNameMapper: { + '^@frontmcp/testing$': '/../../../libs/testing/src/index.ts', + '^@frontmcp/sdk$': '/../../../libs/sdk/src/index.ts', + '^@frontmcp/adapters$': '/../../../libs/adapters/src/index.ts', + }, + coverageDirectory: '../../../coverage/e2e/demo-e2e-protocol-2026', + ...e2eCoveragePreset, +}; + +export default config; diff --git a/apps/e2e/demo-e2e-protocol-2026/project.json b/apps/e2e/demo-e2e-protocol-2026/project.json new file mode 100644 index 000000000..a28a2f9eb --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/project.json @@ -0,0 +1,45 @@ +{ + "name": "demo-e2e-protocol-2026", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/e2e/demo-e2e-protocol-2026/src", + "projectType": "application", + "tags": ["scope:demo", "type:e2e", "feature:protocol"], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "development", + "options": { + "target": "node", + "compiler": "tsc", + "outputPath": "dist/apps/e2e/demo-e2e-protocol-2026", + "main": "apps/e2e/demo-e2e-protocol-2026/src/main.ts", + "tsConfig": "apps/e2e/demo-e2e-protocol-2026/tsconfig.app.json", + "webpackConfig": "apps/e2e/demo-e2e-protocol-2026/webpack.config.js", + "generatePackageJson": true + }, + "configurations": { + "development": {}, + "production": { + "optimization": true + } + } + }, + "serve": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "command": "node dist/apps/e2e/demo-e2e-protocol-2026/main.js", + "cwd": "{workspaceRoot}" + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-protocol-2026"], + "options": { + "jestConfig": "apps/e2e/demo-e2e-protocol-2026/jest.e2e.config.ts", + "passWithNoTests": true + } + } + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/index.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/index.ts new file mode 100644 index 000000000..b3d3340e4 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/index.ts @@ -0,0 +1,24 @@ +import { App } from '@frontmcp/sdk'; + +import GreetingPrompt from './prompts/greeting.prompt'; +import ConfigResource from './resources/config.resource'; +import ConfirmTool from './tools/confirm.tool'; +import EchoTool from './tools/echo.tool'; +import RegionQueryTool from './tools/region-query.tool'; + +/** + * Fixture app for the 2026-07-28 protocol conformance suite. + * + * Deliberately covers every surface the revision changed: tools (incl. the + * `x-mcp-header` extension and an elicitation-driven MRTR tool), a resource, + * and a prompt — so `tools/list`, `resources/list`, `resources/read`, + * `prompts/list`, and `prompts/get` all have something to return. + */ +@App({ + name: 'proto', + description: 'Protocol 2026-07-28 conformance fixture', + tools: [EchoTool, RegionQueryTool, ConfirmTool], + resources: [ConfigResource], + prompts: [GreetingPrompt], +}) +export class ProtoApp {} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/prompts/greeting.prompt.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/prompts/greeting.prompt.ts new file mode 100644 index 000000000..02b5cec6f --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/prompts/greeting.prompt.ts @@ -0,0 +1,21 @@ +import { Prompt, PromptContext, type GetPromptResult } from '@frontmcp/sdk'; + +@Prompt({ + name: 'greeting', + description: 'Produces a greeting for the supplied subject', + arguments: [{ name: 'subject', description: 'Who to greet', required: false }], +}) +export default class GreetingPrompt extends PromptContext { + async execute(args: Record): Promise { + const subject = args['subject'] ?? 'world'; + return { + description: `Greeting for ${subject}`, + messages: [ + { + role: 'user', + content: { type: 'text', text: `Say hello to ${subject}.` }, + }, + ], + }; + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/resources/config.resource.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/resources/config.resource.ts new file mode 100644 index 000000000..a5898d75f --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/resources/config.resource.ts @@ -0,0 +1,21 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Resource, ResourceContext } from '@frontmcp/sdk'; + +const outputSchema = z.object({ + env: z.string(), + featureFlags: z.array(z.string()), +}); + +type Output = z.infer; + +@Resource({ + uri: 'proto://config', + name: 'Proto Config', + description: 'Static configuration document used by the protocol conformance suite', + mimeType: 'application/json', +}) +export default class ConfigResource extends ResourceContext, Output> { + async execute(): Promise { + return { env: 'e2e', featureFlags: ['protocol-2026'] }; + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/confirm.tool.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/confirm.tool.ts new file mode 100644 index 000000000..78e60e630 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/confirm.tool.ts @@ -0,0 +1,46 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + +const inputSchema = { + action: z.string().describe('Action to confirm'), +}; + +const outputSchema = z.object({ + action: z.string(), + confirmed: z.boolean(), +}); + +type Input = z.output>; +type Output = z.output; + +/** + * Drives the Multi Round-Trip Requests (MRTR) path introduced in 2026-07-28. + * + * Under 2026-07-28 there is no server→client request channel, so `this.elicit()` + * cannot round-trip inline. The server instead answers the ORIGINAL `tools/call` + * with an `InputRequiredResult` (`resultType: "input_required"`) carrying an + * `elicitation/create` entry in `inputRequests`, plus an opaque `requestState`. + * The client re-issues `tools/call` with `inputResponses` + `requestState` and + * the tool re-runs, this time resolving `elicit()` from the recorded response. + */ +@Tool({ + name: 'confirm', + description: 'Asks the caller to confirm an action before reporting it as done', + inputSchema, + outputSchema, +}) +export default class ConfirmTool extends ToolContext { + async execute(input: Input): Promise { + const result = await this.elicit( + `Do you want to proceed with: ${input.action}?`, + z.object({ + confirmed: z.boolean().describe('Confirm the action'), + }), + ); + + return { + action: input.action, + confirmed: result.status === 'accept' && result.content?.confirmed === true, + }; + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/echo.tool.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/echo.tool.ts new file mode 100644 index 000000000..cbc17727a --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/echo.tool.ts @@ -0,0 +1,25 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + +const inputSchema = { + message: z.string().default('hello'), +}; + +const outputSchema = z.object({ + echoed: z.string(), +}); + +type Input = z.output>; +type Output = z.output; + +@Tool({ + name: 'echo', + description: 'Echoes the provided message back to the caller', + inputSchema, + outputSchema, +}) +export default class EchoTool extends ToolContext { + async execute(input: Input): Promise { + return { echoed: input.message }; + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/region-query.tool.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/region-query.tool.ts new file mode 100644 index 000000000..595d7d466 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/region-query.tool.ts @@ -0,0 +1,34 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + +/** + * Exercises the `x-mcp-header` schema extension from protocol 2026-07-28. + * + * `region` is mirrored into the `Mcp-Param-Region` HTTP header by conforming + * clients; the server MUST validate that the header matches the body value and + * reject a mismatch with `-32020` (HeaderMismatch). + */ +const inputSchema = { + region: z.string().describe('Region to run the query in').meta({ 'x-mcp-header': 'Region' }), + query: z.string().describe('The query to run'), +}; + +const outputSchema = z.object({ + region: z.string(), + query: z.string(), +}); + +type Input = z.output>; +type Output = z.output; + +@Tool({ + name: 'region-query', + description: 'Runs a query in a given region; region is mirrored into an HTTP header', + inputSchema, + outputSchema, +}) +export default class RegionQueryTool extends ToolContext { + async execute(input: Input): Promise { + return { region: input.region, query: input.query }; + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/main.ts b/apps/e2e/demo-e2e-protocol-2026/src/main.ts new file mode 100644 index 000000000..19437c0c0 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/main.ts @@ -0,0 +1,28 @@ +import { FrontMcp, LogLevel } from '@frontmcp/sdk'; + +import { ProtoApp } from './apps/proto'; + +const port = parseInt(process.env['PORT'] ?? '3160', 10); + +/** + * E2E server for MCP protocol revision 2026-07-28. + * + * The same server must speak BOTH eras: + * - 2026-07-28 — stateless, no `initialize`, per-request `_meta`. + * - 2024-11-05 … 2025-11-25 — session + `initialize` handshake (unchanged). + * + * Version selection is per-request, so no configuration switch is involved. + */ +@FrontMcp({ + info: { name: 'Demo E2E Protocol 2026', version: '0.1.0' }, + apps: [ProtoApp], + logging: { level: LogLevel.Warn }, + http: { port }, + auth: { mode: 'public' }, + elicitation: { enabled: true }, + // `full` turns on every legacy transport (legacy SSE, streamable, stateful + // and stateless JSON) so the backward-compatibility suite exercises the + // widest possible surface alongside the new 2026-07-28 path. + transport: { protocol: 'full' }, +}) +export default class Server {} diff --git a/apps/e2e/demo-e2e-protocol-2026/tsconfig.app.json b/apps/e2e/demo-e2e-protocol-2026/tsconfig.app.json new file mode 100644 index 000000000..3fdc8911e --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/tsconfig.app.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "emitDecoratorMetadata": true, + "experimentalDecorators": true + }, + "exclude": ["jest.config.ts", "jest.e2e.config.ts", "src/**/*.spec.ts", "e2e/**/*.ts"], + "include": ["src/**/*.ts"] +} diff --git a/apps/e2e/demo-e2e-protocol-2026/tsconfig.e2e.json b/apps/e2e/demo-e2e-protocol-2026/tsconfig.e2e.json new file mode 100644 index 000000000..2e879f3d2 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/tsconfig.e2e.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node", "jest"], + "emitDecoratorMetadata": true, + "experimentalDecorators": true + }, + "include": ["e2e/**/*.ts", "jest.e2e.config.ts"] +} diff --git a/apps/e2e/demo-e2e-protocol-2026/tsconfig.json b/apps/e2e/demo-e2e-protocol-2026/tsconfig.json new file mode 100644 index 000000000..a72bf4f01 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.e2e.json" + } + ], + "compilerOptions": { + "esModuleInterop": true + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/webpack.config.js b/apps/e2e/demo-e2e-protocol-2026/webpack.config.js new file mode 100644 index 000000000..39e3696d9 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/webpack.config.js @@ -0,0 +1,28 @@ +const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { join } = require('path'); + +module.exports = { + output: { + path: join(__dirname, '../../../dist/apps/e2e/demo-e2e-protocol-2026'), + ...(process.env.NODE_ENV !== 'production' && { + devtoolModuleFilenameTemplate: '[absolute-resource-path]', + }), + }, + mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', + devtool: 'eval-cheap-module-source-map', + plugins: [ + new NxAppWebpackPlugin({ + target: 'node', + compiler: 'tsc', + main: './src/main.ts', + sourceMap: true, + tsConfig: './tsconfig.app.json', + assets: [], + externalDependencies: 'all', + optimization: false, + outputHashing: 'none', + generatePackageJson: false, + buildLibsFromSource: true, + }), + ], +}; diff --git a/libs/adapters/package.json b/libs/adapters/package.json index 102c06eeb..99b6e3f63 100644 --- a/libs/adapters/package.json +++ b/libs/adapters/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/adapters", - "version": "1.4.0", + "version": "1.5.7", "description": "Adapters for the FrontMCP framework", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", @@ -67,15 +67,15 @@ "node": ">=24.0.0" }, "dependencies": { - "@frontmcp/auth": "1.4.0", - "@frontmcp/di": "1.4.0", - "@frontmcp/sdk": "1.4.0", - "@frontmcp/utils": "1.4.0", + "@frontmcp/auth": "1.5.7", + "@frontmcp/di": "1.5.7", + "@frontmcp/sdk": "1.5.7", + "@frontmcp/utils": "1.5.7", "js-yaml": "^4.1.0", "mcp-from-openapi": "2.5.1", "openapi-types": "^12.1.3" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0" + "@frontmcp/lazy-zod": "1.5.7" } } diff --git a/libs/auth/package.json b/libs/auth/package.json index a9323bd08..89a1f2476 100644 --- a/libs/auth/package.json +++ b/libs/auth/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/auth", - "version": "1.4.0", + "version": "1.5.7", "description": "FrontMCP Auth - Authentication, session management, and credential vault", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", @@ -48,8 +48,8 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0", - "@frontmcp/storage-sqlite": "1.4.0", + "@frontmcp/lazy-zod": "1.5.7", + "@frontmcp/storage-sqlite": "1.5.7", "@vercel/kv": "^3.0.0", "ioredis": "^5.0.0" }, @@ -65,8 +65,8 @@ } }, "dependencies": { - "@frontmcp/di": "1.4.0", - "@frontmcp/utils": "1.4.0", + "@frontmcp/di": "1.5.7", + "@frontmcp/utils": "1.5.7", "jose": "^6.0.0" }, "devDependencies": { diff --git a/libs/cli/package.json b/libs/cli/package.json index 737b5f29d..f0e9bfbd5 100644 --- a/libs/cli/package.json +++ b/libs/cli/package.json @@ -1,6 +1,6 @@ { "name": "frontmcp", - "version": "1.4.0", + "version": "1.5.7", "description": "FrontMCP command line interface", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", @@ -35,9 +35,9 @@ }, "dependencies": { "@clack/prompts": "^0.10.0", - "@frontmcp/lazy-zod": "1.4.0", - "@frontmcp/skills": "1.4.0", - "@frontmcp/utils": "1.4.0", + "@frontmcp/lazy-zod": "1.5.7", + "@frontmcp/skills": "1.5.7", + "@frontmcp/utils": "1.5.7", "@rspack/core": "^1.7.6", "commander": "^13.0.0", "esbuild": "^0.27.3", diff --git a/libs/di/package.json b/libs/di/package.json index bee3173d6..72d5be33d 100644 --- a/libs/di/package.json +++ b/libs/di/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/di", - "version": "1.4.0", + "version": "1.5.7", "description": "Generic dependency injection container and registry utilities for TypeScript applications", "author": "AgentFront ", "license": "Apache-2.0", @@ -48,7 +48,7 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0", + "@frontmcp/lazy-zod": "1.5.7", "reflect-metadata": "^0.2.0" }, "devDependencies": { diff --git a/libs/edge/package.json b/libs/edge/package.json index 6e750e87e..bb9c65b3d 100644 --- a/libs/edge/package.json +++ b/libs/edge/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/edge", - "version": "1.4.0", + "version": "1.5.7", "description": "Run a FrontMCP MCP server on Cloudflare Workers / V8 isolates from a plain config — no decorators, no build step", "author": "AgentFront ", "license": "Apache-2.0", @@ -49,8 +49,8 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@frontmcp/plugin-skilled-openapi": "1.4.0", - "@frontmcp/sdk": "1.4.0" + "@frontmcp/plugin-skilled-openapi": "1.5.7", + "@frontmcp/sdk": "1.5.7" }, "peerDependenciesMeta": { "@frontmcp/plugin-skilled-openapi": { diff --git a/libs/guard/package.json b/libs/guard/package.json index 103ec6b4f..5c76eb9c7 100644 --- a/libs/guard/package.json +++ b/libs/guard/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/guard", - "version": "1.4.0", + "version": "1.5.7", "description": "Rate limiting, concurrency control, timeout, IP filtering, and traffic guard utilities for FrontMCP", "author": "AgentFront ", "license": "Apache-2.0", @@ -49,10 +49,10 @@ "node": ">=24.0.0" }, "dependencies": { - "@frontmcp/utils": "1.4.0" + "@frontmcp/utils": "1.5.7" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0" + "@frontmcp/lazy-zod": "1.5.7" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/libs/lazy-zod/package.json b/libs/lazy-zod/package.json index 165dd62bc..f3b67a163 100644 --- a/libs/lazy-zod/package.json +++ b/libs/lazy-zod/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/lazy-zod", - "version": "1.4.0", + "version": "1.5.7", "description": "Drop-in zod replacement that lazily constructs compound schemas for dramatic cold-start speedups. Exports `z` (lazy, drop-in), `eagerZ` (real zod, zero overhead), and `lazyZ` (explicit factory wrapper).", "author": "AgentFront ", "license": "Apache-2.0", diff --git a/libs/nx-plugin/package.json b/libs/nx-plugin/package.json index 864e61fcc..381905398 100644 --- a/libs/nx-plugin/package.json +++ b/libs/nx-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/nx", - "version": "1.4.0", + "version": "1.5.7", "description": "Nx plugin for FrontMCP — generators and executors for building MCP servers", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", @@ -33,7 +33,7 @@ "node": ">=24.0.0" }, "dependencies": { - "@frontmcp/skills": "1.4.0", + "@frontmcp/skills": "1.5.7", "@nx/devkit": "22.6.4", "tslib": "^2.3.0" }, diff --git a/libs/observability/package.json b/libs/observability/package.json index 30122f66c..da9f53b00 100644 --- a/libs/observability/package.json +++ b/libs/observability/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/observability", - "version": "1.4.0", + "version": "1.5.7", "description": "OpenTelemetry instrumentation, structured JSON logging, and request log objects for FrontMCP", "author": "AgentFront ", "license": "Apache-2.0", @@ -52,8 +52,8 @@ "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { - "@frontmcp/sdk": "1.4.0", - "@frontmcp/utils": "1.4.0", + "@frontmcp/sdk": "1.5.7", + "@frontmcp/utils": "1.5.7", "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", "@opentelemetry/sdk-node": "^0.219.0", "@opentelemetry/sdk-trace-base": "^2.8.0", diff --git a/libs/plugins/package.json b/libs/plugins/package.json index 914f88aa0..245eaebbe 100644 --- a/libs/plugins/package.json +++ b/libs/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/plugins", - "version": "1.4.0", + "version": "1.5.7", "description": "FrontMCP plugins meta-package - installs all official plugins", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", @@ -48,9 +48,9 @@ "node": ">=24.0.0" }, "dependencies": { - "@frontmcp/plugin-cache": "1.4.0", - "@frontmcp/plugin-codecall": "1.4.0", - "@frontmcp/plugin-dashboard": "1.4.0", - "@frontmcp/plugin-remember": "1.4.0" + "@frontmcp/plugin-cache": "1.5.7", + "@frontmcp/plugin-codecall": "1.5.7", + "@frontmcp/plugin-dashboard": "1.5.7", + "@frontmcp/plugin-remember": "1.5.7" } } diff --git a/libs/protocol/package.json b/libs/protocol/package.json index 96b80e10c..92aae2c8f 100644 --- a/libs/protocol/package.json +++ b/libs/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/protocol", - "version": "1.4.0", + "version": "1.5.7", "description": "Centralized MCP protocol types, server, and client re-exports for FrontMCP", "author": "AgentFront ", "license": "Apache-2.0", diff --git a/libs/protocol/src/index.ts b/libs/protocol/src/index.ts index 6a326666e..36aac166c 100644 --- a/libs/protocol/src/index.ts +++ b/libs/protocol/src/index.ts @@ -10,6 +10,9 @@ // MCP protocol types (environment-agnostic) export * from './types'; +// MCP protocol revision 2026-07-28 (not yet shipped by @modelcontextprotocol/sdk) +export * from './types-2026'; + // Auth types export * from './auth-types'; diff --git a/libs/protocol/src/types-2026.ts b/libs/protocol/src/types-2026.ts new file mode 100644 index 000000000..a38b2efdf --- /dev/null +++ b/libs/protocol/src/types-2026.ts @@ -0,0 +1,252 @@ +/** + * MCP protocol revision **2026-07-28**. + * + * The upstream `@modelcontextprotocol/sdk` (1.30.0 at time of writing) only + * ships schemas up to `2025-11-25`, so this revision is defined here — inside + * the single boundary module that owns protocol types — rather than reached for + * from every call site. When upstream catches up, this file is the only place + * that has to change. + * + * Everything here is ADDITIVE. The 2025-and-earlier types re-exported from + * `./types` are untouched, because a server must keep serving both eras. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/changelog + */ + +import type { Implementation, LoggingLevel, RequestId } from './types'; + +/** The protocol revision this module implements. */ +export const PROTOCOL_2026_07_28 = '2026-07-28' as const; + +export type Protocol2026Version = typeof PROTOCOL_2026_07_28; + +/** + * Reserved `_meta` keys introduced by 2026-07-28. + * + * Statelessness means the handshake's payload now rides on every request, so + * these keys carry what `initialize` used to negotiate once. + */ +export const MCP_2026_META = { + /** Request: protocol version; MUST match the `MCP-Protocol-Version` header. */ + protocolVersion: 'io.modelcontextprotocol/protocolVersion', + /** Request: self-reported client identity. */ + clientInfo: 'io.modelcontextprotocol/clientInfo', + /** Request: capabilities for THIS request only — never inferred from prior ones. */ + clientCapabilities: 'io.modelcontextprotocol/clientCapabilities', + /** Request: opt-in log level; absent means "send me no `notifications/message`". */ + logLevel: 'io.modelcontextprotocol/logLevel', + /** Result: self-reported server identity. */ + serverInfo: 'io.modelcontextprotocol/serverInfo', + /** Notification/result: id of the `subscriptions/listen` stream it belongs to. */ + subscriptionId: 'io.modelcontextprotocol/subscriptionId', +} as const; + +/** + * JSON-RPC error codes allocated to the MCP specification. + * + * 2026-07-28 partitions the server-error range: `-32000`…`-32019` stays + * implementation-defined, `-32020`…`-32099` is reserved for the spec. The three + * codes below were renumbered into that block from their draft values. + */ +export const MCP_2026_ERROR_CODES = { + /** Headers disagree with the body, or a required header is missing/malformed. */ + headerMismatch: -32020, + /** The request needs a client capability that was not declared. */ + missingRequiredClientCapability: -32021, + /** The requested protocol version is not supported by this server. */ + unsupportedProtocolVersion: -32022, +} as const; + +/** + * Codes retired by this revision. Kept as documentation so they are never + * reallocated: `-32002` was resource-not-found (now `-32602`) and `-32042` was + * URL-elicitation-required (2025-11-25 only). + */ +export const MCP_2026_RETIRED_ERROR_CODES = [-32002, -32042] as const; + +/** Methods this revision removed from the core protocol. */ +export const MCP_2026_REMOVED_METHODS = [ + 'initialize', + 'notifications/initialized', + 'ping', + 'logging/setLevel', + 'notifications/roots/list_changed', + 'resources/subscribe', + 'resources/unsubscribe', + 'tasks/list', + 'tasks/result', +] as const; + +/** Methods this revision introduced. */ +export const MCP_2026_ADDED_METHODS = ['server/discover', 'subscriptions/listen'] as const; + +// ───────────────────────────────────────────────────────────────────────────── +// Common shapes +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Discriminates a final result from an interim one. + * + * Left open (`| string`) exactly as the spec does, so a future result type does + * not become a parse error. + */ +export type ResultType = 'complete' | 'input_required' | (string & {}); + +export interface RequestMeta2026 { + progressToken?: string | number; + [MCP_2026_META.protocolVersion]: string; + [MCP_2026_META.clientInfo]?: Implementation; + [MCP_2026_META.clientCapabilities]: ClientCapabilities2026; + [MCP_2026_META.logLevel]?: LoggingLevel; + [key: string]: unknown; +} + +export interface ResultMeta2026 { + [MCP_2026_META.serverInfo]?: Implementation; + [key: string]: unknown; +} + +export interface NotificationMeta2026 { + [MCP_2026_META.subscriptionId]?: RequestId; + [key: string]: unknown; +} + +export interface Result2026 { + _meta?: ResultMeta2026; + resultType: ResultType; + [key: string]: unknown; +} + +/** + * A result carrying client-side caching hints. + * + * REQUIRED on `tools/list`, `prompts/list`, `resources/list`, + * `resources/templates/list`, `resources/read`, and `server/discover`. + */ +export interface CacheableResult extends Result2026 { + /** Freshness hint in milliseconds; `0` means "always revalidate". */ + ttlMs: number; + /** `public` = safe to share across authorization contexts; `private` = not. */ + cacheScope: 'public' | 'private'; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Capabilities +// ───────────────────────────────────────────────────────────────────────────── + +export interface ClientCapabilities2026 { + experimental?: Record>; + /** @deprecated Deprecated in 2026-07-28 (SEP-2577). */ + roots?: Record; + /** @deprecated Deprecated in 2026-07-28 (SEP-2577). */ + sampling?: { context?: Record; tools?: Record }; + elicitation?: { form?: Record; url?: Record }; + /** Optional MCP extensions; keys are prefixed identifiers. */ + extensions?: Record>; +} + +export interface ServerCapabilities2026 { + experimental?: Record>; + /** @deprecated Deprecated in 2026-07-28 (SEP-2577). */ + logging?: Record; + completions?: Record; + prompts?: { listChanged?: boolean }; + resources?: { subscribe?: boolean; listChanged?: boolean }; + tools?: { listChanged?: boolean }; + /** Optional MCP extensions; keys are prefixed identifiers. */ + extensions?: Record>; +} + +// ───────────────────────────────────────────────────────────────────────────── +// server/discover +// ───────────────────────────────────────────────────────────────────────────── + +export interface DiscoverResult extends CacheableResult { + /** Versions the client may choose from for subsequent requests. */ + supportedVersions: string[]; + capabilities: ServerCapabilities2026; + instructions?: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// subscriptions/listen +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The notification types a client opts in to. Every type is opt-in: the server + * MUST NOT push a type the client did not ask for. + */ +export interface SubscriptionFilter { + toolsListChanged?: boolean; + promptsListChanged?: boolean; + resourcesListChanged?: boolean; + /** Replaces the removed `resources/subscribe` RPC. */ + resourceSubscriptions?: string[]; +} + +export interface SubscriptionsListenParams { + notifications: SubscriptionFilter; + _meta: RequestMeta2026; +} + +export interface SubscriptionsAcknowledgedParams { + /** The subset of requested types the server actually agreed to honor. */ + notifications: SubscriptionFilter; + _meta?: NotificationMeta2026; +} + +export const SUBSCRIPTIONS_ACKNOWLEDGED_METHOD = 'notifications/subscriptions/acknowledged' as const; + +// ───────────────────────────────────────────────────────────────────────────── +// Multi Round-Trip Requests (MRTR) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A server-initiated request embedded in a result rather than sent on the wire. + * + * 2026-07-28 removed the server→client request direction, so sampling, + * elicitation, and roots all travel this way. + */ +export interface InputRequest { + method: 'elicitation/create' | 'sampling/createMessage' | 'roots/list' | (string & {}); + params?: Record; +} + +export type InputRequests = Record; + +export type InputResponses = Record>; + +/** + * The interim result that asks the client for more input. + * + * At least one of `inputRequests` / `requestState` MUST be present. The client + * retries the ORIGINAL request with `inputResponses` and the echoed + * `requestState`; it must treat `requestState` as opaque. + */ +export interface InputRequiredResult extends Result2026 { + resultType: 'input_required'; + inputRequests?: InputRequests; + requestState?: string; +} + +/** Params any client request may carry when resuming an MRTR exchange. */ +export interface InputResponseParams { + inputResponses?: InputResponses; + requestState?: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Guards +// ───────────────────────────────────────────────────────────────────────────── + +/** True when `version` is the 2026-07-28 revision. */ +export function isProtocol2026(version: unknown): version is Protocol2026Version { + return version === PROTOCOL_2026_07_28; +} + +/** Reads the protocol version a request declares in its `_meta`, if any. */ +export function readDeclaredProtocolVersion(body: unknown): string | undefined { + const params = (body as { params?: { _meta?: Record } } | undefined)?.params; + const declared = params?._meta?.[MCP_2026_META.protocolVersion]; + return typeof declared === 'string' ? declared : undefined; +} diff --git a/libs/react/package.json b/libs/react/package.json index aab05875c..dad5216b9 100644 --- a/libs/react/package.json +++ b/libs/react/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/react", - "version": "1.4.0", + "version": "1.5.7", "description": "React hooks, components, and AI SDK integration for FrontMCP", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", @@ -91,9 +91,9 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0", - "@frontmcp/sdk": "1.4.0", - "@frontmcp/utils": "1.4.0", + "@frontmcp/lazy-zod": "1.5.7", + "@frontmcp/sdk": "1.5.7", + "@frontmcp/utils": "1.5.7", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "react-router-dom": "^7.0.0" @@ -107,8 +107,8 @@ } }, "dependencies": { - "@frontmcp/sdk": "1.4.0", - "@frontmcp/utils": "1.4.0" + "@frontmcp/sdk": "1.5.7", + "@frontmcp/utils": "1.5.7" }, "devDependencies": { "typescript": "^5.9.3" diff --git a/libs/sdk/package.json b/libs/sdk/package.json index 5f3b82f3f..4815440b1 100644 --- a/libs/sdk/package.json +++ b/libs/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/sdk", - "version": "1.5.6", + "version": "1.5.7", "description": "FrontMCP SDK", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", @@ -94,8 +94,8 @@ "peerDependencies": { "@anthropic-ai/sdk": "^0.30.0 || ^0.78.0", "@enclave-vm/core": "^2.15.1", - "@frontmcp/observability": "1.5.6", - "@frontmcp/storage-sqlite": "1.5.6", + "@frontmcp/observability": "1.5.7", + "@frontmcp/storage-sqlite": "1.5.7", "@opentelemetry/api": "^1.9.0", "@opentelemetry/sdk-trace-base": "^2.8.0", "@vercel/kv": "^3.0.0", @@ -136,13 +136,13 @@ } }, "dependencies": { - "@frontmcp/auth": "1.5.6", - "@frontmcp/di": "1.5.6", - "@frontmcp/guard": "1.5.6", - "@frontmcp/lazy-zod": "1.5.6", - "@frontmcp/protocol": "1.5.6", - "@frontmcp/uipack": "1.5.6", - "@frontmcp/utils": "1.5.6", + "@frontmcp/auth": "1.5.7", + "@frontmcp/di": "1.5.7", + "@frontmcp/guard": "1.5.7", + "@frontmcp/lazy-zod": "1.5.7", + "@frontmcp/protocol": "1.5.7", + "@frontmcp/uipack": "1.5.7", + "@frontmcp/utils": "1.5.7", "@types/cors": "^2.8.17", "ioredis": "^5.8.0", "jose": "^6.1.3", diff --git a/libs/sdk/src/common/utils/decide-request-intent.utils.ts b/libs/sdk/src/common/utils/decide-request-intent.utils.ts index 4f6c407b4..1a46b067b 100644 --- a/libs/sdk/src/common/utils/decide-request-intent.utils.ts +++ b/libs/sdk/src/common/utils/decide-request-intent.utils.ts @@ -12,6 +12,10 @@ export const intentSchema = z.union([ z.literal('stateful-http'), z.literal('stateless-http'), z.literal('delete-session'), + // Protocol 2026-07-28: stateless, sessionless, per-request version negotiation. + // Decided by the router from the request's headers/`_meta`, not by the bitmap + // rules below — those describe the session-oriented transports only. + z.literal('mcp-2026'), z.literal('unknown'), ]); @@ -40,7 +44,8 @@ export type HttpRequestIntent = | 'streamable-http' | 'stateful-http' | 'stateless-http' - | 'delete-session'; + | 'delete-session' + | 'mcp-2026'; export type Intent = HttpRequestIntent | 'unknown'; diff --git a/libs/sdk/src/context/frontmcp-context.ts b/libs/sdk/src/context/frontmcp-context.ts index e48ec41f9..52af44c51 100644 --- a/libs/sdk/src/context/frontmcp-context.ts +++ b/libs/sdk/src/context/frontmcp-context.ts @@ -25,6 +25,24 @@ import { generateTraceContext, type TraceContext } from './trace-context'; /** Symbol key for storing pre-resolved elicit result in context store */ const PRE_RESOLVED_ELICIT_KEY = Symbol.for('frontmcp:pre-resolved-elicit'); +/** Symbol key for the in-flight MRTR exchange (protocol 2026-07-28) */ +const MRTR_EXCHANGE_KEY = Symbol.for('frontmcp:mrtr-exchange'); + +/** + * Structural view of the MRTR exchange stored on the context. + * + * Typed structurally rather than by importing `MrtrExchange` so the context + * module stays free of a dependency on the transport layer. + */ +export interface MrtrExchangeRef { + resolveElicitation(pending: { + message: string; + requestedSchema: Record; + mode?: 'form' | 'url'; + url?: string; + }): ElicitResult; +} + /** * Request metadata extracted from HTTP headers. */ @@ -449,6 +467,33 @@ export class FrontMcpContext { this.store.set(PRE_RESOLVED_ELICIT_KEY, result); } + // ===================== + // MRTR (protocol 2026-07-28) + // ===================== + + /** + * Attach the request's MRTR exchange. + * + * Set by the 2026-07-28 dispatcher before running a request. Its presence is + * what switches `elicit()` from the inline server→client round trip (removed + * in this revision) to the `InputRequiredResult` round trip. + * + * @internal + */ + setMrtrExchange(exchange: MrtrExchangeRef): void { + this.store.set(MRTR_EXCHANGE_KEY, exchange); + } + + /** + * Get the request's MRTR exchange, if this request is running under + * protocol 2026-07-28. + * + * @internal + */ + getMrtrExchange(): MrtrExchangeRef | undefined { + return this.store.get(MRTR_EXCHANGE_KEY) as MrtrExchangeRef | undefined; + } + /** * Get the pre-resolved elicit result, if any. * diff --git a/libs/sdk/src/elicitation/helpers/elicit.helper.ts b/libs/sdk/src/elicitation/helpers/elicit.helper.ts index 0fcb86db4..1c9dab40c 100644 --- a/libs/sdk/src/elicitation/helpers/elicit.helper.ts +++ b/libs/sdk/src/elicitation/helpers/elicit.helper.ts @@ -85,13 +85,35 @@ export async function performElicit( throw new ElicitationDisabledError(); } - // 1. Validate session + const ctx = tryGetContext(); + + // 1. Multi Round-Trip Requests (protocol 2026-07-28). + // + // This revision removed the server→client request direction entirely, so the + // inline round trip below cannot happen. The exchange either hands back an + // answer the client already supplied, or throws `InputRequiredSignal` so the + // dispatcher can return an `InputRequiredResult` and let the client retry. + // + // Checked BEFORE the session guard: 2026-07-28 has no protocol-level + // sessions, so `sessionId` is not meaningful on that path. + const mrtr = ctx?.getMrtrExchange?.(); + if (mrtr) { + const zodSchema = + requestedSchema instanceof z.ZodType ? requestedSchema : z.object(requestedSchema as z.ZodRawShape); + const answer = mrtr.resolveElicitation({ + message, + requestedSchema: toJSONSchema(zodSchema) as Record, + ...(options?.mode ? { mode: options.mode } : {}), + }); + return answer as ElicitResult ? O : unknown>; + } + + // 2. Validate session if (!sessionId) { throw new ElicitationNotSupportedError('No session available for elicitation'); } - // 2. Check for pre-resolved result (fallback re-invocation case) - const ctx = tryGetContext(); + // 3. Check for pre-resolved result (fallback re-invocation case) const preResolved = ctx?.getPreResolvedElicitResult?.(); if (preResolved) { // Clear the pre-resolved result to prevent reuse @@ -99,12 +121,12 @@ export async function performElicit( return preResolved as ElicitResult ? O : unknown>; } - // 3. Check client capabilities + // 4. Check client capabilities const capabilities = getClientCapabilities(sessionId); const mode = options?.mode ?? 'form'; if (!supportsElicitation(capabilities, mode)) { - // 4. Fallback: throw error with context for re-invocation + // 5. Fallback: throw error with context for re-invocation // This triggers the fallback flow handled by CallToolFlow/CallAgentFlow const elicitId = options?.elicitationId ?? generateElicitationId(); const ttl = options?.ttl ?? DEFAULT_ELICIT_TTL; diff --git a/libs/sdk/src/errors/index.ts b/libs/sdk/src/errors/index.ts index 2cf339988..bd595b94c 100644 --- a/libs/sdk/src/errors/index.ts +++ b/libs/sdk/src/errors/index.ts @@ -105,6 +105,9 @@ export { ElicitationSubscriptionError, } from './elicitation.error'; +// Export MRTR signals (protocol 2026-07-28) +export { InputRequiredSignal, MissingClientCapabilityError } from './mrtr.error'; + // Export remote MCP errors export { // Connection errors diff --git a/libs/sdk/src/errors/mrtr.error.ts b/libs/sdk/src/errors/mrtr.error.ts new file mode 100644 index 000000000..6207a6c1b --- /dev/null +++ b/libs/sdk/src/errors/mrtr.error.ts @@ -0,0 +1,49 @@ +/** + * Multi Round-Trip Requests (MRTR) signals — protocol 2026-07-28, SEP-2322. + * + * 2026-07-28 removed the server→client request direction. When a server needs + * sampling, elicitation, or roots it can no longer ask inline; it answers the + * ORIGINAL request with an `InputRequiredResult` and the client retries with + * the answers attached. + * + * These are control-flow signals, not failures: `InputRequiredSignal` unwinds + * the tool out of `execute()` so the dispatcher can turn the pending request + * into an interim result. + */ + +import type { InputRequests } from '@frontmcp/protocol'; + +import { PublicMcpError } from './mcp.error'; + +/** + * Raised when execution cannot continue without client-supplied input. + * + * Carries the requests to embed in `InputRequiredResult.inputRequests` plus the + * opaque `requestState` the client must echo back on the retry. + */ +export class InputRequiredSignal extends PublicMcpError { + constructor( + /** Server-assigned keys → the request each one stands for. */ + public readonly inputRequests: InputRequests, + /** Opaque blob the client returns verbatim; encodes answers gathered so far. */ + public readonly requestState: string, + ) { + super('Additional input required', 'INPUT_REQUIRED', 200); + } +} + +/** + * Raised when a request needs a client capability the client did not declare. + * + * Under 2026-07-28 capabilities are per-request, so this is a plain validation + * failure (`400` + `-32021`) rather than a session-level negotiation problem. + */ +export class MissingClientCapabilityError extends PublicMcpError { + constructor( + /** The capability set the server needs, in `ClientCapabilities` shape. */ + public readonly requiredCapabilities: Record, + message = 'Request requires a client capability that was not declared', + ) { + super(message, 'MISSING_REQUIRED_CLIENT_CAPABILITY', 400); + } +} diff --git a/libs/sdk/src/scope/flows/http.request.flow.ts b/libs/sdk/src/scope/flows/http.request.flow.ts index 7b128df85..b28f02c0e 100644 --- a/libs/sdk/src/scope/flows/http.request.flow.ts +++ b/libs/sdk/src/scope/flows/http.request.flow.ts @@ -33,6 +33,7 @@ import { type ServerRequest, } from '../../common'; import { SessionVerificationFailedError } from '../../errors'; +import { isProtocol2026Request } from '../../transport/mcp-2026'; import { type Scope } from '../scope.instance'; const plan = { @@ -52,6 +53,9 @@ const plan = { // Node handle stages below). On the Node/Express path it's a no-op and falls // through to the runtime-coupled stages. 'handleWebFetch', + // Protocol 2026-07-28. Runs before the session-era handlers because it is + // claimed by an explicit per-request version declaration, never by fallback. + 'handleMcp2026', 'handleLegacySse', 'handleSse', 'handleStreamableHttp', @@ -369,6 +373,35 @@ export default class HttpRequestFlow extends FlowBase { debug: decision.debug, }); + // ── MCP protocol 2026-07-28 ──────────────────────────────────────────── + // This revision is stateless: no `initialize`, no `Mcp-Session-Id`, and + // version negotiation happens per-request. None of the session-oriented + // branches below apply to it, and several would actively misroute it + // (a sessionless POST becomes "send initialize first", a DELETE becomes + // session termination), so the claim is made here — before any of them. + // + // Detection is explicit: only a request that declares 2026-07-28 (or uses + // a method introduced by it) is claimed, which is what leaves every + // earlier revision on its original path. + if (isProtocol2026Request({ headers: request.headers, body: request.body })) { + const verify = this.state.required.verifyResult; + if (verify.kind === 'authorized') { + request[ServerRequestTokens.auth] = verify.authorization; + } else if (verify.kind === 'forbidden') { + this.logger.warn(`[${this.requestId}] mcp-2026: forbidden, insufficient scope`); + this.respond(httpRespond.forbidden({ headers: { 'WWW-Authenticate': verify.prmMetadataHeader } })); + return; + } else { + this.logger.warn(`[${this.requestId}] mcp-2026: unauthorized`); + this.respond(httpRespond.unauthorized({ headers: { 'WWW-Authenticate': verify.prmMetadataHeader } })); + return; + } + + this.logger.verbose(`[${this.requestId}] routing to mcp-2026 pipeline`); + this.state.set('intent', 'mcp-2026'); + return; + } + // #380 — Detect JSON-RPC POST/GET requests without a session/initialize // and surface a structured JSON-RPC error envelope instead of letting // them fall through to Express's default 404 (which returns HTML and @@ -657,6 +690,34 @@ export default class HttpRequestFlow extends FlowBase { } } + /** + * MCP protocol 2026-07-28. Delegates to `handle:mcp-2026`, which owns header + * validation, `server/discover`, `subscriptions/listen`, MRTR, and result + * decoration for that revision. + */ + @Stage('handleMcp2026', { + filter: ({ + state: { + required: { intent }, + }, + }) => intent === 'mcp-2026', + }) + async handleMcp2026() { + try { + const response = await this.scope.runFlow('handle:mcp-2026', this.rawInput); + if (response) { + this.respond(response); + } + this.handled(); + } catch (error) { + // FlowControl is expected control flow, not an error + if (!(error instanceof FlowControl)) { + this.logError(error, 'handleMcp2026'); + } + throw error; + } + } + @Stage('handleLegacySse', { filter: ({ state: { diff --git a/libs/sdk/src/tool/flows/call-tool.flow.ts b/libs/sdk/src/tool/flows/call-tool.flow.ts index 421ebf2d9..82ae8046c 100644 --- a/libs/sdk/src/tool/flows/call-tool.flow.ts +++ b/libs/sdk/src/tool/flows/call-tool.flow.ts @@ -37,10 +37,12 @@ import { AuthorizationRequiredError, ElicitationFallbackRequired, EntryUnavailableError, + InputRequiredSignal, InternalMcpError, InvalidInputError, InvalidMethodError, InvalidOutputError, + MissingClientCapabilityError, RateLimitError, TaskAugmentationNotSupportedError, TaskAugmentationRequiredError, @@ -1081,6 +1083,12 @@ export default class CallToolFlow extends FlowBase { if (error instanceof FlowControl) { throw error; } + // MRTR signals (protocol 2026-07-28) are control flow, not failures: the + // tool is asking the client for input. Wrapping them in ToolExecutionError + // would turn a legitimate `input_required` round trip into a tool crash. + if (error instanceof InputRequiredSignal || error instanceof MissingClientCapabilityError) { + throw error; + } // Re-throw timeout errors without wrapping if (error instanceof ExecutionTimeoutError) { this.logger.warn('execute: tool execution timed out', { diff --git a/libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts b/libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts new file mode 100644 index 000000000..253dde65b --- /dev/null +++ b/libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts @@ -0,0 +1,300 @@ +/** + * `handle:mcp-2026` — the request pipeline for MCP protocol revision 2026-07-28. + * + * This revision is stateless: there is no `initialize` handshake, no + * `Mcp-Session-Id`, and no server→client request direction. Rather than bend + * the session-oriented transports into that shape, it gets its own flow — + * a sibling of `handle:streamable-http` / `handle:stateless-http`, hookable at + * every stage like all the others, reached only when the request explicitly + * declares the 2026-07-28 revision. + * + * Every earlier revision continues down its original flow untouched. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http + */ +import { z } from '@frontmcp/lazy-zod'; +import { MCP_2026_META, type SubscriptionFilter } from '@frontmcp/protocol'; + +import { + Flow, + FlowBase, + FlowHooksOf, + httpInputSchema, + httpOutputSchema, + httpRespond, + ServerRequestTokens, + type Authorization, + type FlowPlan, + type FlowRunOptions, +} from '../../common'; +import { type Scope } from '../../scope'; +import { + createSubscriptionStream, + dispatch2026, + isProtocol2026Request, + MCP_HEADERS, + readHeader, + validate2026Request, + type JsonRpcErrorPayload, +} from '../mcp-2026'; + +export const plan = { + pre: ['parseInput', 'validate', 'router'], + execute: ['handleNotification', 'handleSubscriptions', 'handleMessage'], + post: [], + finalize: ['cleanup'], +} as const satisfies FlowPlan; + +export const stateSchema = z.object({ + /** Negotiated protocol version for this request. */ + version: z.string().optional(), + requestType: z.enum(['notification', 'subscriptions', 'message']).optional(), + isAnonymous: z.boolean().default(true), +}); + +const name = 'handle:mcp-2026' as const; +const { Stage } = FlowHooksOf(name); + +declare global { + interface ExtendFlows { + 'handle:mcp-2026': FlowRunOptions< + HandleMcp2026Flow, + typeof plan, + typeof httpInputSchema, + typeof httpOutputSchema, + typeof stateSchema + >; + } +} + +/** Build the JSON-RPC error envelope for a failed 2026-07-28 request. */ +function errorResponse(status: number, error: JsonRpcErrorPayload, id: unknown) { + return httpRespond.json( + { + jsonrpc: '2.0', + id: id === undefined ? null : id, + error, + }, + { status }, + ); +} + +@Flow({ + name, + plan, + access: 'public', + inputSchema: httpInputSchema, + outputSchema: httpOutputSchema, +}) +export default class HandleMcp2026Flow extends FlowBase { + name = name; + + private get log() { + return this.scope.logger.child('HandleMcp2026Flow'); + } + + @Stage('parseInput') + async parseInput() { + const { request } = this.rawInput; + const auth = request[ServerRequestTokens.auth] as Authorization | undefined; + + this.state.set( + stateSchema.parse({ + isAnonymous: !auth?.token || auth.token.length === 0 || auth.session?.payload?.isPublic === true, + }), + ); + } + + /** + * Enforce the transport rules of this revision BEFORE any handler runs. + * + * Header/body agreement is a security control, not a formality: an + * intermediary may route on `Mcp-Method` / `Mcp-Name` while the server + * executes on the body, so a disagreement must be refused rather than + * resolved in favour of one side. + */ + @Stage('validate') + async validate() { + const { request } = this.rawInput; + const method = request.method.toUpperCase(); + + // GET and DELETE were the session-era verbs (standalone SSE stream and + // session termination). Both are gone; the spec prescribes 405. + if (method === 'GET' || method === 'DELETE') { + this.respond({ + kind: 'text', + status: 405, + body: `HTTP ${method} is not supported by MCP protocol 2026-07-28`, + contentType: 'text/plain; charset=utf-8', + headers: { Allow: 'POST' }, + }); + return; + } + + const body = (request.body ?? {}) as Record; + const scope = this.scope as unknown as Scope; + + const result = validate2026Request({ + headers: request.headers as Record | undefined, + body, + lookupToolSchema: (toolName) => this.findToolSchema(scope, toolName), + }); + + if (!result.ok) { + this.log.info('validate: rejected', { code: result.error.code, message: result.error.message }); + this.respond(errorResponse(result.status, result.error, body['id'])); + return; + } + + this.state.set('version', result.version); + } + + /** + * Resolve a tool's input JSON Schema so `x-mcp-header` annotations can be + * validated against the call arguments. + * + * Uses the SAME resolution `tools:call-tool` uses (`getTools(true)` matched on + * `fullName` or `name`, including hidden tools). Anything looser would let a + * header-validated call and the call that actually executes disagree about + * which tool they mean. + */ + private findToolSchema(scope: Scope, toolName: string): Record | null { + const match = scope.tools + .getTools(true) + .find((entry) => entry.fullName === toolName || entry.metadata.name === toolName); + return match?.getInputJsonSchema() ?? null; + } + + @Stage('router') + async router() { + const body = (this.rawInput.request.body ?? {}) as Record; + const isNotification = body['id'] === undefined || body['id'] === null; + + if (isNotification) { + this.state.set('requestType', 'notification'); + return; + } + + this.state.set('requestType', body['method'] === 'subscriptions/listen' ? 'subscriptions' : 'message'); + } + + /** + * A JSON-RPC notification POST is acknowledged with `202 Accepted` and no + * body. This revision defines no client→server notifications over HTTP + * (cancellation is signalled by closing the stream), so nothing is dispatched. + */ + @Stage('handleNotification', { + filter: ({ state }) => state.required.requestType === 'notification', + }) + async handleNotification() { + this.respond({ kind: 'text', status: 202, body: '', contentType: 'text/plain; charset=utf-8' }); + } + + /** + * `subscriptions/listen` owns its HTTP response for the life of the + * subscription, so it is answered with an SSE stream rather than a buffered + * result. The stream is an `AsyncIterable`, which the Node writer and the Web + * response renderer both know how to drain — no runtime-specific branch here. + */ + @Stage('handleSubscriptions', { + filter: ({ state }) => state.required.requestType === 'subscriptions', + }) + async handleSubscriptions() { + const { request, response } = this.rawInput; + const body = (request.body ?? {}) as Record; + const params = (body['params'] as Record | undefined) ?? {}; + const requested = (params['notifications'] as SubscriptionFilter | undefined) ?? {}; + + // Client disconnect is the only way a listen stream ends from the client + // side in this revision (there is no unsubscribe RPC), so tie the registry + // listeners' lifetime to the response socket. + const controller = new AbortController(); + const abort = () => controller.abort(); + (response as unknown as { on?: (event: string, cb: () => void) => void })?.on?.('close', abort); + + const { acknowledged, stream } = createSubscriptionStream({ + scope: this.scope as unknown as Scope, + subscriptionId: body['id'] as string | number, + requested, + signal: controller.signal, + }); + + this.log.info('handleSubscriptions: stream opened', { + subscriptionId: body['id'], + acknowledged: Object.keys(acknowledged), + }); + + this.respond({ + kind: 'sse', + status: 200, + stream, + contentType: 'text/event-stream', + disposition: 'inline', + headers: { + // Tell reverse proxies not to buffer, or a quiet subscription looks dead. + 'X-Accel-Buffering': 'no', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); + } + + @Stage('handleMessage', { + filter: ({ state }) => state.required.requestType === 'message', + }) + async handleMessage() { + const { request } = this.rawInput; + const body = (request.body ?? {}) as Record; + const params = (body['params'] as Record | undefined) ?? {}; + const meta = (params['_meta'] as Record | undefined) ?? {}; + const auth = request[ServerRequestTokens.auth] as Authorization | undefined; + + const clientCapabilities = (meta[MCP_2026_META.clientCapabilities] as Record | undefined) ?? {}; + + const outcome = await dispatch2026({ + scope: this.scope as unknown as Scope, + body, + clientCapabilities, + frontmcpContext: this.tryGetContext(), + authInfo: auth + ? { + token: auth.token, + clientId: auth.user?.sub, + // Sessions no longer exist at the protocol level, but the shared + // handlers key per-request state (memory, credentials) off an id. + // Derive a request-scoped one so nothing leaks between calls. + sessionId: auth.session?.id, + extra: { user: auth.user, sessionId: auth.session?.id }, + } + : undefined, + isAnonymous: this.state.required.isAnonymous, + composeInstructions: () => this.scope.metadata.instructions, + }); + + if (outcome.kind === 'error') { + this.respond(errorResponse(outcome.status, outcome.error, body['id'])); + return; + } + + this.respond( + httpRespond.json( + { + jsonrpc: '2.0', + id: body['id'] ?? null, + result: outcome.result, + }, + { status: 200 }, + ), + ); + } + + @Stage('cleanup') + async cleanup() { + // Nothing to release: this revision holds no per-request session state. + // The stage exists so plugins have a symmetric hook point with the other + // transport flows. + } +} + +/** Re-exported so the router stage of `http:request` can classify without importing the module. */ +export { isProtocol2026Request, MCP_HEADERS, readHeader }; diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/header-codec.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/header-codec.spec.ts new file mode 100644 index 000000000..c0b857f7b --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/__tests__/header-codec.spec.ts @@ -0,0 +1,102 @@ +import { + decodeHeaderValue, + encodeHeaderValue, + hasInvalidHeaderChars, + headerMatchesBodyValue, + isSentinelEncoded, +} from '../header-codec'; + +describe('2026-07-28 header value codec', () => { + describe('isSentinelEncoded', () => { + it('recognizes a wrapped value', () => { + expect(isSentinelEncoded('=?base64?aGk=?=')).toBe(true); + }); + + it('rejects a plain value', () => { + expect(isSentinelEncoded('us-west1')).toBe(false); + }); + + it('rejects an uppercase sentinel — the markers are case-sensitive', () => { + expect(isSentinelEncoded('=?BASE64?aGk=?=')).toBe(false); + }); + + it('rejects a prefix with no suffix', () => { + expect(isSentinelEncoded('=?base64?aGk=')).toBe(false); + }); + }); + + describe('encodeHeaderValue', () => { + it('passes plain ASCII through untouched', () => { + expect(encodeHeaderValue('us-west1')).toBe('us-west1'); + }); + + it('encodes non-ASCII', () => { + expect(encodeHeaderValue('Hello, 世界')).toBe('=?base64?SGVsbG8sIOS4lueVjA==?='); + }); + + it('encodes values with leading or trailing whitespace', () => { + expect(encodeHeaderValue(' padded ')).toBe('=?base64?IHBhZGRlZCA=?='); + }); + + it('encodes a literal that would otherwise look like the sentinel', () => { + // Without this the server would decode a value the client never encoded. + expect(encodeHeaderValue('=?base64?literal?=')).toBe('=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?='); + }); + }); + + describe('decodeHeaderValue', () => { + it('returns plain values unchanged', () => { + expect(decodeHeaderValue('get_weather')).toBe('get_weather'); + }); + + it('round-trips every encoded form', () => { + for (const value of ['Hello, 世界', ' padded ', '=?base64?literal?=', 'line1\nline2']) { + expect(decodeHeaderValue(encodeHeaderValue(value))).toBe(value); + } + }); + + it('returns undefined for a sentinel wrapping non-base64 payload', () => { + // Buffer.from is lenient, so a naive decode would silently produce garbage + // and then compare it to the body. Signal the failure instead. + expect(decodeHeaderValue('=?base64?not valid base64!!?=')).toBeUndefined(); + }); + }); + + describe('hasInvalidHeaderChars', () => { + it('accepts visible ASCII, space and tab', () => { + expect(hasInvalidHeaderChars('abc DEF\t123')).toBe(false); + }); + + it('flags control characters', () => { + expect(hasInvalidHeaderChars('line1\nline2')).toBe(true); + expect(hasInvalidHeaderChars('bell')).toBe(true); + }); + + it('flags non-ASCII', () => { + expect(hasInvalidHeaderChars('世界')).toBe(true); + }); + }); + + describe('headerMatchesBodyValue', () => { + it('matches strings exactly', () => { + expect(headerMatchesBodyValue('us-west1', 'us-west1')).toBe(true); + expect(headerMatchesBodyValue('us-west1', 'eu-central1')).toBe(false); + }); + + it('compares integers numerically, not as strings', () => { + expect(headerMatchesBodyValue('42', 42)).toBe(true); + expect(headerMatchesBodyValue('42.0', 42)).toBe(true); + expect(headerMatchesBodyValue('43', 42)).toBe(false); + }); + + it('rejects a non-numeric header against a numeric body value', () => { + expect(headerMatchesBodyValue('not-a-number', 42)).toBe(false); + }); + + it('uses lowercase spelling for booleans', () => { + expect(headerMatchesBodyValue('true', true)).toBe(true); + expect(headerMatchesBodyValue('True', true)).toBe(false); + expect(headerMatchesBodyValue('false', false)).toBe(true); + }); + }); +}); diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts new file mode 100644 index 000000000..91034bf88 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts @@ -0,0 +1,160 @@ +import { InputRequiredSignal, MissingClientCapabilityError } from '../../../errors'; +import { buildInputRequiredResult, decodeRequestState, encodeRequestState, MrtrExchange } from '../mrtr'; + +const ELICITATION_CAPABLE = { elicitation: { form: {} } }; + +const PENDING = { + message: 'Proceed?', + requestedSchema: { type: 'object', properties: { confirmed: { type: 'boolean' } } }, +}; + +describe('requestState codec', () => { + it('round-trips recorded responses', () => { + const responses = { 'elicit-1': { action: 'accept', content: { confirmed: true } } }; + expect(decodeRequestState(encodeRequestState(responses))).toEqual(responses); + }); + + it('treats a malformed blob as no prior answers', () => { + // The value is opaque to the client, so a bad one means tampering or + // truncation — restarting the exchange beats failing the call. + expect(decodeRequestState('not-base64url!!')).toEqual({}); + expect(decodeRequestState(undefined)).toEqual({}); + expect(decodeRequestState('')).toEqual({}); + expect(decodeRequestState(Buffer.from('[]', 'utf8').toString('base64url'))).toEqual({}); + }); +}); + +describe('MrtrExchange', () => { + it('raises InputRequiredSignal on the first unanswered elicitation', () => { + const exchange = new MrtrExchange({ clientCapabilities: ELICITATION_CAPABLE }); + + let signal: InputRequiredSignal | undefined; + try { + exchange.resolveElicitation(PENDING); + } catch (error) { + signal = error as InputRequiredSignal; + } + + expect(signal).toBeInstanceOf(InputRequiredSignal); + expect(signal?.inputRequests['elicit-1']).toMatchObject({ + method: 'elicitation/create', + params: { message: 'Proceed?' }, + }); + expect(typeof signal?.requestState).toBe('string'); + }); + + it('returns a recorded answer instead of asking again', () => { + const exchange = new MrtrExchange({ + clientCapabilities: ELICITATION_CAPABLE, + inputResponses: { 'elicit-1': { action: 'accept', content: { confirmed: true } } }, + }); + + expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { confirmed: true } }); + }); + + it('accepts the `status` spelling as well as `action`', () => { + const exchange = new MrtrExchange({ + clientCapabilities: ELICITATION_CAPABLE, + inputResponses: { 'elicit-1': { status: 'decline' } }, + }); + + expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'decline' }); + }); + + it('defaults an answer with no action to cancel', () => { + const exchange = new MrtrExchange({ + clientCapabilities: ELICITATION_CAPABLE, + inputResponses: { 'elicit-1': {} }, + }); + + expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'cancel' }); + }); + + it('derives keys from call order so a replayed tool lines up', () => { + const exchange = new MrtrExchange({ + clientCapabilities: ELICITATION_CAPABLE, + inputResponses: { + 'elicit-1': { action: 'accept', content: { step: 1 } }, + 'elicit-2': { action: 'accept', content: { step: 2 } }, + }, + }); + + expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { step: 1 } }); + expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { step: 2 } }); + }); + + it('asks for the next step once earlier answers are exhausted', () => { + const exchange = new MrtrExchange({ + clientCapabilities: ELICITATION_CAPABLE, + inputResponses: { 'elicit-1': { action: 'accept', content: { step: 1 } } }, + }); + + exchange.resolveElicitation(PENDING); + expect(() => exchange.resolveElicitation(PENDING)).toThrow(InputRequiredSignal); + }); + + it('carries earlier answers forward through requestState', () => { + const first = { 'elicit-1': { action: 'accept', content: { step: 1 } } }; + const exchange = new MrtrExchange({ + clientCapabilities: ELICITATION_CAPABLE, + carriedResponses: first, + }); + + expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { step: 1 } }); + + // The follow-up ask must re-encode what we already know, or a multi-step + // tool would never converge. + try { + exchange.resolveElicitation(PENDING); + throw new Error('expected InputRequiredSignal'); + } catch (error) { + expect(decodeRequestState((error as InputRequiredSignal).requestState)).toEqual(first); + } + }); + + it('lets a fresh inputResponse win over a carried one', () => { + const exchange = new MrtrExchange({ + clientCapabilities: ELICITATION_CAPABLE, + carriedResponses: { 'elicit-1': { action: 'decline' } }, + inputResponses: { 'elicit-1': { action: 'accept', content: { confirmed: true } } }, + }); + + expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { confirmed: true } }); + }); + + it('demands the elicitation capability before asking', () => { + const exchange = new MrtrExchange({ clientCapabilities: {} }); + + let error: unknown; + try { + exchange.resolveElicitation(PENDING); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(MissingClientCapabilityError); + expect((error as MissingClientCapabilityError).requiredCapabilities).toEqual({ elicitation: { form: {} } }); + }); + + it('still resolves a recorded answer without the capability declared', () => { + // The client already answered — refusing now would strand a valid retry. + const exchange = new MrtrExchange({ + clientCapabilities: {}, + inputResponses: { 'elicit-1': { action: 'accept', content: { confirmed: true } } }, + }); + + expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { confirmed: true } }); + }); +}); + +describe('buildInputRequiredResult', () => { + it('produces the interim result envelope', () => { + const signal = new InputRequiredSignal({ 'elicit-1': { method: 'elicitation/create', params: {} } }, 'state-blob'); + + expect(buildInputRequiredResult(signal)).toEqual({ + resultType: 'input_required', + inputRequests: { 'elicit-1': { method: 'elicitation/create', params: {} } }, + requestState: 'state-blob', + }); + }); +}); diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/request-validation.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/request-validation.spec.ts new file mode 100644 index 000000000..65ba092d1 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/__tests__/request-validation.spec.ts @@ -0,0 +1,301 @@ +import { MCP_2026_ERROR_CODES, MCP_2026_META, PROTOCOL_2026_07_28 } from '@frontmcp/protocol'; + +import { encodeHeaderValue } from '../header-codec'; +import { collectHeaderParams, isProtocol2026Request, readHeader, validate2026Request } from '../request-validation'; + +const META = { + [MCP_2026_META.protocolVersion]: PROTOCOL_2026_07_28, + [MCP_2026_META.clientCapabilities]: {}, +}; + +function request(method: string, params: Record = {}, id: unknown = 1) { + return { jsonrpc: '2.0', id, method, params: { ...params, _meta: META } } as Record; +} + +function headers(extra: Record = {}) { + return { + 'mcp-protocol-version': PROTOCOL_2026_07_28, + ...extra, + } as Record; +} + +describe('readHeader', () => { + it('reads case-insensitively', () => { + expect(readHeader({ 'MCP-Method': 'tools/list' }, 'mcp-method')).toBe('tools/list'); + }); + + it('takes the first entry of an array-valued header', () => { + expect(readHeader({ 'mcp-method': ['tools/list', 'other'] }, 'mcp-method')).toBe('tools/list'); + }); + + it('returns undefined when absent', () => { + expect(readHeader({}, 'mcp-method')).toBeUndefined(); + expect(readHeader(undefined, 'mcp-method')).toBeUndefined(); + }); +}); + +describe('isProtocol2026Request', () => { + it('claims a request declaring the version in _meta', () => { + expect(isProtocol2026Request({ headers: {}, body: request('tools/list') })).toBe(true); + }); + + it('claims a request whose header names an unknown version', () => { + // Otherwise a future version would fall into the session pipeline and get a + // confusing "send initialize first" instead of a proper -32022. + expect( + isProtocol2026Request({ headers: { 'mcp-protocol-version': '2099-01-01' }, body: { method: 'tools/list' } }), + ).toBe(true); + }); + + it('claims 2026-only methods', () => { + expect(isProtocol2026Request({ headers: {}, body: { method: 'server/discover' } })).toBe(true); + expect(isProtocol2026Request({ headers: {}, body: { method: 'subscriptions/listen' } })).toBe(true); + }); + + it('does NOT claim legacy revisions', () => { + for (const version of ['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']) { + expect( + isProtocol2026Request({ + headers: { 'mcp-protocol-version': version }, + body: { method: 'tools/list', params: {} }, + }), + ).toBe(false); + } + }); + + it('does NOT claim a bare legacy initialize', () => { + expect(isProtocol2026Request({ headers: {}, body: { method: 'initialize', params: {} } })).toBe(false); + }); +}); + +describe('validate2026Request', () => { + it('accepts a fully conforming request', () => { + const result = validate2026Request({ + headers: headers({ 'mcp-method': 'tools/list' }), + body: request('tools/list'), + }); + expect(result).toEqual({ ok: true, version: PROTOCOL_2026_07_28 }); + }); + + it('rejects a missing protocol version header', () => { + const result = validate2026Request({ headers: { 'mcp-method': 'tools/list' }, body: request('tools/list') }); + expect(result).toMatchObject({ ok: false, status: 400, error: { code: MCP_2026_ERROR_CODES.headerMismatch } }); + }); + + it('rejects a body missing the _meta protocol version', () => { + const result = validate2026Request({ + headers: headers({ 'mcp-method': 'tools/list' }), + body: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: {} } }, + }); + expect(result).toMatchObject({ ok: false, error: { code: MCP_2026_ERROR_CODES.headerMismatch } }); + expect((result as { error: { message: string } }).error.message).toContain(MCP_2026_META.protocolVersion); + }); + + it('rejects a header/body version disagreement', () => { + const result = validate2026Request({ + headers: { 'mcp-protocol-version': '2025-06-18', 'mcp-method': 'tools/list' }, + body: request('tools/list'), + }); + expect(result).toMatchObject({ ok: false, error: { code: MCP_2026_ERROR_CODES.headerMismatch } }); + }); + + it('reports an unsupported version with the supported list', () => { + const body = { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: { [MCP_2026_META.protocolVersion]: '2099-01-01' } }, + }; + const result = validate2026Request({ + headers: { 'mcp-protocol-version': '2099-01-01', 'mcp-method': 'tools/list' }, + body, + }); + + expect(result).toMatchObject({ + ok: false, + status: 400, + error: { + code: MCP_2026_ERROR_CODES.unsupportedProtocolVersion, + data: { requested: '2099-01-01' }, + }, + }); + expect((result as { error: { data: { supported: string[] } } }).error.data.supported).toContain( + PROTOCOL_2026_07_28, + ); + }); + + it('rejects a missing Mcp-Method header', () => { + const result = validate2026Request({ headers: headers(), body: request('tools/list') }); + expect(result).toMatchObject({ ok: false, error: { code: MCP_2026_ERROR_CODES.headerMismatch } }); + }); + + it('rejects an Mcp-Method that disagrees with the body', () => { + const result = validate2026Request({ + headers: headers({ 'mcp-method': 'resources/list' }), + body: request('tools/list'), + }); + expect(result).toMatchObject({ ok: false, error: { code: MCP_2026_ERROR_CODES.headerMismatch } }); + }); + + it('validates Mcp-Name against params.name for tools/call', () => { + const body = request('tools/call', { name: 'echo', arguments: {} }); + + expect( + validate2026Request({ headers: headers({ 'mcp-method': 'tools/call', 'mcp-name': 'echo' }), body }), + ).toMatchObject({ ok: true }); + + expect( + validate2026Request({ headers: headers({ 'mcp-method': 'tools/call', 'mcp-name': 'other' }), body }), + ).toMatchObject({ ok: false, error: { code: MCP_2026_ERROR_CODES.headerMismatch } }); + + expect(validate2026Request({ headers: headers({ 'mcp-method': 'tools/call' }), body })).toMatchObject({ + ok: false, + error: { code: MCP_2026_ERROR_CODES.headerMismatch }, + }); + }); + + it('validates Mcp-Name against params.uri for resources/read', () => { + const body = request('resources/read', { uri: 'proto://config' }); + expect( + validate2026Request({ + headers: headers({ 'mcp-method': 'resources/read', 'mcp-name': 'proto://config' }), + body, + }), + ).toMatchObject({ ok: true }); + }); + + it('decodes a sentinel-encoded Mcp-Name before comparing', () => { + const body = request('tools/call', { name: 'Hello, 世界', arguments: {} }); + expect( + validate2026Request({ + headers: headers({ 'mcp-method': 'tools/call', 'mcp-name': encodeHeaderValue('Hello, 世界') }), + body, + }), + ).toMatchObject({ ok: true }); + }); + + it('skips body validation for notifications', () => { + // The revision leaves notification header requirements undefined, so only + // the version header is enforced. + const result = validate2026Request({ + headers: headers({ 'mcp-method': 'notifications/cancelled' }), + body: { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, + }); + expect(result).toMatchObject({ ok: true }); + }); + + it('rejects a body with no method', () => { + const result = validate2026Request({ headers: headers(), body: { jsonrpc: '2.0', id: 1 } }); + expect(result).toMatchObject({ ok: false, error: { code: -32600 } }); + }); + + describe('x-mcp-header parameters', () => { + const schema = { + type: 'object', + properties: { + region: { type: 'string', 'x-mcp-header': 'Region' }, + query: { type: 'string' }, + }, + }; + const lookupToolSchema = () => schema; + + const call = (args: Record) => request('tools/call', { name: 'q', arguments: args }); + const base = { 'mcp-method': 'tools/call', 'mcp-name': 'q' }; + + it('accepts a matching header', () => { + expect( + validate2026Request({ + headers: headers({ ...base, 'mcp-param-region': 'us-west1' }), + body: call({ region: 'us-west1', query: 'x' }), + lookupToolSchema, + }), + ).toMatchObject({ ok: true }); + }); + + it('rejects a mismatched header', () => { + expect( + validate2026Request({ + headers: headers({ ...base, 'mcp-param-region': 'eu-central1' }), + body: call({ region: 'us-west1', query: 'x' }), + lookupToolSchema, + }), + ).toMatchObject({ ok: false, error: { code: MCP_2026_ERROR_CODES.headerMismatch } }); + }); + + it('rejects a client that omits the header for a present argument', () => { + expect( + validate2026Request({ + headers: headers(base), + body: call({ region: 'us-west1', query: 'x' }), + lookupToolSchema, + }), + ).toMatchObject({ ok: false, error: { code: MCP_2026_ERROR_CODES.headerMismatch } }); + }); + + it('expects no header when the argument is absent', () => { + expect( + validate2026Request({ headers: headers(base), body: call({ query: 'x' }), lookupToolSchema }), + ).toMatchObject({ ok: true }); + }); + + it('rejects a header sent for an absent argument', () => { + expect( + validate2026Request({ + headers: headers({ ...base, 'mcp-param-region': 'us-west1' }), + body: call({ query: 'x' }), + lookupToolSchema, + }), + ).toMatchObject({ ok: false, error: { code: MCP_2026_ERROR_CODES.headerMismatch } }); + }); + + it('skips validation when the tool is unknown', () => { + expect( + validate2026Request({ + headers: headers(base), + body: call({ region: 'us-west1' }), + lookupToolSchema: () => null, + }), + ).toMatchObject({ ok: true }); + }); + }); +}); + +describe('collectHeaderParams', () => { + it('collects annotations on top-level properties', () => { + const found = collectHeaderParams({ + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' } }, + }); + expect(found.get('region')).toEqual(['region']); + }); + + it('collects annotations on nested properties reachable through `properties` only', () => { + const found = collectHeaderParams({ + type: 'object', + properties: { + target: { + type: 'object', + properties: { zone: { type: 'string', 'x-mcp-header': 'Zone' } }, + }, + }, + }); + expect(found.get('zone')).toEqual(['target', 'zone']); + }); + + it('ignores annotations behind array items', () => { + // `items` is not a statically reachable chain, so the annotation is invalid + // and must not produce a header expectation. + const found = collectHeaderParams({ + type: 'object', + properties: { + list: { type: 'array', items: { type: 'object', properties: { x: { 'x-mcp-header': 'X' } } } }, + }, + }); + expect(found.size).toBe(0); + }); + + it('returns empty for a schema with no properties', () => { + expect(collectHeaderParams({ type: 'string' }).size).toBe(0); + expect(collectHeaderParams(null).size).toBe(0); + }); +}); diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/result-decorator.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/result-decorator.spec.ts new file mode 100644 index 000000000..a9d882d67 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/__tests__/result-decorator.spec.ts @@ -0,0 +1,66 @@ +import { MCP_2026_META } from '@frontmcp/protocol'; + +import { DEFAULT_CACHE_TTL_MS } from '../protocol-2026.constants'; +import { decorateResult, resolveCacheScope } from '../result-decorator'; + +const serverInfo = { name: 'test-server', version: '1.0.0' }; + +describe('decorateResult', () => { + it('marks an ordinary result complete', () => { + const out = decorateResult({ tools: [] }, { method: 'tools/list', serverInfo }); + expect(out['resultType']).toBe('complete'); + }); + + it('preserves an interim resultType', () => { + // The MRTR path sets `input_required` itself; overwriting it would collapse + // the round trip into a bogus final result. + const out = decorateResult({ resultType: 'input_required' }, { method: 'tools/call', serverInfo }); + expect(out['resultType']).toBe('input_required'); + }); + + it('attaches serverInfo to _meta', () => { + const out = decorateResult({}, { method: 'tools/list', serverInfo }); + expect((out['_meta'] as Record)[MCP_2026_META.serverInfo]).toEqual(serverInfo); + }); + + it('preserves existing _meta entries', () => { + const out = decorateResult({ _meta: { custom: 'value' } }, { method: 'tools/list', serverInfo }); + expect(out['_meta']).toMatchObject({ custom: 'value' }); + }); + + it('adds ttlMs and cacheScope to cacheable methods', () => { + for (const method of Object.keys(DEFAULT_CACHE_TTL_MS)) { + const out = decorateResult({}, { method, serverInfo, cacheScope: 'public' }); + expect(out['ttlMs']).toBe(DEFAULT_CACHE_TTL_MS[method]); + expect(out['cacheScope']).toBe('public'); + } + }); + + it('leaves non-cacheable methods alone', () => { + const out = decorateResult({ content: [] }, { method: 'tools/call', serverInfo }); + expect(out['ttlMs']).toBeUndefined(); + expect(out['cacheScope']).toBeUndefined(); + }); + + it('honours an explicit ttl override', () => { + const out = decorateResult({}, { method: 'tools/list', serverInfo, ttlMs: 1234 }); + expect(out['ttlMs']).toBe(1234); + }); + + it('defaults cacheScope to private', () => { + // Defaulting to `public` would let a shared proxy serve one tenant's list + // to another, so the safe value is the default. + const out = decorateResult({}, { method: 'tools/list', serverInfo }); + expect(out['cacheScope']).toBe('private'); + }); +}); + +describe('resolveCacheScope', () => { + it('marks anonymous traffic public', () => { + expect(resolveCacheScope(true)).toBe('public'); + }); + + it('marks authenticated traffic private', () => { + expect(resolveCacheScope(false)).toBe('private'); + }); +}); diff --git a/libs/sdk/src/transport/mcp-2026/discover.ts b/libs/sdk/src/transport/mcp-2026/discover.ts new file mode 100644 index 000000000..d507ea063 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/discover.ts @@ -0,0 +1,59 @@ +/** + * `server/discover` — protocol 2026-07-28, SEP-2575. + * + * Servers MUST implement this RPC. It advertises the versions the server + * speaks, its capabilities, and its instructions — the payload `initialize` + * used to return, minus everything that only made sense for a session. + * + * Clients MAY skip it entirely and negotiate inline via per-request `_meta`, + * which is why it must be cheap and side-effect free. + */ +import { type DiscoverResult, type ServerCapabilities2026 } from '@frontmcp/protocol'; + +import { type Scope } from '../../scope'; +import { buildScopedServerOptions } from '../build-scoped-server-options'; +import { ADVERTISED_EXTENSIONS, SUPPORTED_PROTOCOL_VERSIONS_2026 } from './protocol-2026.constants'; + +/** + * Project the scope's capability set into the 2026-07-28 shape. + * + * The only structural change is `extensions`: this revision promotes optional + * MCP extensions out of `experimental` into a first-class field, so anything + * the scope already advertises is merged with the server-level extension list. + */ +export function buildDiscoverCapabilities(scope: Scope): ServerCapabilities2026 { + const { capabilities } = buildScopedServerOptions(scope); + const source = capabilities as Record; + + const extensions: Record> = { + ...ADVERTISED_EXTENSIONS, + ...((source['extensions'] as Record> | undefined) ?? {}), + }; + + const result: ServerCapabilities2026 = { extensions }; + + if (source['experimental']) result.experimental = source['experimental'] as Record>; + if (source['logging']) result.logging = source['logging'] as Record; + if (source['completions']) result.completions = source['completions'] as Record; + if (source['prompts']) result.prompts = source['prompts'] as { listChanged?: boolean }; + if (source['resources']) result.resources = source['resources'] as { subscribe?: boolean; listChanged?: boolean }; + if (source['tools']) result.tools = source['tools'] as { listChanged?: boolean }; + + return result; +} + +/** + * Build the `server/discover` result body. + * + * `resultType`, `_meta.serverInfo`, `ttlMs` and `cacheScope` are added by the + * shared result decorator, so this returns only the method-specific fields. + */ +export function buildDiscoverResult(scope: Scope, instructions?: string): Omit { + const capabilities = buildDiscoverCapabilities(scope); + + return { + supportedVersions: [...SUPPORTED_PROTOCOL_VERSIONS_2026], + capabilities, + ...(instructions ? { instructions } : {}), + } as Omit; +} diff --git a/libs/sdk/src/transport/mcp-2026/dispatcher.ts b/libs/sdk/src/transport/mcp-2026/dispatcher.ts new file mode 100644 index 000000000..02009018b --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/dispatcher.ts @@ -0,0 +1,165 @@ +/** + * JSON-RPC dispatch for protocol 2026-07-28. + * + * Reuses the SAME `createMcpHandlers` set every other transport uses, so tools, + * resources, prompts and completions behave identically across revisions — + * only the envelope differs. What this layer adds is the 2026-specific + * behaviour: method admission (removed methods now 404), the MRTR exchange, + * and result decoration. + */ +import { MCP_2026_ERROR_CODES, MCP_2026_REMOVED_METHODS, McpError, type Implementation } from '@frontmcp/protocol'; + +import { type FrontMcpContext } from '../../context'; +import { InputRequiredSignal, MissingClientCapabilityError } from '../../errors'; +import { type Scope } from '../../scope'; +import { buildScopedServerOptions } from '../build-scoped-server-options'; +import { createMcpHandlers } from '../mcp-handlers'; +import { buildDiscoverResult } from './discover'; +import { buildInputRequiredResult, decodeRequestState, MrtrExchange } from './mrtr'; +import { type JsonRpcErrorPayload } from './request-validation'; +import { decorateResult, resolveCacheScope } from './result-decorator'; + +export interface DispatchOptions { + scope: Scope; + /** The validated JSON-RPC request body. */ + body: Record; + /** Capabilities the client declared in this request's `_meta`. */ + clientCapabilities: Record; + /** Ambient request context, used to carry the MRTR exchange to `elicit()`. */ + frontmcpContext?: FrontMcpContext; + /** Auth info forwarded to the shared handlers. */ + authInfo?: Record; + /** True when the caller is unauthenticated, which makes results publicly cacheable. */ + isAnonymous: boolean; + signal?: AbortSignal; + /** Lazily composed instructions for `server/discover`. */ + composeInstructions?: () => string | undefined; +} + +export type DispatchResult = + | { kind: 'result'; result: Record } + | { kind: 'error'; status: number; error: JsonRpcErrorPayload }; + +/** Read the JSON-RPC method literal a handler's request schema is bound to. */ +function methodOfSchema(schema: unknown): string | undefined { + const shape = (schema as { shape?: Record } | undefined)?.shape; + const method = shape?.['method'] as { value?: unknown; _def?: { values?: unknown[] } } | undefined; + if (typeof method?.value === 'string') return method.value; + const values = method?._def?.values; + return Array.isArray(values) && typeof values[0] === 'string' ? values[0] : undefined; +} + +/** + * Map a thrown error onto a JSON-RPC error payload. + * + * The one substantive change from earlier revisions is resource-not-found: + * `-32002` was retired in favour of `-32602` to align with JSON-RPC. Because + * the shared handlers still raise the old code (they serve legacy clients too), + * the translation happens here rather than at the source. + */ +export function toJsonRpcError(error: unknown): { status: number; error: JsonRpcErrorPayload } { + if (error instanceof MissingClientCapabilityError) { + return { + status: 400, + error: { + code: MCP_2026_ERROR_CODES.missingRequiredClientCapability, + message: error.getPublicMessage(), + data: { requiredCapabilities: error.requiredCapabilities }, + }, + }; + } + + const withJsonRpc = error as { toJsonRpcError?: () => JsonRpcErrorPayload } | undefined; + let payload: JsonRpcErrorPayload; + + if (typeof withJsonRpc?.toJsonRpcError === 'function') { + payload = withJsonRpc.toJsonRpcError(); + } else if (error instanceof McpError) { + payload = { code: error.code, message: error.message, data: (error as { data?: unknown }).data }; + } else { + payload = { code: -32603, message: error instanceof Error ? error.message : String(error) }; + } + + // Retired in 2026-07-28: resource-not-found is now Invalid Params. + if (payload.code === -32002) payload = { ...payload, code: -32602 }; + + return { status: 200, error: payload }; +} + +/** + * Dispatch one 2026-07-28 JSON-RPC request. + * + * `subscriptions/listen` is NOT handled here — it needs to own the HTTP + * response as a stream, so the flow handles it before calling in. + */ +export async function dispatch2026(options: DispatchOptions): Promise { + const { scope, body, clientCapabilities, frontmcpContext, authInfo, isAnonymous, signal, composeInstructions } = + options; + + const method = body['method'] as string; + const params = (body['params'] as Record | undefined) ?? {}; + const cacheScope = resolveCacheScope(isAnonymous); + const serverInfo = scope.metadata.info as Implementation; + + if ((MCP_2026_REMOVED_METHODS as readonly string[]).includes(method)) { + return { + kind: 'error', + status: 404, + error: { code: -32601, message: `Method not found: ${method} was removed in protocol 2026-07-28` }, + }; + } + + if (method === 'server/discover') { + return { + kind: 'result', + result: decorateResult(buildDiscoverResult(scope, composeInstructions?.()) as Record, { + method, + serverInfo, + cacheScope, + }), + }; + } + + const serverOptions = buildScopedServerOptions(scope, composeInstructions?.() ?? ''); + const handlers = createMcpHandlers({ scope, serverOptions, composeInstructions }); + + const handler = handlers.find((entry) => methodOfSchema(entry.requestSchema) === method); + if (!handler) { + return { kind: 'error', status: 404, error: { code: -32601, message: `Method not found: ${method}` } }; + } + + // Every request carries its own MRTR exchange: capabilities are per-request in + // this revision, so an exchange must never outlive the request that made it. + const exchange = new MrtrExchange({ + inputResponses: params['inputResponses'] as Record> | undefined, + carriedResponses: decodeRequestState(params['requestState']), + clientCapabilities, + }); + frontmcpContext?.setMrtrExchange(exchange); + + const ctx = { + signal: signal ?? new AbortController().signal, + requestId: body['id'] as string | number, + authInfo, + sendNotification: async () => undefined, + sendRequest: async () => { + // 2026-07-28 removed the server→client request direction outright. A + // handler reaching for it is a bug, not a transport limitation. + throw new McpError(-32603, 'Server-initiated requests were removed in protocol 2026-07-28; use MRTR'); + }, + }; + + try { + const raw = (await handler.handler(body as never, ctx as never)) as Record; + return { kind: 'result', result: decorateResult(raw, { method, serverInfo, cacheScope }) }; + } catch (error) { + if (error instanceof InputRequiredSignal) { + return { + kind: 'result', + result: decorateResult(buildInputRequiredResult(error), { method, serverInfo, cacheScope }), + }; + } + const mapped = toJsonRpcError(error); + return { kind: 'error', status: mapped.status, error: mapped.error }; + } +} diff --git a/libs/sdk/src/transport/mcp-2026/header-codec.ts b/libs/sdk/src/transport/mcp-2026/header-codec.ts new file mode 100644 index 000000000..8be923317 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/header-codec.ts @@ -0,0 +1,75 @@ +/** + * Value encoding for the mirrored HTTP headers of protocol 2026-07-28. + * + * HTTP field values are limited to visible ASCII, so a tool name, resource URI, + * or parameter value that falls outside that set travels Base64-wrapped in the + * `=?base64?…?=` sentinel. Servers MUST decode before comparing to the body — + * a naive string compare would reject every conforming client that had to + * encode, and would accept a crafted literal that merely LOOKS encoded. + */ + +const SENTINEL_PREFIX = '=?base64?'; +const SENTINEL_SUFFIX = '?='; + +/** True when `value` is wrapped in the (case-sensitive, lowercase) sentinel. */ +export function isSentinelEncoded(value: string): boolean { + return ( + value.startsWith(SENTINEL_PREFIX) && + value.endsWith(SENTINEL_SUFFIX) && + value.length >= SENTINEL_PREFIX.length + SENTINEL_SUFFIX.length + ); +} + +/** + * Decode a mirrored header value. + * + * Returns `undefined` when the sentinel is present but the payload is not valid + * Base64/UTF-8 — the caller turns that into a `HeaderMismatch` rather than + * silently comparing garbage. + */ +export function decodeHeaderValue(value: string): string | undefined { + if (!isSentinelEncoded(value)) return value; + + const payload = value.slice(SENTINEL_PREFIX.length, value.length - SENTINEL_SUFFIX.length); + try { + const buf = Buffer.from(payload, 'base64'); + // `Buffer.from` is lenient — round-trip to confirm the payload really was + // Base64 rather than arbitrary text that happened to parse. + if (buf.toString('base64').replace(/=+$/, '') !== payload.replace(/=+$/, '')) return undefined; + return buf.toString('utf8'); + } catch { + return undefined; + } +} + +/** Encode a value for a mirrored header, wrapping it only when necessary. */ +export function encodeHeaderValue(value: string): string { + const needsEncoding = /[^\x20-\x7e]/.test(value) || value !== value.trim() || isSentinelEncoded(value); + if (!needsEncoding) return value; + return `${SENTINEL_PREFIX}${Buffer.from(value, 'utf8').toString('base64')}${SENTINEL_SUFFIX}`; +} + +/** True when a raw header value contains octets HTTP does not permit. */ +export function hasInvalidHeaderChars(value: string): boolean { + // RFC 9110: field values are visible ASCII (0x21-0x7E), SP (0x20) and HTAB (0x09). + // eslint-disable-next-line no-control-regex + return /[^\x09\x20-\x7e]/.test(value); +} + +/** + * Compare a decoded header value against the value found in the request body. + * + * Numbers are compared numerically per the spec note (`42` and `42.0` are + * equal); booleans use their lowercase spelling; everything else is an exact + * string match. + */ +export function headerMatchesBodyValue(headerValue: string, bodyValue: unknown): boolean { + if (typeof bodyValue === 'number') { + const parsed = Number(headerValue); + return Number.isFinite(parsed) && parsed === bodyValue; + } + if (typeof bodyValue === 'boolean') { + return headerValue === String(bodyValue); + } + return headerValue === String(bodyValue); +} diff --git a/libs/sdk/src/transport/mcp-2026/index.ts b/libs/sdk/src/transport/mcp-2026/index.ts new file mode 100644 index 000000000..7187004c1 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/index.ts @@ -0,0 +1,17 @@ +/** + * MCP protocol revision 2026-07-28 support. + * + * Everything in here is additive: a request is only routed through this module + * when it explicitly declares the 2026-07-28 revision (or uses a method that + * exists only in it). Every earlier revision keeps its original code path. + * + * @module transport/mcp-2026 + */ +export * from './protocol-2026.constants'; +export * from './header-codec'; +export * from './request-validation'; +export * from './result-decorator'; +export * from './discover'; +export * from './mrtr'; +export * from './subscriptions'; +export * from './dispatcher'; diff --git a/libs/sdk/src/transport/mcp-2026/mrtr.ts b/libs/sdk/src/transport/mcp-2026/mrtr.ts new file mode 100644 index 000000000..4f4329d6a --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/mrtr.ts @@ -0,0 +1,165 @@ +/** + * Multi Round-Trip Requests (MRTR) — protocol 2026-07-28, SEP-2322. + * + * ## How a round trip works + * + * 1. The tool calls `this.elicit(...)`. No response is recorded for that call + * yet, so the exchange records the pending request and throws + * {@link InputRequiredSignal}. + * 2. The dispatcher turns the signal into an `InputRequiredResult` + * (`resultType: "input_required"`) carrying `inputRequests` and an opaque + * `requestState`. + * 3. The client gathers the input and re-issues the SAME request with + * `inputResponses` + `requestState`. + * 4. The tool runs again from the top. This time `elicit()` finds a recorded + * answer for its call and returns it inline, so execution proceeds. + * + * Tools are therefore replayed, not resumed — which is why the keys are derived + * from the call ORDER (`elicit-1`, `elicit-2`, …) rather than randomly: the + * second run must line its calls up with the first run's answers. + * + * `requestState` accumulates every answer gathered so far, so a multi-step tool + * converges even if the client only echoes the most recent `inputResponses`. + */ +import type { InputRequests, InputResponses } from '@frontmcp/protocol'; + +import { type ElicitStatus } from '../../elicitation'; +import { InputRequiredSignal, MissingClientCapabilityError } from '../../errors'; + +/** Shape recorded for a pending elicitation before it becomes an input request. */ +export interface PendingElicitation { + message: string; + requestedSchema: Record; + mode?: 'form' | 'url'; + url?: string; +} + +/** + * Translate the wire-format elicitation answer to the SDK's internal shape. + * + * The MCP schema names the field `action`; FrontMCP's `ElicitResult` names it + * `status`. Both spellings are accepted so a client that mirrors either one is + * understood. + */ +function toElicitResult(response: Record): { status: ElicitStatus; content?: unknown } { + const action = (response['action'] ?? response['status']) as ElicitStatus | undefined; + return { + status: action ?? 'cancel', + ...(response['content'] === undefined ? {} : { content: response['content'] }), + }; +} + +interface DecodedRequestState { + responses: InputResponses; +} + +/** Encode accumulated answers into the opaque blob the client echoes back. */ +export function encodeRequestState(responses: InputResponses): string { + return Buffer.from(JSON.stringify({ responses } satisfies DecodedRequestState), 'utf8').toString('base64url'); +} + +/** + * Decode a client-echoed `requestState`. + * + * A malformed blob is treated as "no prior answers" rather than an error: the + * value is opaque to the client, so the only way it can be wrong is if it was + * tampered with or truncated, and restarting the exchange is safer than failing + * the call. + */ +export function decodeRequestState(state: unknown): InputResponses { + if (typeof state !== 'string' || state.length === 0) return {}; + try { + const parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')) as DecodedRequestState; + return parsed && typeof parsed.responses === 'object' && parsed.responses !== null ? parsed.responses : {}; + } catch { + return {}; + } +} + +/** + * Per-request bookkeeping for one MRTR exchange. + * + * Lives on the `FrontMcpContext` for the duration of a single dispatch, so + * `elicit()` deep inside a tool can reach it without threading it through + * every flow stage. + */ +export class MrtrExchange { + /** Answers already supplied by the client, keyed by input-request key. */ + private readonly responses: InputResponses; + + /** Requests raised during THIS run that the client still has to answer. */ + private readonly pending: InputRequests = {}; + + /** Number of `elicit()` calls seen so far, used to derive stable keys. */ + private elicitCount = 0; + + constructor(params: { + /** `inputResponses` from the request params. */ + inputResponses?: InputResponses; + /** Answers carried over from earlier rounds via `requestState`. */ + carriedResponses?: InputResponses; + /** Capabilities the client declared for this request. */ + clientCapabilities: Record; + }) { + // Fresh `inputResponses` win over carried ones for the same key: the client + // is answering the question we just asked. + this.responses = { ...(params.carriedResponses ?? {}), ...(params.inputResponses ?? {}) }; + this.clientCapabilities = params.clientCapabilities; + } + + readonly clientCapabilities: Record; + + /** True when the client declared support for elicitation in this request. */ + supportsElicitation(): boolean { + return ( + typeof this.clientCapabilities['elicitation'] === 'object' && this.clientCapabilities['elicitation'] !== null + ); + } + + /** + * Resolve the next `elicit()` call. + * + * Returns the recorded answer when the client already supplied one, otherwise + * records the request and throws so the dispatcher can ask for it. + */ + resolveElicitation(pending: PendingElicitation): { status: ElicitStatus; content?: unknown } { + this.elicitCount += 1; + const key = `elicit-${this.elicitCount}`; + + const recorded = this.responses[key]; + if (recorded) return toElicitResult(recorded); + + if (!this.supportsElicitation()) { + throw new MissingClientCapabilityError( + { elicitation: { form: {} } }, + 'This request requires the `elicitation` client capability', + ); + } + + this.pending[key] = { + method: 'elicitation/create', + params: { + message: pending.message, + requestedSchema: pending.requestedSchema, + ...(pending.mode ? { mode: pending.mode } : {}), + ...(pending.url ? { url: pending.url } : {}), + }, + }; + + throw new InputRequiredSignal(this.pending, encodeRequestState(this.responses)); + } +} + +/** + * Build the `InputRequiredResult` body for a raised signal. + * + * `resultType` is set here rather than by the generic result decorator because + * an interim result is precisely the case the decorator must not overwrite. + */ +export function buildInputRequiredResult(signal: InputRequiredSignal): Record { + return { + resultType: 'input_required', + inputRequests: signal.inputRequests, + requestState: signal.requestState, + }; +} diff --git a/libs/sdk/src/transport/mcp-2026/protocol-2026.constants.ts b/libs/sdk/src/transport/mcp-2026/protocol-2026.constants.ts new file mode 100644 index 000000000..8751991de --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/protocol-2026.constants.ts @@ -0,0 +1,75 @@ +/** + * Constants for MCP protocol revision 2026-07-28. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/changelog + */ +import { PROTOCOL_2026_07_28 } from '@frontmcp/protocol'; + +/** + * Every revision this server speaks, newest first. + * + * Advertised verbatim by `server/discover` and echoed in the `supported` array + * of an `UnsupportedProtocolVersionError`. The older entries are load-bearing: + * dropping one would strand every client that negotiated it. + */ +export const SUPPORTED_PROTOCOL_VERSIONS_2026 = [ + PROTOCOL_2026_07_28, + '2025-11-25', + '2025-06-18', + '2025-03-26', + '2024-11-05', +] as const; + +/** + * Revisions handled by the pre-existing session/`initialize` pipeline. + * + * A request declaring one of these is NOT claimed by the 2026 path, which is + * what keeps the old behaviour bit-for-bit identical. + */ +export const LEGACY_PROTOCOL_VERSIONS = ['2024-10-07', '2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']; + +/** Methods that exist only in 2026-07-28. */ +export const PROTOCOL_2026_ONLY_METHODS = ['server/discover', 'subscriptions/listen']; + +/** HTTP header names mirrored from the JSON-RPC body (SEP-2243). */ +export const MCP_HEADERS = { + protocolVersion: 'mcp-protocol-version', + method: 'mcp-method', + name: 'mcp-name', + /** Prefix for `x-mcp-header`-derived parameter headers. */ + paramPrefix: 'mcp-param-', +} as const; + +/** Methods whose `Mcp-Name` header is sourced from `params.name`. */ +export const NAME_FROM_PARAMS_NAME = ['tools/call', 'prompts/get']; + +/** Methods whose `Mcp-Name` header is sourced from `params.uri`. */ +export const NAME_FROM_PARAMS_URI = ['resources/read']; + +/** + * Default `ttlMs` per cacheable method. + * + * Conservative on purpose: list endpoints change rarely and benefit most from + * caching, while `resources/read` is content that a server may regenerate, so + * it defaults to "revalidate every time" rather than risking a stale read. + */ +export const DEFAULT_CACHE_TTL_MS: Record = { + 'server/discover': 300_000, + 'tools/list': 60_000, + 'prompts/list': 60_000, + 'resources/list': 60_000, + 'resources/templates/list': 60_000, + 'resources/read': 0, +}; + +/** Methods whose results MUST carry `ttlMs` + `cacheScope` (`CacheableResult`). */ +export const CACHEABLE_METHODS = Object.keys(DEFAULT_CACHE_TTL_MS); + +/** + * MCP extensions this server advertises under `capabilities.extensions`. + * + * Keys follow the `_meta` naming rules (mandatory reverse-DNS prefix). + */ +export const ADVERTISED_EXTENSIONS: Record> = { + 'io.modelcontextprotocol/tasks': {}, +}; diff --git a/libs/sdk/src/transport/mcp-2026/request-validation.ts b/libs/sdk/src/transport/mcp-2026/request-validation.ts new file mode 100644 index 000000000..bfd5158d3 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/request-validation.ts @@ -0,0 +1,300 @@ +/** + * Request admission + header validation for protocol 2026-07-28. + * + * Two jobs, deliberately separated: + * + * 1. **Claim** — decide whether a request belongs to the 2026 pipeline at all. + * Anything not claimed falls through to the untouched session/`initialize` + * pipeline, which is what keeps older clients working byte-for-byte. + * 2. **Validate** — enforce the header/body agreement rules of SEP-2243 and the + * per-request version rules of SEP-2575. + */ +import { MCP_2026_ERROR_CODES, MCP_2026_META, PROTOCOL_2026_07_28 } from '@frontmcp/protocol'; + +import { decodeHeaderValue, hasInvalidHeaderChars, headerMatchesBodyValue } from './header-codec'; +import { + LEGACY_PROTOCOL_VERSIONS, + MCP_HEADERS, + NAME_FROM_PARAMS_NAME, + NAME_FROM_PARAMS_URI, + PROTOCOL_2026_ONLY_METHODS, + SUPPORTED_PROTOCOL_VERSIONS_2026, +} from './protocol-2026.constants'; + +export interface JsonRpcErrorPayload { + code: number; + message: string; + data?: unknown; +} + +export type ValidationFailure = { ok: false; status: number; error: JsonRpcErrorPayload }; +export type ValidationSuccess = { ok: true; version: string }; +export type ValidationResult = ValidationSuccess | ValidationFailure; + +/** Case-insensitive header read across the shapes Node/Express/Web produce. */ +export function readHeader(headers: Record | undefined, name: string): string | undefined { + if (!headers) return undefined; + const direct = headers[name] ?? headers[name.toLowerCase()]; + const value = direct ?? Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]; + if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined; + return typeof value === 'string' ? value : undefined; +} + +/** All `Mcp-Param-*` headers, keyed by the lowercased suffix after the prefix. */ +export function readParamHeaders(headers: Record | undefined): Map { + const out = new Map(); + for (const [key, value] of Object.entries(headers ?? {})) { + const lower = key.toLowerCase(); + if (!lower.startsWith(MCP_HEADERS.paramPrefix)) continue; + const raw = Array.isArray(value) ? value[0] : value; + if (typeof raw === 'string') out.set(lower.slice(MCP_HEADERS.paramPrefix.length), raw); + } + return out; +} + +function metaProtocolVersion(body: unknown): string | undefined { + const meta = (body as { params?: { _meta?: Record } } | undefined)?.params?._meta; + const value = meta?.[MCP_2026_META.protocolVersion]; + return typeof value === 'string' ? value : undefined; +} + +/** + * Decide whether this request belongs to the 2026-07-28 pipeline. + * + * Claimed when ANY of: + * - the body declares a version via the 2026-only `_meta` key (present only in + * this revision, so its presence is unambiguous); + * - the `MCP-Protocol-Version` header names something that is not a revision the + * legacy pipeline knows (so an unknown/future version gets a proper + * `-32022` instead of a confusing session error); + * - the method exists only in this revision. + */ +export function isProtocol2026Request(params: { + headers: Record | undefined; + body: unknown; +}): boolean { + const { headers, body } = params; + + if (metaProtocolVersion(body) !== undefined) return true; + + const headerVersion = readHeader(headers, MCP_HEADERS.protocolVersion); + if (headerVersion && !LEGACY_PROTOCOL_VERSIONS.includes(headerVersion)) return true; + + const method = (body as { method?: unknown } | undefined)?.method; + return typeof method === 'string' && PROTOCOL_2026_ONLY_METHODS.includes(method); +} + +function headerMismatch(message: string): ValidationFailure { + return { + ok: false, + status: 400, + error: { code: MCP_2026_ERROR_CODES.headerMismatch, message: `Header mismatch: ${message}` }, + }; +} + +/** + * Walk a JSON Schema and collect `x-mcp-header` annotations. + * + * Only *statically reachable* properties count — the chain must consist purely + * of `properties` keys. An annotation behind `items`, `$ref`, or a composition + * keyword is invalid per the spec and is ignored here rather than being + * enforced against the client. + */ +export function collectHeaderParams( + schema: unknown, + path: string[] = [], + out: Map = new Map(), +): Map { + if (!schema || typeof schema !== 'object') return out; + const properties = (schema as { properties?: Record }).properties; + if (!properties || typeof properties !== 'object') return out; + + for (const [key, value] of Object.entries(properties)) { + if (!value || typeof value !== 'object') continue; + const annotation = (value as Record)['x-mcp-header']; + const nextPath = [...path, key]; + if (typeof annotation === 'string' && annotation.length > 0) { + out.set(annotation.toLowerCase(), nextPath); + } + collectHeaderParams(value, nextPath, out); + } + return out; +} + +/** Read the value at a `properties`-only path within the call arguments. */ +function readAtPath(args: unknown, path: string[]): unknown { + let cursor: unknown = args; + for (const segment of path) { + if (!cursor || typeof cursor !== 'object') return undefined; + cursor = (cursor as Record)[segment]; + } + return cursor; +} + +export interface Validate2026Options { + headers: Record | undefined; + body: Record; + /** Resolves a tool's input JSON Schema so `x-mcp-header` can be validated. */ + lookupToolSchema?: (toolName: string) => Record | null | undefined; +} + +/** + * Validate a claimed 2026-07-28 request. + * + * Order matters and mirrors the spec's own precedence: transport-level header + * presence/agreement first, then version support, then the per-method mirrored + * values. That way a client sending a wrong version AND a wrong method header + * learns about the version first, which is the actionable one. + */ +export function validate2026Request(options: Validate2026Options): ValidationResult { + const { headers, body, lookupToolSchema } = options; + const method = typeof body['method'] === 'string' ? (body['method'] as string) : undefined; + + if (!method) { + return { ok: false, status: 400, error: { code: -32600, message: 'Invalid Request: missing method' } }; + } + + const headerVersion = readHeader(headers, MCP_HEADERS.protocolVersion); + const bodyVersion = metaProtocolVersion(body); + + if (!headerVersion) { + return headerMismatch(`the ${MCP_HEADERS.protocolVersion} header is required`); + } + + const isNotification = body['id'] === undefined || body['id'] === null; + + // Notification POSTs carry no `_meta` contract in this revision — the spec + // explicitly leaves their header requirements undefined — so validation stops + // at the version header. + if (isNotification) { + return versionSupported(headerVersion) ?? { ok: true, version: headerVersion }; + } + + if (!bodyVersion) { + return headerMismatch(`request params._meta must declare "${MCP_2026_META.protocolVersion}"`); + } + if (headerVersion !== bodyVersion) { + return headerMismatch( + `${MCP_HEADERS.protocolVersion} header value '${headerVersion}' does not match body value '${bodyVersion}'`, + ); + } + + const unsupported = versionSupported(headerVersion); + if (unsupported) return unsupported; + + const methodHeader = readHeader(headers, MCP_HEADERS.method); + if (!methodHeader) { + return headerMismatch(`the ${MCP_HEADERS.method} header is required`); + } + if (methodHeader !== method) { + return headerMismatch(`${MCP_HEADERS.method} header value '${methodHeader}' does not match body value '${method}'`); + } + + const params = (body['params'] as Record | undefined) ?? {}; + + const expectsName = NAME_FROM_PARAMS_NAME.includes(method) || NAME_FROM_PARAMS_URI.includes(method); + if (expectsName) { + const sourceKey = NAME_FROM_PARAMS_URI.includes(method) ? 'uri' : 'name'; + const bodyValue = params[sourceKey]; + const rawHeader = readHeader(headers, MCP_HEADERS.name); + + if (bodyValue !== undefined) { + if (rawHeader === undefined) { + return headerMismatch(`the ${MCP_HEADERS.name} header is required for ${method}`); + } + if (hasInvalidHeaderChars(rawHeader)) { + return headerMismatch(`${MCP_HEADERS.name} header contains invalid characters`); + } + const decoded = decodeHeaderValue(rawHeader); + if (decoded === undefined) { + return headerMismatch(`${MCP_HEADERS.name} header is not valid base64`); + } + if (!headerMatchesBodyValue(decoded, bodyValue)) { + return headerMismatch( + `${MCP_HEADERS.name} header value '${decoded}' does not match body value '${String(bodyValue)}'`, + ); + } + } + } + + if (method === 'tools/call' && lookupToolSchema) { + const failure = validateParamHeaders(headers, params, lookupToolSchema); + if (failure) return failure; + } + + return { ok: true, version: headerVersion }; +} + +function versionSupported(version: string): ValidationFailure | undefined { + if ((SUPPORTED_PROTOCOL_VERSIONS_2026 as readonly string[]).includes(version)) return undefined; + return { + ok: false, + status: 400, + error: { + code: MCP_2026_ERROR_CODES.unsupportedProtocolVersion, + message: `Unsupported protocol version: ${version}`, + data: { supported: [...SUPPORTED_PROTOCOL_VERSIONS_2026], requested: version }, + }, + }; +} + +/** + * Enforce the `Mcp-Param-{Name}` ⇄ argument agreement. + * + * A conforming client mirrors every annotated argument it actually sends. A + * missing header for a present argument means a non-conforming client, which + * the spec requires the server to reject — otherwise an intermediary routing on + * the header and the server executing on the body could disagree. + */ +function validateParamHeaders( + headers: Record | undefined, + params: Record, + lookupToolSchema: NonNullable, +): ValidationFailure | undefined { + const toolName = typeof params['name'] === 'string' ? (params['name'] as string) : undefined; + if (!toolName) return undefined; + + const schema = lookupToolSchema(toolName); + if (!schema) return undefined; + + const annotated = collectHeaderParams(schema); + if (annotated.size === 0) return undefined; + + const args = params['arguments']; + const paramHeaders = readParamHeaders(headers); + + for (const [headerName, path] of annotated) { + const bodyValue = readAtPath(args, path); + const rawHeader = paramHeaders.get(headerName); + + // Argument absent (or explicitly null) → the client MUST omit the header and + // the server MUST NOT expect it. + if (bodyValue === undefined || bodyValue === null) { + if (rawHeader !== undefined) { + return headerMismatch(`Mcp-Param-${headerName} was sent but '${path.join('.')}' is absent from the arguments`); + } + continue; + } + + if (rawHeader === undefined) { + return headerMismatch(`Mcp-Param-${headerName} header is required for argument '${path.join('.')}'`); + } + if (hasInvalidHeaderChars(rawHeader)) { + return headerMismatch(`Mcp-Param-${headerName} header contains invalid characters`); + } + const decoded = decodeHeaderValue(rawHeader); + if (decoded === undefined) { + return headerMismatch(`Mcp-Param-${headerName} header is not valid base64`); + } + if (!headerMatchesBodyValue(decoded, bodyValue)) { + return headerMismatch( + `Mcp-Param-${headerName} header value '${decoded}' does not match body value '${String(bodyValue)}'`, + ); + } + } + + return undefined; +} + +/** The revision this pipeline implements — exported for callers building results. */ +export const IMPLEMENTED_PROTOCOL_VERSION = PROTOCOL_2026_07_28; diff --git a/libs/sdk/src/transport/mcp-2026/result-decorator.ts b/libs/sdk/src/transport/mcp-2026/result-decorator.ts new file mode 100644 index 000000000..e141307cc --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/result-decorator.ts @@ -0,0 +1,70 @@ +/** + * Result shaping for protocol 2026-07-28. + * + * Three additions ride on every response of this revision: + * - `resultType` — REQUIRED on all results, so the client can tell a final + * result from an MRTR interim one without guessing. + * - `_meta["io.modelcontextprotocol/serverInfo"]` — the identity that used to + * arrive once via `initialize`. + * - `ttlMs` + `cacheScope` — REQUIRED on `CacheableResult` methods only. + * + * Applied at the dispatcher boundary rather than inside each handler, so the + * existing handlers (shared with every older transport) stay untouched and no + * legacy response can accidentally grow these fields. + */ +import { MCP_2026_META, type Implementation } from '@frontmcp/protocol'; + +import { CACHEABLE_METHODS, DEFAULT_CACHE_TTL_MS } from './protocol-2026.constants'; + +export interface DecorateResultOptions { + /** JSON-RPC method that produced this result. */ + method: string; + /** Server identity advertised back to the client. */ + serverInfo: Implementation; + /** + * `private` when the payload may vary by authorization context, `public` when + * it is identical for every caller. Getting this wrong lets a shared proxy + * serve one tenant's tool list to another, so the default is `private`. + */ + cacheScope?: 'public' | 'private'; + /** Override for the per-method TTL default. */ + ttlMs?: number; +} + +/** Attach the 2026-07-28 envelope fields to a handler's raw result. */ +export function decorateResult( + result: Record, + options: DecorateResultOptions, +): Record { + const { method, serverInfo, cacheScope = 'private', ttlMs } = options; + + const existingMeta = (result['_meta'] as Record | undefined) ?? {}; + const decorated: Record = { + ...result, + // A handler that already produced an interim result (MRTR) keeps its own + // discriminator; everything else is a completed result. + resultType: typeof result['resultType'] === 'string' ? result['resultType'] : 'complete', + _meta: { + ...existingMeta, + [MCP_2026_META.serverInfo]: serverInfo, + }, + }; + + if (CACHEABLE_METHODS.includes(method)) { + decorated['ttlMs'] = ttlMs ?? DEFAULT_CACHE_TTL_MS[method] ?? 0; + decorated['cacheScope'] = cacheScope; + } + + return decorated; +} + +/** + * Choose a cache scope for a request. + * + * Anonymous/public traffic carries no per-user variation and is safe to share; + * anything tied to a token is `private` so intermediaries cannot cross + * authorization contexts. + */ +export function resolveCacheScope(isAnonymous: boolean): 'public' | 'private' { + return isAnonymous ? 'public' : 'private'; +} diff --git a/libs/sdk/src/transport/mcp-2026/subscriptions.ts b/libs/sdk/src/transport/mcp-2026/subscriptions.ts new file mode 100644 index 000000000..83f6463ce --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/subscriptions.ts @@ -0,0 +1,197 @@ +/** + * `subscriptions/listen` — protocol 2026-07-28, SEP-2575. + * + * Replaces the standalone HTTP GET stream and the + * `resources/subscribe` / `resources/unsubscribe` RPC pair with ONE long-lived + * POST-response stream. Two rules shape the implementation: + * + * - **Opt-in only.** The server MUST NOT push a notification type the client + * did not name in the request's filter. + * - **Acknowledge first.** The acknowledgement MUST be the first message + * carrying the subscription's id, so a client never sees a change + * notification before it knows which of its requests was honored. + * + * The stream is produced as an `AsyncIterable` of SSE frames rather + * than by writing to a runtime-specific response object, so the same code + * renders on Node and on a Web `Response` body. + */ +import { MCP_2026_META, SUBSCRIPTIONS_ACKNOWLEDGED_METHOD, type SubscriptionFilter } from '@frontmcp/protocol'; + +import { type Scope } from '../../scope'; + +/** How often a bare SSE comment is emitted to hold the connection open. */ +const KEEPALIVE_INTERVAL_MS = 15_000; + +export interface SubscriptionStreamOptions { + scope: Scope; + /** JSON-RPC id of the `subscriptions/listen` request; doubles as the stream id. */ + subscriptionId: string | number; + /** Notification types the client asked for. */ + requested: SubscriptionFilter; + /** Fires when the client disconnects so the registry listeners are released. */ + signal?: AbortSignal; +} + +/** + * Narrow the client's filter to what this scope can actually deliver. + * + * A server that has no prompts cannot honor `promptsListChanged`; the spec says + * to omit it from the acknowledgement rather than accept and stay silent, so + * the client knows not to wait for it. + */ +export function resolveAcknowledgedFilter(scope: Scope, requested: SubscriptionFilter): SubscriptionFilter { + const acknowledged: SubscriptionFilter = {}; + + if (requested.toolsListChanged && scope.tools.getCapabilities().tools?.listChanged) { + acknowledged.toolsListChanged = true; + } + if (requested.promptsListChanged && scope.prompts.getCapabilities().prompts?.listChanged) { + acknowledged.promptsListChanged = true; + } + if (requested.resourcesListChanged && scope.resources.getCapabilities().resources?.listChanged) { + acknowledged.resourcesListChanged = true; + } + if (requested.resourceSubscriptions && requested.resourceSubscriptions.length > 0) { + acknowledged.resourceSubscriptions = [...requested.resourceSubscriptions]; + } + + return acknowledged; +} + +const encoder = new TextEncoder(); + +/** + * Serialize one JSON-RPC message as an SSE `message` event. + * + * Emitted as bytes so the same iterable feeds the Node writer and a Web + * `Response` body without a per-runtime conversion step. + */ +function frame(message: unknown): Uint8Array { + return encoder.encode(`event: message\ndata: ${JSON.stringify(message)}\n\n`); +} + +/** A bare SSE comment — ignored by clients, keeps intermediaries from timing out. */ +const KEEPALIVE_FRAME = encoder.encode(':\n\n'); + +interface QueuedNotification { + method: string; + params: Record; +} + +/** + * Build the SSE body for a `subscriptions/listen` request. + * + * Returns the acknowledged filter alongside the stream so the caller can log or + * assert on it without consuming the stream. + */ +export function createSubscriptionStream(options: SubscriptionStreamOptions): { + acknowledged: SubscriptionFilter; + stream: AsyncIterable; +} { + const { scope, subscriptionId, requested, signal } = options; + const acknowledged = resolveAcknowledgedFilter(scope, requested); + + const queue: QueuedNotification[] = []; + let notify: (() => void) | undefined; + let closed = false; + + const push = (method: string, params: Record = {}): void => { + if (closed) return; + queue.push({ method, params }); + notify?.(); + }; + + const unsubscribes: Array<() => void> = []; + + if (acknowledged.toolsListChanged) { + unsubscribes.push(scope.tools.subscribe({}, () => push('notifications/tools/list_changed'))); + } + if (acknowledged.promptsListChanged) { + unsubscribes.push(scope.prompts.subscribe({}, () => push('notifications/prompts/list_changed'))); + } + + const watchedUris = new Set(acknowledged.resourceSubscriptions ?? []); + if (acknowledged.resourcesListChanged || watchedUris.size > 0) { + unsubscribes.push( + scope.resources.subscribe({}, (event) => { + const updatedUri = (event as { updatedUri?: unknown }).updatedUri; + const uri = typeof updatedUri === 'string' ? updatedUri : undefined; + if (event.kind === 'updated') { + // A subscription is on a URI prefix, so a sub-resource update counts. + if (uri && [...watchedUris].some((watched) => uri === watched || uri.startsWith(watched))) { + push('notifications/resources/updated', { uri }); + } + return; + } + if (acknowledged.resourcesListChanged) { + push('notifications/resources/list_changed'); + } + }), + ); + } + + const cleanup = (): void => { + if (closed) return; + closed = true; + for (const unsubscribe of unsubscribes) { + try { + unsubscribe(); + } catch { + // A listener that is already detached is not an error worth surfacing. + } + } + notify?.(); + }; + + signal?.addEventListener('abort', cleanup, { once: true }); + + async function* generate(): AsyncIterable { + try { + // MUST be the first message carrying this subscription's id. + yield frame({ + jsonrpc: '2.0', + method: SUBSCRIPTIONS_ACKNOWLEDGED_METHOD, + params: { + notifications: acknowledged, + _meta: { [MCP_2026_META.subscriptionId]: subscriptionId }, + }, + }); + + for (;;) { + while (queue.length > 0) { + const next = queue.shift() as QueuedNotification; + yield frame({ + jsonrpc: '2.0', + method: next.method, + params: { + ...next.params, + _meta: { [MCP_2026_META.subscriptionId]: subscriptionId }, + }, + }); + } + + if (closed || signal?.aborted) break; + + const woke = await new Promise((resolve) => { + const timer = setTimeout(() => { + notify = undefined; + resolve(false); + }, KEEPALIVE_INTERVAL_MS); + notify = () => { + clearTimeout(timer); + notify = undefined; + resolve(true); + }; + }); + + // Timed out with nothing queued — emit an SSE comment so intermediaries + // and idle timeouts don't tear down a healthy but quiet stream. + if (!woke && !closed && !signal?.aborted) yield KEEPALIVE_FRAME; + } + } finally { + cleanup(); + } + } + + return { acknowledged, stream: generate() }; +} diff --git a/libs/sdk/src/transport/mcp-handlers/call-tool-request.handler.ts b/libs/sdk/src/transport/mcp-handlers/call-tool-request.handler.ts index be2b2f31b..88ab3aebd 100644 --- a/libs/sdk/src/transport/mcp-handlers/call-tool-request.handler.ts +++ b/libs/sdk/src/transport/mcp-handlers/call-tool-request.handler.ts @@ -8,7 +8,9 @@ import { import { FlowControl } from '../../common'; import { formatMcpErrorResponse, + InputRequiredSignal, InternalMcpError, + MissingClientCapabilityError, TaskAugmentationNotSupportedError, TaskAugmentationRequiredError, ToolCredentialsRequiredError, @@ -68,6 +70,16 @@ export default function callToolRequestHandler({ return formatMcpErrorResponse(new InternalMcpError(`Flow ended with: ${e.type}`)); } + // MRTR signals (protocol 2026-07-28) are protocol-level control flow, not + // tool failures: the tool is asking the client for input, or telling it + // which capability it must declare. Flattening them into a CallToolResult + // with `isError` would hide the `input_required` round trip from the + // dispatcher and strand the exchange. Re-throw so it can shape the + // `InputRequiredResult` / `-32021` response. + if (e instanceof InputRequiredSignal || e instanceof MissingClientCapabilityError) { + throw e; + } + // Task augmentation rejections are protocol-level errors per MCP spec §Tool-Level // Negotiation — emit them as JSON-RPC errors (not CallToolResult with isError). if (e instanceof TaskAugmentationNotSupportedError || e instanceof TaskAugmentationRequiredError) { diff --git a/libs/sdk/src/transport/transport.registry.ts b/libs/sdk/src/transport/transport.registry.ts index 9a0d334c9..ac4e9e17b 100644 --- a/libs/sdk/src/transport/transport.registry.ts +++ b/libs/sdk/src/transport/transport.registry.ts @@ -8,6 +8,7 @@ import type { RedisOptions } from '../common/types/options/redis'; import { InvalidTransportSessionError, SessionClaimConflictError } from '../errors/transport.errors'; import type { ClientCapabilities } from '../notification/notification.service'; import type { Scope } from '../scope'; +import HandleMcp2026Flow from './flows/handle.mcp-2026.flow'; import HandleSseFlow from './flows/handle.sse.flow'; import HandleStatelessHttpFlow from './flows/handle.stateless-http.flow'; import HandleStreamableHttpFlow from './flows/handle.streamable-http.flow'; @@ -188,7 +189,7 @@ export class TransportService { } } - await this.scope.registryFlows(HandleStreamableHttpFlow, HandleSseFlow, HandleStatelessHttpFlow); + await this.scope.registryFlows(HandleStreamableHttpFlow, HandleSseFlow, HandleStatelessHttpFlow, HandleMcp2026Flow); } async destroy() { diff --git a/libs/skills/package.json b/libs/skills/package.json index c939641c4..73b0b09d3 100644 --- a/libs/skills/package.json +++ b/libs/skills/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/skills", - "version": "1.4.0", + "version": "1.5.7", "description": "Curated skills catalog for FrontMCP projects", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", diff --git a/libs/storage-sqlite/package.json b/libs/storage-sqlite/package.json index 852d6591b..434234308 100644 --- a/libs/storage-sqlite/package.json +++ b/libs/storage-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/storage-sqlite", - "version": "1.4.0", + "version": "1.5.7", "description": "SQLite storage backend for FrontMCP - local session, elicitation, and event persistence without Redis", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", @@ -45,11 +45,11 @@ "node": ">=24.0.0" }, "dependencies": { - "@frontmcp/utils": "1.4.0", + "@frontmcp/utils": "1.5.7", "better-sqlite3": "^12.6.2" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0" + "@frontmcp/lazy-zod": "1.5.7" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", diff --git a/libs/testing/package.json b/libs/testing/package.json index 221936b1a..7c2c280a5 100644 --- a/libs/testing/package.json +++ b/libs/testing/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/testing", - "version": "1.4.0", + "version": "1.5.7", "description": "E2E testing framework for FrontMCP servers - MCP client, auth mocks, Playwright integration", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", @@ -92,9 +92,9 @@ "./esm": null }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0", - "@frontmcp/sdk": "1.4.0", - "@frontmcp/ui": "1.4.0", + "@frontmcp/lazy-zod": "1.5.7", + "@frontmcp/sdk": "1.5.7", + "@frontmcp/ui": "1.5.7", "@jest/globals": "^29.0.0 || ^30.0.0", "@playwright/test": "^1.40.0", "jest": "^29.0.0 || ^30.0.0" @@ -117,8 +117,8 @@ "node": ">=24.0.0" }, "dependencies": { - "@frontmcp/protocol": "1.4.0", - "@frontmcp/utils": "1.4.0", + "@frontmcp/protocol": "1.5.7", + "@frontmcp/utils": "1.5.7", "jose": "^6.0.11", "tslib": "^2.3.0" }, diff --git a/libs/testing/src/server/port-registry.ts b/libs/testing/src/server/port-registry.ts index 2070d9642..04dd682a0 100644 --- a/libs/testing/src/server/port-registry.ts +++ b/libs/testing/src/server/port-registry.ts @@ -73,6 +73,9 @@ export const E2E_PORT_RANGES = { // Distributed E2E tests (50440-50459) 'demo-e2e-distributed': { start: 50440, size: 20 }, + // Protocol revision E2E tests (50460-50479) + 'demo-e2e-protocol-2026': { start: 50460, size: 20 }, + // Mock servers and utilities (50900-50999) 'mock-oauth': { start: 50900, size: 10 }, 'mock-api': { start: 50910, size: 10 }, diff --git a/libs/ui/package.json b/libs/ui/package.json index 2ff25eafa..3e2a41d99 100644 --- a/libs/ui/package.json +++ b/libs/ui/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/ui", - "version": "1.4.0", + "version": "1.5.7", "description": "FrontMCP UI - MUI-based React components, renderers, and MCP bridge for MCP applications", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", @@ -472,7 +472,7 @@ } }, "optionalDependencies": { - "@frontmcp/uipack": "1.4.0" + "@frontmcp/uipack": "1.5.7" }, "devDependencies": { "@types/dompurify": "^3.0.0", diff --git a/libs/uipack/package.json b/libs/uipack/package.json index 6652d514c..a7c3c79c6 100644 --- a/libs/uipack/package.json +++ b/libs/uipack/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/uipack", - "version": "1.4.0", + "version": "1.5.7", "description": "FrontMCP UIpack - HTML shell builder, pluggable import resolver, and NPM component loader for MCP UI (React-free core)", "author": "AgentFront ", "homepage": "https://docs.agentfront.dev", @@ -63,6 +63,6 @@ "typescript": "^5.9.3" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0" + "@frontmcp/lazy-zod": "1.5.7" } } diff --git a/libs/utils/package.json b/libs/utils/package.json index c0c2c1c9f..afd3378a2 100644 --- a/libs/utils/package.json +++ b/libs/utils/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/utils", - "version": "1.4.0", + "version": "1.5.7", "description": "Shared utility functions for FrontMCP - string manipulation, URI handling, path utilities, and more", "author": "AgentFront ", "license": "Apache-2.0", @@ -32,7 +32,7 @@ "@noble/hashes": "^2.0.1" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0", + "@frontmcp/lazy-zod": "1.5.7", "@vercel/kv": "^2.0.0 || ^3.0.0", "ioredis": "^5.0.0" }, diff --git a/plugins/plugin-approval/package.json b/plugins/plugin-approval/package.json index 443551c89..4d2307937 100644 --- a/plugins/plugin-approval/package.json +++ b/plugins/plugin-approval/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/plugin-approval", - "version": "1.4.0", + "version": "1.5.7", "description": "Approval plugin for FrontMCP - tool authorization workflow with PKCE webhook security", "author": "AgentFront ", "license": "Apache-2.0", @@ -50,13 +50,13 @@ } }, "dependencies": { - "@frontmcp/sdk": "1.4.0", - "@frontmcp/utils": "1.4.0", + "@frontmcp/sdk": "1.5.7", + "@frontmcp/utils": "1.5.7", "ioredis": "^5.8.0", "reflect-metadata": "^0.2.2" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0", + "@frontmcp/lazy-zod": "1.5.7", "@vercel/kv": "^2.0.0 || ^3.0.0" }, "peerDependenciesMeta": { diff --git a/plugins/plugin-cache/package.json b/plugins/plugin-cache/package.json index 705b8bd94..191189457 100644 --- a/plugins/plugin-cache/package.json +++ b/plugins/plugin-cache/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/plugin-cache", - "version": "1.4.0", + "version": "1.5.7", "description": "Cache plugin for FrontMCP - Redis, Vercel KV, and in-memory caching with automatic tool result caching", "author": "AgentFront ", "license": "Apache-2.0", @@ -49,7 +49,7 @@ } }, "dependencies": { - "@frontmcp/sdk": "1.4.0", + "@frontmcp/sdk": "1.5.7", "ioredis": "^5.8.0" }, "peerDependencies": { diff --git a/plugins/plugin-codecall/package.json b/plugins/plugin-codecall/package.json index 71e00517c..1245f749f 100644 --- a/plugins/plugin-codecall/package.json +++ b/plugins/plugin-codecall/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/plugin-codecall", - "version": "1.5.6", + "version": "1.5.7", "description": "CodeCall plugin for FrontMCP - AgentScript-based meta-tools for orchestrating MCP tools", "author": "AgentFront ", "license": "Apache-2.0", @@ -50,13 +50,13 @@ }, "dependencies": { "@enclave-vm/core": "^2.15.1", - "@frontmcp/protocol": "1.5.6", - "@frontmcp/sdk": "1.5.6", + "@frontmcp/protocol": "1.5.7", + "@frontmcp/sdk": "1.5.7", "vectoriadb": "^2.3.2" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.5.6", - "@frontmcp/plugin-cache": "1.5.6" + "@frontmcp/lazy-zod": "1.5.7", + "@frontmcp/plugin-cache": "1.5.7" }, "devDependencies": { "reflect-metadata": "^0.2.2" diff --git a/plugins/plugin-dashboard/package.json b/plugins/plugin-dashboard/package.json index 61756fef9..8503c2b6c 100644 --- a/plugins/plugin-dashboard/package.json +++ b/plugins/plugin-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/plugin-dashboard", - "version": "1.4.0", + "version": "1.5.7", "description": "Dashboard plugin for FrontMCP - visual monitoring and introspection of MCP servers", "author": "AgentFront ", "license": "Apache-2.0", @@ -48,12 +48,12 @@ } }, "dependencies": { - "@frontmcp/sdk": "1.4.0" + "@frontmcp/sdk": "1.5.7" }, "devDependencies": { "reflect-metadata": "^0.2.2" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0" + "@frontmcp/lazy-zod": "1.5.7" } } diff --git a/plugins/plugin-feature-flags/package.json b/plugins/plugin-feature-flags/package.json index 5519bd32a..fe5fbc793 100644 --- a/plugins/plugin-feature-flags/package.json +++ b/plugins/plugin-feature-flags/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/plugin-feature-flags", - "version": "1.4.0", + "version": "1.5.7", "description": "Feature flag plugin for FrontMCP - dynamically gate MCP capabilities behind feature flags", "author": "AgentFront ", "license": "Apache-2.0", @@ -50,7 +50,7 @@ } }, "dependencies": { - "@frontmcp/sdk": "1.4.0" + "@frontmcp/sdk": "1.5.7" }, "peerDependencies": { "@launchdarkly/node-server-sdk": "^9.0.0 || ^10.0.0", diff --git a/plugins/plugin-remember/package.json b/plugins/plugin-remember/package.json index b9e6e4249..cb1dc16de 100644 --- a/plugins/plugin-remember/package.json +++ b/plugins/plugin-remember/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/plugin-remember", - "version": "1.4.0", + "version": "1.5.7", "description": "Remember plugin for FrontMCP - encrypted session memory with approval system for secure tool authorization", "author": "AgentFront ", "license": "Apache-2.0", @@ -50,12 +50,12 @@ } }, "dependencies": { - "@frontmcp/sdk": "1.4.0", - "@frontmcp/utils": "1.4.0", + "@frontmcp/sdk": "1.5.7", + "@frontmcp/utils": "1.5.7", "ioredis": "^5.8.0" }, "peerDependencies": { - "@frontmcp/lazy-zod": "1.4.0", + "@frontmcp/lazy-zod": "1.5.7", "@vercel/kv": "^2.0.0 || ^3.0.0" }, "peerDependenciesMeta": { diff --git a/plugins/plugin-skilled-openapi/package.json b/plugins/plugin-skilled-openapi/package.json index 0d38a6881..e94191a0b 100644 --- a/plugins/plugin-skilled-openapi/package.json +++ b/plugins/plugin-skilled-openapi/package.json @@ -1,6 +1,6 @@ { "name": "@frontmcp/plugin-skilled-openapi", - "version": "1.4.0", + "version": "1.5.7", "description": "FrontMCP plugin: serve a customer's OpenAPI spec as skill bundles (signed, OpenAPI-Overlay-based) with hidden per-operation tools mediated by three meta-tools (search_skill / load_skill / run_workflow — the last runs an enclave-sandboxed AgentScript that orchestrates the loaded skills' operations).", "author": "AgentFront ", "license": "Apache-2.0", @@ -49,14 +49,14 @@ } }, "dependencies": { - "@frontmcp/adapters": "1.4.0", - "@frontmcp/auth": "1.4.0", - "@frontmcp/sdk": "1.4.0" + "@frontmcp/adapters": "1.5.7", + "@frontmcp/auth": "1.5.7", + "@frontmcp/sdk": "1.5.7" }, "peerDependencies": { "@enclave-vm/ast": "*", "@enclave-vm/core": "*", - "@frontmcp/lazy-zod": "1.4.0" + "@frontmcp/lazy-zod": "1.5.7" }, "peerDependenciesMeta": { "@enclave-vm/ast": { diff --git a/scripts/normalize-internal-versions.mjs b/scripts/normalize-internal-versions.mjs new file mode 100644 index 000000000..86f10b298 --- /dev/null +++ b/scripts/normalize-internal-versions.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * Normalize the version line of every publishable workspace package. + * + * Sets `version` on each `libs//package.json` and `plugins//package.json` + * to a single target version, and rewrites every internal `@frontmcp/*` entry in + * `dependencies` / `devDependencies` / `peerDependencies` / `optionalDependencies` + * to that same exact version. + * + * Why this exists + * --------------- + * Internal deps are pinned to exact versions (see create-release-branch.yml — caret + * ranges do not satisfy prereleases like `1.1.0-beta.1`, which breaks publishing). + * Yarn records those exact pins inside yarn.lock, so any package.json whose version + * line disagrees with its siblings makes `yarn install --immutable` fail with YN0028 + * — and, worse, makes Yarn resolve the mismatched siblings from the npm registry + * instead of linking the local workspace. + * + * That is exactly what a cherry-pick from a release branch does: it drags the release + * line's bumped `version` fields and pins into a branch that is still on its own line. + * + * Usage + * ----- + * node scripts/normalize-internal-versions.mjs # infer target, rewrite + * node scripts/normalize-internal-versions.mjs 1.4.0 # explicit target, rewrite + * node scripts/normalize-internal-versions.mjs --check # infer target, report only + * node scripts/normalize-internal-versions.mjs 1.4.0 --check + * + * `--check` exits non-zero when anything would change, so CI can gate on it. + * + * With no explicit target, the version shared by the largest number of workspace + * packages wins. That makes the script self-healing: a cherry-pick that contaminates + * a handful of manifests is corrected back to whatever the branch as a whole is on. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']; +const WORKSPACE_DIRS = ['libs', 'plugins']; +const INTERNAL_SCOPE = '@frontmcp/'; + +const argv = process.argv.slice(2); +const checkOnly = argv.includes('--check'); +const explicitVersion = argv.find((a) => !a.startsWith('--')); + +if (explicitVersion && !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(explicitVersion)) { + console.error(`Invalid version: ${explicitVersion}. Expected semver, e.g. 1.4.0 or 1.5.0-beta.1`); + process.exit(1); +} + +/** @returns {{file: string, raw: string, pkg: Record}[]} */ +function collectManifests() { + const found = []; + for (const dir of WORKSPACE_DIRS) { + if (!fs.existsSync(dir)) continue; + for (const entry of fs.readdirSync(dir).sort()) { + const file = path.join(dir, entry, 'package.json'); + if (!fs.existsSync(file)) continue; + const raw = fs.readFileSync(file, 'utf8'); + found.push({ file, raw, pkg: JSON.parse(raw) }); + } + } + return found; +} + +/** Version held by the most workspace packages; ties are ambiguous and rejected. */ +function inferTargetVersion(manifests) { + const tally = new Map(); + for (const { pkg } of manifests) { + if (typeof pkg.version === 'string') { + tally.set(pkg.version, (tally.get(pkg.version) ?? 0) + 1); + } + } + const ranked = [...tally.entries()].sort((a, b) => b[1] - a[1]); + if (ranked.length === 0) { + console.error('Could not infer a target version: no workspace package declares one.'); + process.exit(1); + } + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) { + console.error( + `Could not infer a target version: "${ranked[0][0]}" and "${ranked[1][0]}" are equally common ` + + `(${ranked[0][1]} packages each). Pass the intended version explicitly.`, + ); + process.exit(1); + } + return ranked[0][0]; +} + +const manifests = collectManifests(); +if (manifests.length === 0) { + console.error(`No workspace manifests found under ${WORKSPACE_DIRS.join('/, ')}/.`); + process.exit(1); +} + +const target = explicitVersion ?? inferTargetVersion(manifests); +console.log( + `Target version: ${target}${explicitVersion ? '' : ' (inferred — most common across workspace packages)'}\n`, +); + +const drifted = []; + +for (const { file, raw, pkg } of manifests) { + /** @type {string[]} */ + const changes = []; + + if (pkg.version !== target) { + changes.push(`version: ${pkg.version} -> ${target}`); + pkg.version = target; + } + + for (const section of SECTIONS) { + const deps = pkg[section]; + if (!deps) continue; + for (const name of Object.keys(deps)) { + // Only internal packages are pinned this way. External deps (and any + // `workspace:`/`file:` protocol entry, should one ever appear) are left alone. + if (!name.startsWith(INTERNAL_SCOPE)) continue; + if (typeof deps[name] !== 'string' || deps[name].includes(':')) continue; + if (deps[name] === target) continue; + changes.push(`${section}.${name}: ${deps[name]} -> ${target}`); + deps[name] = target; + } + } + + if (changes.length === 0) continue; + + drifted.push({ file, changes }); + console.log(`${file}`); + for (const c of changes) console.log(` ${c}`); + + if (!checkOnly) { + fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + (raw.endsWith('\n') ? '\n' : '')); + } +} + +if (drifted.length === 0) { + console.log(`All ${manifests.length} workspace package(s) already on v${target}.`); + process.exit(0); +} + +const changeCount = drifted.reduce((n, d) => n + d.changes.length, 0); + +if (checkOnly) { + console.error( + `\n✗ ${changeCount} version pin(s) across ${drifted.length} package(s) do not match v${target}.\n` + + ` Run: node scripts/normalize-internal-versions.mjs && yarn install --mode=update-lockfile`, + ); + process.exit(1); +} + +console.log(`\n✓ Normalized ${changeCount} pin(s) across ${drifted.length} package(s) to v${target}.`); +console.log(' Next: yarn install --mode=update-lockfile'); diff --git a/scripts/normalize-internal-versions.test.mjs b/scripts/normalize-internal-versions.test.mjs new file mode 100644 index 000000000..943a22c5c --- /dev/null +++ b/scripts/normalize-internal-versions.test.mjs @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { after, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const scriptPath = path.join(__dirname, 'normalize-internal-versions.mjs'); + +const tempDirs = []; + +after(async () => { + for (const dir of tempDirs) { + try { + await fs.rm(dir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } +}); + +/** + * Materialize a fake workspace and run the script inside it. + * @param {Record>} layout keyed by "/" + * @param {string[]} args + */ +async function run(layout, args = []) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'normalize-versions-')); + tempDirs.push(root); + + for (const [rel, pkg] of Object.entries(layout)) { + const dir = path.join(root, rel); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); + } + + const res = spawnSync(process.execPath, [scriptPath, ...args], { cwd: root, encoding: 'utf8' }); + + const read = async (rel) => JSON.parse(await fs.readFile(path.join(root, rel, 'package.json'), 'utf8')); + return { status: res.status, stdout: res.stdout, stderr: res.stderr, read }; +} + +test('rewrites version and internal pins to an explicit target', async () => { + const { status, read } = await run( + { + 'libs/sdk': { + name: '@frontmcp/sdk', + version: '1.5.6', + dependencies: { '@frontmcp/utils': '1.5.6', zod: '^4.0.0' }, + peerDependencies: { '@frontmcp/observability': '1.5.6' }, + }, + 'libs/utils': { name: '@frontmcp/utils', version: '1.4.0' }, + }, + ['1.4.0'], + ); + + assert.equal(status, 0); + const sdk = await read('libs/sdk'); + assert.equal(sdk.version, '1.4.0'); + assert.equal(sdk.dependencies['@frontmcp/utils'], '1.4.0'); + assert.equal(sdk.peerDependencies['@frontmcp/observability'], '1.4.0'); +}); + +test('leaves external dependencies untouched', async () => { + const { read } = await run( + { + 'libs/sdk': { + name: '@frontmcp/sdk', + version: '1.5.6', + dependencies: { zod: '^4.0.0', 'mcp-from-openapi': '2.5.1', '@enclave-vm/core': '^2.15.1' }, + }, + }, + ['1.4.0'], + ); + + const sdk = await read('libs/sdk'); + assert.equal(sdk.dependencies['zod'], '^4.0.0'); + assert.equal(sdk.dependencies['mcp-from-openapi'], '2.5.1'); + assert.equal(sdk.dependencies['@enclave-vm/core'], '^2.15.1'); +}); + +test('covers plugins/ as well as libs/', async () => { + const { read } = await run( + { + 'libs/sdk': { name: '@frontmcp/sdk', version: '1.4.0' }, + 'plugins/plugin-codecall': { + name: '@frontmcp/plugin-codecall', + version: '1.5.6', + dependencies: { '@frontmcp/sdk': '1.5.6' }, + }, + }, + ['1.4.0'], + ); + + const plugin = await read('plugins/plugin-codecall'); + assert.equal(plugin.version, '1.4.0'); + assert.equal(plugin.dependencies['@frontmcp/sdk'], '1.4.0'); +}); + +test('infers the majority version when no target is given', async () => { + // The real cherry-pick shape: two manifests dragged to the release line, the + // rest still on the branch's own line. The majority must win. + const { status, stdout, read } = await run({ + 'libs/a': { name: '@frontmcp/a', version: '1.4.0' }, + 'libs/b': { name: '@frontmcp/b', version: '1.4.0' }, + 'libs/c': { name: '@frontmcp/c', version: '1.4.0' }, + 'libs/sdk': { name: '@frontmcp/sdk', version: '1.5.6', dependencies: { '@frontmcp/a': '1.5.6' } }, + }); + + assert.equal(status, 0); + assert.match(stdout, /Target version: 1\.4\.0/); + const sdk = await read('libs/sdk'); + assert.equal(sdk.version, '1.4.0'); + assert.equal(sdk.dependencies['@frontmcp/a'], '1.4.0'); +}); + +test('refuses to guess when two versions are equally common', async () => { + const { status, stderr } = await run({ + 'libs/a': { name: '@frontmcp/a', version: '1.4.0' }, + 'libs/b': { name: '@frontmcp/b', version: '1.5.6' }, + }); + + assert.equal(status, 1); + assert.match(stderr, /equally common/); +}); + +test('--check reports drift without writing', async () => { + const { status, stderr, read } = await run( + { + 'libs/sdk': { name: '@frontmcp/sdk', version: '1.5.6', dependencies: { '@frontmcp/utils': '1.5.6' } }, + 'libs/utils': { name: '@frontmcp/utils', version: '1.4.0' }, + }, + ['1.4.0', '--check'], + ); + + assert.equal(status, 1); + assert.match(stderr, /do not match v1\.4\.0/); + // Unchanged on disk. + assert.equal((await read('libs/sdk')).version, '1.5.6'); +}); + +test('--check passes on a consistent workspace', async () => { + const { status, stdout } = await run( + { + 'libs/sdk': { name: '@frontmcp/sdk', version: '1.4.0', dependencies: { '@frontmcp/utils': '1.4.0' } }, + 'libs/utils': { name: '@frontmcp/utils', version: '1.4.0' }, + }, + ['1.4.0', '--check'], + ); + + assert.equal(status, 0); + assert.match(stdout, /already on v1\.4\.0/); +}); + +test('is idempotent', async () => { + const layout = { + 'libs/sdk': { name: '@frontmcp/sdk', version: '1.5.6', dependencies: { '@frontmcp/utils': '1.5.6' } }, + 'libs/utils': { name: '@frontmcp/utils', version: '1.4.0' }, + }; + const first = await run(layout, ['1.4.0']); + assert.equal(first.status, 0); + + const second = await run( + { + 'libs/sdk': await first.read('libs/sdk'), + 'libs/utils': await first.read('libs/utils'), + }, + ['1.4.0', '--check'], + ); + assert.equal(second.status, 0); +}); + +test('skips protocol ranges such as workspace:', async () => { + // Not used today, but if the repo ever migrates internal deps to the workspace + // protocol these must not be clobbered back into exact pins. + const { read } = await run( + { + 'libs/sdk': { name: '@frontmcp/sdk', version: '1.5.6', dependencies: { '@frontmcp/utils': 'workspace:^' } }, + }, + ['1.4.0'], + ); + + assert.equal((await read('libs/sdk')).dependencies['@frontmcp/utils'], 'workspace:^'); +}); + +test('rejects a malformed explicit version', async () => { + const { status, stderr } = await run({ 'libs/sdk': { name: '@frontmcp/sdk', version: '1.4.0' } }, ['not-a-version']); + + assert.equal(status, 1); + assert.match(stderr, /Invalid version/); +}); + +test('accepts prerelease targets', async () => { + const { status, read } = await run( + { + 'libs/sdk': { name: '@frontmcp/sdk', version: '1.4.0', dependencies: { '@frontmcp/utils': '1.4.0' } }, + }, + ['1.5.0-beta.1'], + ); + + assert.equal(status, 0); + const sdk = await read('libs/sdk'); + assert.equal(sdk.version, '1.5.0-beta.1'); + assert.equal(sdk.dependencies['@frontmcp/utils'], '1.5.0-beta.1'); +}); diff --git a/yarn.lock b/yarn.lock index ccc1245ad..ff1f629fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2385,33 +2385,33 @@ __metadata: languageName: node linkType: hard -"@frontmcp/adapters@npm:1.5.6, @frontmcp/adapters@workspace:libs/adapters": +"@frontmcp/adapters@npm:1.5.7, @frontmcp/adapters@workspace:libs/adapters": version: 0.0.0-use.local resolution: "@frontmcp/adapters@workspace:libs/adapters" dependencies: - "@frontmcp/auth": "npm:1.5.6" - "@frontmcp/di": "npm:1.5.6" - "@frontmcp/sdk": "npm:1.5.6" - "@frontmcp/utils": "npm:1.5.6" + "@frontmcp/auth": "npm:1.5.7" + "@frontmcp/di": "npm:1.5.7" + "@frontmcp/sdk": "npm:1.5.7" + "@frontmcp/utils": "npm:1.5.7" js-yaml: "npm:^4.1.0" mcp-from-openapi: "npm:2.5.1" openapi-types: "npm:^12.1.3" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 languageName: unknown linkType: soft -"@frontmcp/auth@npm:1.5.6, @frontmcp/auth@workspace:libs/auth": +"@frontmcp/auth@npm:1.5.7, @frontmcp/auth@workspace:libs/auth": version: 0.0.0-use.local resolution: "@frontmcp/auth@workspace:libs/auth" dependencies: - "@frontmcp/di": "npm:1.5.6" - "@frontmcp/utils": "npm:1.5.6" + "@frontmcp/di": "npm:1.5.7" + "@frontmcp/utils": "npm:1.5.7" jose: "npm:^6.0.0" typescript: "npm:^5.9.3" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 - "@frontmcp/storage-sqlite": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 + "@frontmcp/storage-sqlite": 1.5.7 "@vercel/kv": ^3.0.0 ioredis: ^5.0.0 peerDependenciesMeta: @@ -2424,7 +2424,7 @@ __metadata: languageName: unknown linkType: soft -"@frontmcp/di@npm:1.5.6, @frontmcp/di@workspace:libs/di": +"@frontmcp/di@npm:1.5.7, @frontmcp/di@workspace:libs/di": version: 0.0.0-use.local resolution: "@frontmcp/di@workspace:libs/di" dependencies: @@ -2433,7 +2433,7 @@ __metadata: typescript: "npm:^5.0.0" zod: "npm:^4.0.0" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 reflect-metadata: ^0.2.0 languageName: unknown linkType: soft @@ -2445,28 +2445,28 @@ __metadata: "@types/node": "npm:^24.0.0" typescript: "npm:^5.0.0" peerDependencies: - "@frontmcp/plugin-skilled-openapi": 1.5.6 - "@frontmcp/sdk": 1.5.6 + "@frontmcp/plugin-skilled-openapi": 1.5.7 + "@frontmcp/sdk": 1.5.7 peerDependenciesMeta: "@frontmcp/plugin-skilled-openapi": optional: true languageName: unknown linkType: soft -"@frontmcp/guard@npm:1.5.6, @frontmcp/guard@workspace:libs/guard": +"@frontmcp/guard@npm:1.5.7, @frontmcp/guard@workspace:libs/guard": version: 0.0.0-use.local resolution: "@frontmcp/guard@workspace:libs/guard" dependencies: - "@frontmcp/utils": "npm:1.5.6" + "@frontmcp/utils": "npm:1.5.7" "@types/node": "npm:^24.0.0" typescript: "npm:^5.0.0" zod: "npm:^4.0.0" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 languageName: unknown linkType: soft -"@frontmcp/lazy-zod@npm:1.5.6, @frontmcp/lazy-zod@workspace:libs/lazy-zod": +"@frontmcp/lazy-zod@npm:1.5.7, @frontmcp/lazy-zod@workspace:libs/lazy-zod": version: 0.0.0-use.local resolution: "@frontmcp/lazy-zod@workspace:libs/lazy-zod" dependencies: @@ -2482,7 +2482,7 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/nx@workspace:libs/nx-plugin" dependencies: - "@frontmcp/skills": "npm:1.5.6" + "@frontmcp/skills": "npm:1.5.7" "@nx/devkit": "npm:22.6.4" tslib: "npm:^2.3.0" peerDependencies: @@ -2499,8 +2499,8 @@ __metadata: "@types/node": "npm:^24.0.0" typescript: "npm:^5.0.0" peerDependencies: - "@frontmcp/sdk": 1.5.6 - "@frontmcp/utils": 1.5.6 + "@frontmcp/sdk": 1.5.7 + "@frontmcp/utils": 1.5.7 "@opentelemetry/exporter-trace-otlp-http": ^0.219.0 "@opentelemetry/sdk-node": ^0.219.0 "@opentelemetry/sdk-trace-base": ^2.8.0 @@ -2524,12 +2524,12 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/plugin-approval@workspace:plugins/plugin-approval" dependencies: - "@frontmcp/sdk": "npm:1.5.6" - "@frontmcp/utils": "npm:1.5.6" + "@frontmcp/sdk": "npm:1.5.7" + "@frontmcp/utils": "npm:1.5.7" ioredis: "npm:^5.8.0" reflect-metadata: "npm:^0.2.2" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 "@vercel/kv": ^2.0.0 || ^3.0.0 peerDependenciesMeta: "@vercel/kv": @@ -2537,11 +2537,11 @@ __metadata: languageName: unknown linkType: soft -"@frontmcp/plugin-cache@npm:1.5.6, @frontmcp/plugin-cache@workspace:plugins/plugin-cache": +"@frontmcp/plugin-cache@npm:1.5.7, @frontmcp/plugin-cache@workspace:plugins/plugin-cache": version: 0.0.0-use.local resolution: "@frontmcp/plugin-cache@workspace:plugins/plugin-cache" dependencies: - "@frontmcp/sdk": "npm:1.5.6" + "@frontmcp/sdk": "npm:1.5.7" ioredis: "npm:^5.8.0" reflect-metadata: "npm:^0.2.2" peerDependencies: @@ -2552,29 +2552,29 @@ __metadata: languageName: unknown linkType: soft -"@frontmcp/plugin-codecall@npm:1.5.6, @frontmcp/plugin-codecall@workspace:plugins/plugin-codecall": +"@frontmcp/plugin-codecall@npm:1.5.7, @frontmcp/plugin-codecall@workspace:plugins/plugin-codecall": version: 0.0.0-use.local resolution: "@frontmcp/plugin-codecall@workspace:plugins/plugin-codecall" dependencies: "@enclave-vm/core": "npm:^2.15.1" - "@frontmcp/protocol": "npm:1.5.6" - "@frontmcp/sdk": "npm:1.5.6" + "@frontmcp/protocol": "npm:1.5.7" + "@frontmcp/sdk": "npm:1.5.7" reflect-metadata: "npm:^0.2.2" vectoriadb: "npm:^2.3.2" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 - "@frontmcp/plugin-cache": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 + "@frontmcp/plugin-cache": 1.5.7 languageName: unknown linkType: soft -"@frontmcp/plugin-dashboard@npm:1.5.6, @frontmcp/plugin-dashboard@workspace:plugins/plugin-dashboard": +"@frontmcp/plugin-dashboard@npm:1.5.7, @frontmcp/plugin-dashboard@workspace:plugins/plugin-dashboard": version: 0.0.0-use.local resolution: "@frontmcp/plugin-dashboard@workspace:plugins/plugin-dashboard" dependencies: - "@frontmcp/sdk": "npm:1.5.6" + "@frontmcp/sdk": "npm:1.5.7" reflect-metadata: "npm:^0.2.2" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 languageName: unknown linkType: soft @@ -2582,7 +2582,7 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/plugin-feature-flags@workspace:plugins/plugin-feature-flags" dependencies: - "@frontmcp/sdk": "npm:1.5.6" + "@frontmcp/sdk": "npm:1.5.7" reflect-metadata: "npm:^0.2.2" peerDependencies: "@launchdarkly/node-server-sdk": ^9.0.0 || ^10.0.0 @@ -2598,16 +2598,16 @@ __metadata: languageName: unknown linkType: soft -"@frontmcp/plugin-remember@npm:1.5.6, @frontmcp/plugin-remember@workspace:plugins/plugin-remember": +"@frontmcp/plugin-remember@npm:1.5.7, @frontmcp/plugin-remember@workspace:plugins/plugin-remember": version: 0.0.0-use.local resolution: "@frontmcp/plugin-remember@workspace:plugins/plugin-remember" dependencies: - "@frontmcp/sdk": "npm:1.5.6" - "@frontmcp/utils": "npm:1.5.6" + "@frontmcp/sdk": "npm:1.5.7" + "@frontmcp/utils": "npm:1.5.7" ioredis: "npm:^5.8.0" reflect-metadata: "npm:^0.2.2" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 "@vercel/kv": ^2.0.0 || ^3.0.0 peerDependenciesMeta: "@vercel/kv": @@ -2619,14 +2619,14 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/plugin-skilled-openapi@workspace:plugins/plugin-skilled-openapi" dependencies: - "@frontmcp/adapters": "npm:1.5.6" - "@frontmcp/auth": "npm:1.5.6" - "@frontmcp/sdk": "npm:1.5.6" + "@frontmcp/adapters": "npm:1.5.7" + "@frontmcp/auth": "npm:1.5.7" + "@frontmcp/sdk": "npm:1.5.7" reflect-metadata: "npm:^0.2.2" peerDependencies: "@enclave-vm/ast": "*" "@enclave-vm/core": "*" - "@frontmcp/lazy-zod": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 peerDependenciesMeta: "@enclave-vm/ast": optional: true @@ -2639,14 +2639,14 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/plugins@workspace:libs/plugins" dependencies: - "@frontmcp/plugin-cache": "npm:1.5.6" - "@frontmcp/plugin-codecall": "npm:1.5.6" - "@frontmcp/plugin-dashboard": "npm:1.5.6" - "@frontmcp/plugin-remember": "npm:1.5.6" + "@frontmcp/plugin-cache": "npm:1.5.7" + "@frontmcp/plugin-codecall": "npm:1.5.7" + "@frontmcp/plugin-dashboard": "npm:1.5.7" + "@frontmcp/plugin-remember": "npm:1.5.7" languageName: unknown linkType: soft -"@frontmcp/protocol@npm:1.5.6, @frontmcp/protocol@workspace:libs/protocol": +"@frontmcp/protocol@npm:1.5.7, @frontmcp/protocol@workspace:libs/protocol": version: 0.0.0-use.local resolution: "@frontmcp/protocol@workspace:libs/protocol" dependencies: @@ -2660,13 +2660,13 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/react@workspace:libs/react" dependencies: - "@frontmcp/sdk": "npm:1.5.6" - "@frontmcp/utils": "npm:1.5.6" + "@frontmcp/sdk": "npm:1.5.7" + "@frontmcp/utils": "npm:1.5.7" typescript: "npm:^5.9.3" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 - "@frontmcp/sdk": 1.5.6 - "@frontmcp/utils": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 + "@frontmcp/sdk": 1.5.7 + "@frontmcp/utils": 1.5.7 react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 react-router-dom: ^7.0.0 @@ -2678,17 +2678,17 @@ __metadata: languageName: unknown linkType: soft -"@frontmcp/sdk@npm:1.5.6, @frontmcp/sdk@workspace:libs/sdk": +"@frontmcp/sdk@npm:1.5.7, @frontmcp/sdk@workspace:libs/sdk": version: 0.0.0-use.local resolution: "@frontmcp/sdk@workspace:libs/sdk" dependencies: - "@frontmcp/auth": "npm:1.5.6" - "@frontmcp/di": "npm:1.5.6" - "@frontmcp/guard": "npm:1.5.6" - "@frontmcp/lazy-zod": "npm:1.5.6" - "@frontmcp/protocol": "npm:1.5.6" - "@frontmcp/uipack": "npm:1.5.6" - "@frontmcp/utils": "npm:1.5.6" + "@frontmcp/auth": "npm:1.5.7" + "@frontmcp/di": "npm:1.5.7" + "@frontmcp/guard": "npm:1.5.7" + "@frontmcp/lazy-zod": "npm:1.5.7" + "@frontmcp/protocol": "npm:1.5.7" + "@frontmcp/uipack": "npm:1.5.7" + "@frontmcp/utils": "npm:1.5.7" "@types/cors": "npm:^2.8.17" ioredis: "npm:^5.8.0" jose: "npm:^6.1.3" @@ -2699,8 +2699,8 @@ __metadata: peerDependencies: "@anthropic-ai/sdk": ^0.30.0 || ^0.78.0 "@enclave-vm/core": ^2.15.1 - "@frontmcp/observability": 1.5.6 - "@frontmcp/storage-sqlite": 1.5.6 + "@frontmcp/observability": 1.5.7 + "@frontmcp/storage-sqlite": 1.5.7 "@opentelemetry/api": ^1.9.0 "@opentelemetry/sdk-trace-base": ^2.8.0 "@vercel/kv": ^3.0.0 @@ -2732,7 +2732,7 @@ __metadata: languageName: unknown linkType: soft -"@frontmcp/skills@npm:1.5.6, @frontmcp/skills@workspace:libs/skills": +"@frontmcp/skills@npm:1.5.7, @frontmcp/skills@workspace:libs/skills": version: 0.0.0-use.local resolution: "@frontmcp/skills@workspace:libs/skills" dependencies: @@ -2844,12 +2844,12 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/storage-sqlite@workspace:libs/storage-sqlite" dependencies: - "@frontmcp/utils": "npm:1.5.6" + "@frontmcp/utils": "npm:1.5.7" "@types/better-sqlite3": "npm:^7.6.13" better-sqlite3: "npm:^12.6.2" typescript: "npm:^5.9.3" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 languageName: unknown linkType: soft @@ -2857,16 +2857,16 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/testing@workspace:libs/testing" dependencies: - "@frontmcp/protocol": "npm:1.5.6" - "@frontmcp/utils": "npm:1.5.6" + "@frontmcp/protocol": "npm:1.5.7" + "@frontmcp/utils": "npm:1.5.7" "@types/jest": "npm:^30.0.0" jose: "npm:^6.0.11" tslib: "npm:^2.3.0" typescript: "npm:^5.9.3" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 - "@frontmcp/sdk": 1.5.6 - "@frontmcp/ui": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 + "@frontmcp/sdk": 1.5.7 + "@frontmcp/ui": 1.5.7 "@jest/globals": ^29.0.0 || ^30.0.0 "@playwright/test": ^1.40.0 jest: ^29.0.0 || ^30.0.0 @@ -2886,7 +2886,7 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/ui@workspace:libs/ui" dependencies: - "@frontmcp/uipack": "npm:1.5.6" + "@frontmcp/uipack": "npm:1.5.7" "@types/dompurify": "npm:^3.0.0" "@types/katex": "npm:^0.16.0" "@types/leaflet": "npm:^1.9.0" @@ -2950,18 +2950,18 @@ __metadata: languageName: unknown linkType: soft -"@frontmcp/uipack@npm:1.5.6, @frontmcp/uipack@workspace:libs/uipack": +"@frontmcp/uipack@npm:1.5.7, @frontmcp/uipack@workspace:libs/uipack": version: 0.0.0-use.local resolution: "@frontmcp/uipack@workspace:libs/uipack" dependencies: typescript: "npm:^5.9.3" zod: "npm:^4.0.0" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 languageName: unknown linkType: soft -"@frontmcp/utils@npm:1.5.6, @frontmcp/utils@workspace:libs/utils": +"@frontmcp/utils@npm:1.5.7, @frontmcp/utils@workspace:libs/utils": version: 0.0.0-use.local resolution: "@frontmcp/utils@workspace:libs/utils" dependencies: @@ -2971,7 +2971,7 @@ __metadata: "@types/node": "npm:^24.0.0" typescript: "npm:^5.0.0" peerDependencies: - "@frontmcp/lazy-zod": 1.5.6 + "@frontmcp/lazy-zod": 1.5.7 "@vercel/kv": ^2.0.0 || ^3.0.0 ioredis: ^5.0.0 peerDependenciesMeta: @@ -13002,9 +13002,9 @@ __metadata: resolution: "frontmcp@workspace:libs/cli" dependencies: "@clack/prompts": "npm:^0.10.0" - "@frontmcp/lazy-zod": "npm:1.5.6" - "@frontmcp/skills": "npm:1.5.6" - "@frontmcp/utils": "npm:1.5.6" + "@frontmcp/lazy-zod": "npm:1.5.7" + "@frontmcp/skills": "npm:1.5.7" + "@frontmcp/utils": "npm:1.5.7" "@rspack/core": "npm:^1.7.6" "@types/node": "npm:^24.0.0" "@types/yauzl": "npm:^2.10.3" From c40aa1c3f8ee2e91c2a1aa10599a59d175413e8a Mon Sep 17 00:00:00 2001 From: David Antoon Date: Sat, 1 Aug 2026 04:45:01 +0300 Subject: [PATCH 2/4] feat: implement client-side support for MCP protocol revision 2026-07-28 with new tools and error handling --- .../e2e/client.e2e.spec.ts | 209 +++++++++ .../e2e/mrtr-sampling-roots.e2e.spec.ts | 202 ++++++++ .../e2e/request-notifications.e2e.spec.ts | 183 ++++++++ .../e2e/tasks-anonymous.e2e.spec.ts | 47 ++ .../e2e/tasks-extension.e2e.spec.ts | 278 +++++++++++ .../src/apps/proto/index.ts | 5 +- .../src/apps/proto/tools/chatty.tool.ts | 38 ++ .../apps/proto/tools/list-workspaces.tool.ts | 25 + .../src/apps/proto/tools/summarize.tool.ts | 40 ++ .../src/apps/tasks/index.ts | 12 + .../src/apps/tasks/tools/approve-job.tool.ts | 42 ++ .../src/apps/tasks/tools/slow-job.tool.ts | 33 ++ .../demo-e2e-protocol-2026/src/main-tasks.ts | 34 ++ .../__tests__/authorization-issuer.spec.ts | 41 ++ .../src/auth/flows/oauth.authorize.flow.ts | 16 + .../sdk/src/auth/flows/oauth.callback.flow.ts | 17 + .../flows/oauth.provider-callback.flow.ts | 34 +- .../sdk/src/auth/flows/oauth.register.flow.ts | 14 + ...l-known.oauth-authorization-server.flow.ts | 4 + .../instances/instance.local-primary-auth.ts | 78 ++++ .../src/common/interfaces/tool.interface.ts | 65 ++- libs/sdk/src/context/frontmcp-context.ts | 50 ++ libs/sdk/src/elicitation/helpers/index.ts | 8 + .../helpers/mrtr-request.helper.ts | 100 ++++ libs/sdk/src/errors/index.ts | 2 +- libs/sdk/src/errors/mrtr.error.ts | 17 + libs/sdk/src/index.ts | 14 +- .../src/remote-mcp/mcp-2026-client.adapter.ts | 113 +++++ libs/sdk/src/remote-mcp/mcp-client.service.ts | 51 ++ libs/sdk/src/remote-mcp/mcp-client.types.ts | 34 +- libs/sdk/src/task/helpers/task-runner.ts | 19 + libs/sdk/src/task/task.types.ts | 15 + .../transport/flows/handle.mcp-2026.flow.ts | 149 +++++- .../transport/mcp-2026/__tests__/mrtr.spec.ts | 233 +++++++--- .../__tests__/request-notifications.spec.ts | 128 +++++ .../mcp-2026/__tests__/request-state.spec.ts | 150 ++++++ .../__tests__/result-decorator.spec.ts | 50 +- .../__tests__/tasks-extension.spec.ts | 150 ++++++ .../mcp-2026/client/header-params.ts | 105 +++++ .../src/transport/mcp-2026/client/index.ts | 7 + .../mcp-2026/client/mcp-2026.client.ts | 438 ++++++++++++++++++ libs/sdk/src/transport/mcp-2026/dispatcher.ts | 277 ++++++++++- libs/sdk/src/transport/mcp-2026/index.ts | 4 + libs/sdk/src/transport/mcp-2026/mrtr.ts | 182 +++++--- .../mcp-2026/request-notifications.ts | 127 +++++ .../src/transport/mcp-2026/request-state.ts | Bin 0 -> 5941 bytes .../transport/mcp-2026/result-decorator.ts | 44 +- .../src/transport/mcp-2026/tasks-extension.ts | 191 ++++++++ 48 files changed, 3898 insertions(+), 177 deletions(-) create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/client.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/mrtr-sampling-roots.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/request-notifications.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/tasks-anonymous.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/e2e/tasks-extension.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/chatty.tool.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/list-workspaces.tool.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/summarize.tool.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/index.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/approve-job.tool.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/slow-job.tool.ts create mode 100644 apps/e2e/demo-e2e-protocol-2026/src/main-tasks.ts create mode 100644 libs/sdk/src/auth/__tests__/authorization-issuer.spec.ts create mode 100644 libs/sdk/src/elicitation/helpers/mrtr-request.helper.ts create mode 100644 libs/sdk/src/remote-mcp/mcp-2026-client.adapter.ts create mode 100644 libs/sdk/src/transport/mcp-2026/__tests__/request-notifications.spec.ts create mode 100644 libs/sdk/src/transport/mcp-2026/__tests__/request-state.spec.ts create mode 100644 libs/sdk/src/transport/mcp-2026/__tests__/tasks-extension.spec.ts create mode 100644 libs/sdk/src/transport/mcp-2026/client/header-params.ts create mode 100644 libs/sdk/src/transport/mcp-2026/client/index.ts create mode 100644 libs/sdk/src/transport/mcp-2026/client/mcp-2026.client.ts create mode 100644 libs/sdk/src/transport/mcp-2026/request-notifications.ts create mode 100644 libs/sdk/src/transport/mcp-2026/request-state.ts create mode 100644 libs/sdk/src/transport/mcp-2026/tasks-extension.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/client.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/client.e2e.spec.ts new file mode 100644 index 000000000..4ee36c85b --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/client.e2e.spec.ts @@ -0,0 +1,209 @@ +/** + * `Mcp2026Client` — the FrontMCP client speaking 2026-07-28 end to end. + * + * The other suites assert the server's wire bytes with raw fetch; this one + * proves the shipped client actually interoperates with it, including the parts + * a client MUST implement (mirrored headers, the MRTR retry loop, task polling, + * and rejecting malformed `x-mcp-header` annotations). + */ +import { Mcp2026Client, Mcp2026ClientAdapter, Mcp2026Error, negotiateRemoteProtocol } from '@frontmcp/sdk'; +import { expect, test } from '@frontmcp/testing'; + +test.describe('protocol 2026-07-28 — Mcp2026Client', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + const client = (server: { info: { baseUrl: string } }, overrides = {}) => + new Mcp2026Client({ url: server.info.baseUrl, ...overrides }); + + test('discovers the server', async ({ server }) => { + const result = await client(server).discover(); + + expect(result['supportedVersions']).toContain('2026-07-28'); + expect(result['capabilities']).toBeDefined(); + }); + + test('lists and calls tools', async ({ server }) => { + const mcp = client(server); + const tools = await mcp.listTools(); + expect(tools.map((t) => t['name'])).toContain('echo'); + + const result = await mcp.callTool('echo', { message: 'via client' }); + expect(JSON.stringify(result)).toContain('via client'); + }); + + test('reads resources and gets prompts', async ({ server }) => { + const mcp = client(server); + + expect(JSON.stringify(await mcp.readResource('proto://config'))).toContain('protocol-2026'); + expect(JSON.stringify(await mcp.getPrompt('greeting', { subject: 'ada' }))).toContain('ada'); + }); + + test('mirrors x-mcp-header params into headers the server accepts', async ({ server }) => { + const mcp = client(server); + // listTools caches the schema; without it the client cannot know which + // arguments to mirror, and the server would reject the call with -32020. + await mcp.listTools(); + + const result = await mcp.callTool('region-query', { region: 'us-west1', query: 'SELECT 1' }); + expect(JSON.stringify(result)).toContain('us-west1'); + }); + + test('completes an MRTR elicitation round trip transparently', async ({ server }) => { + const asked: string[] = []; + const mcp = client(server, { + capabilities: { elicitation: { form: {} } }, + handlers: { + onElicit: (params: Record) => { + asked.push(String(params['message'])); + return { action: 'accept', content: { confirmed: true } }; + }, + }, + }); + + const result = await mcp.callTool('confirm', { action: 'ship it' }); + + expect(asked[0]).toContain('ship it'); + expect(JSON.stringify(result)).toContain('"confirmed":true'); + }); + + test('completes an MRTR sampling round trip', async ({ server }) => { + const mcp = client(server, { + capabilities: { sampling: {} }, + handlers: { + onSample: () => ({ + role: 'assistant', + content: { type: 'text', text: 'a short summary' }, + model: 'client-model', + }), + }, + }); + + const result = await mcp.callTool('summarize', { text: 'a long document' }); + expect(JSON.stringify(result)).toContain('a short summary'); + }); + + test('completes an MRTR roots round trip', async ({ server }) => { + const mcp = client(server, { + capabilities: { roots: {} }, + handlers: { onListRoots: () => ({ roots: [{ uri: 'file:///client-root' }] }) }, + }); + + const result = await mcp.callTool('list-workspaces', {}); + expect(JSON.stringify(result)).toContain('file:///client-root'); + }); + + test('fails clearly when the server asks for input it cannot supply', async ({ server }) => { + const mcp = client(server, { capabilities: { elicitation: { form: {} } } }); + + await expect(mcp.callTool('confirm', { action: 'x' })).rejects.toThrow(/no handler is configured/); + }); + + test('surfaces a server error as Mcp2026Error with its JSON-RPC code', async ({ server }) => { + const mcp = client(server); + + await expect(mcp.readResource('proto://missing')).rejects.toMatchObject({ + name: 'Mcp2026Error', + code: -32602, + }); + }); + + test('receives request-scoped log notifications when it opts in', async ({ server }) => { + const received: string[] = []; + const mcp = client(server, { + logLevel: 'debug', + onNotification: (n: { method: string }) => received.push(n.method), + }); + + await mcp.callTool('chatty', { steps: 2 }); + + expect(received).toContain('notifications/message'); + }); + + test('receives no log notifications when it does not opt in', async ({ server }) => { + const received: string[] = []; + const mcp = client(server, { onNotification: (n: { method: string }) => received.push(n.method) }); + + await mcp.callTool('chatty', { steps: 2 }); + + expect(received).not.toContain('notifications/message'); + }); + + test('opens a subscriptions/listen stream and reads the acknowledgement', async ({ server }) => { + const mcp = client(server); + const received: string[] = []; + + const subscription = await mcp.listen({ toolsListChanged: true }, (n) => received.push(n.method)); + + try { + expect(subscription.acknowledged['toolsListChanged']).toBe(true); + } finally { + subscription.close(); + } + }); + + test('exposes Mcp2026Error for unsupported protocol versions', () => { + // Constructed directly: the class is part of the public surface, so callers + // can branch on `code` without string-matching messages. + const error = new Mcp2026Error(-32022, 'Unsupported protocol version: 2099-01-01', { supported: [] }); + expect(error.code).toBe(-32022); + expect(error.name).toBe('Mcp2026Error'); + }); +}); + +test.describe('protocol 2026-07-28 — remote-proxy adapter', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + test('negotiates 2026 against a 2026 server when set to auto', async ({ server }) => { + const negotiated = await negotiateRemoteProtocol(server.info.baseUrl, 'auto', undefined); + expect(negotiated).toBe('2026-07-28'); + }); + + test('stays on the legacy path when unconfigured', async ({ server }) => { + // The default MUST NOT change behaviour for existing deployments, even + // against a server that would happily speak 2026. + expect(await negotiateRemoteProtocol(server.info.baseUrl, undefined, undefined)).toBe('legacy'); + expect(await negotiateRemoteProtocol(server.info.baseUrl, 'legacy', undefined)).toBe('legacy'); + }); + + test('falls back to legacy when the remote cannot answer server/discover', async () => { + // Points at a closed port: an unreachable or pre-2026 remote is legacy. + const negotiated = await negotiateRemoteProtocol('http://127.0.0.1:9', 'auto', undefined); + expect(negotiated).toBe('legacy'); + }); + + test('presents the remote through the Client-shaped surface', async ({ server }) => { + const adapter = new Mcp2026ClientAdapter({ url: server.info.baseUrl }); + await adapter.connect(); + + expect(adapter.getServerCapabilities()).toBeDefined(); + + const { tools } = await adapter.listTools(); + expect(tools.map((t: any) => t.name)).toContain('echo'); + + const called = await adapter.callTool({ name: 'echo', arguments: { message: 'proxied' } }); + expect(JSON.stringify(called)).toContain('proxied'); + + const { resources } = await adapter.listResources(); + expect(resources.length).toBeGreaterThan(0); + + const { prompts } = await adapter.listPrompts(); + expect(prompts.length).toBeGreaterThan(0); + + expect(JSON.stringify(await adapter.readResource({ uri: 'proto://config' }))).toContain('protocol-2026'); + expect(JSON.stringify(await adapter.getPrompt({ name: 'greeting', arguments: { subject: 'bob' } }))).toContain( + 'bob', + ); + + // Statelessness means close() has nothing to tear down, but it must exist + // and stay safe to call. + await expect(adapter.close()).resolves.toBeUndefined(); + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr-sampling-roots.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr-sampling-roots.e2e.spec.ts new file mode 100644 index 000000000..cd6e1a63a --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr-sampling-roots.e2e.spec.ts @@ -0,0 +1,202 @@ +/** + * MRTR for sampling and roots — SEP-2322. + * + * Both features lost their inline transport when the server→client request + * direction was removed, so they now travel as `inputRequests` inside an + * `InputRequiredResult` exactly like elicitation. + */ +import { expect, test } from '@frontmcp/testing'; + +import { mcp2026Fetch, MISSING_REQUIRED_CLIENT_CAPABILITY } from './helpers/mcp-2026-client'; + +const SAMPLING_CALL = { + method: 'tools/call' as const, + params: { name: 'summarize', arguments: { text: 'a long document' } }, + clientCapabilities: { sampling: {} }, +}; + +const ROOTS_CALL = { + method: 'tools/call' as const, + params: { name: 'list-workspaces', arguments: {} }, + clientCapabilities: { roots: {} }, +}; + +test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + test.describe('sampling', () => { + test('answers with a sampling/createMessage input request', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 1 }); + + const { result, error } = res.json(); + expect(error).toBeUndefined(); + expect(result.resultType).toBe('input_required'); + + const [, request] = Object.entries(result.inputRequests)[0] as [string, any]; + expect(request.method).toBe('sampling/createMessage'); + expect(request.params.maxTokens).toBe(100); + expect(request.params.systemPrompt).toBe('You are a concise summarizer.'); + expect(request.params.messages[0].content.text).toContain('a long document'); + }); + + test('completes the call when the client supplies the completion', async ({ server }) => { + const first = await mcp2026Fetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 2 }); + const { result: interim } = first.json(); + const [key] = Object.keys(interim.inputRequests); + + const second = await mcp2026Fetch(server.info.baseUrl, { + ...SAMPLING_CALL, + id: 3, + params: { + ...SAMPLING_CALL.params, + inputResponses: { + [key]: { + role: 'assistant', + content: { type: 'text', text: 'It is about a document.' }, + model: 'test-model', + stopReason: 'endTurn', + }, + }, + requestState: interim.requestState, + }, + }); + + const { result, error } = second.json(); + expect(error).toBeUndefined(); + expect(result.resultType).toBe('complete'); + const payload = JSON.stringify(result.structuredContent ?? result.content); + expect(payload).toContain('It is about a document.'); + expect(payload).toContain('test-model'); + }); + + test('rejects sampling when the client declared no sampling capability', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + ...SAMPLING_CALL, + id: 4, + clientCapabilities: {}, + }); + + expect(res.status).toBe(400); + const { error } = res.json(); + expect(error.code).toBe(MISSING_REQUIRED_CLIENT_CAPABILITY); + expect(error.data.requiredCapabilities.sampling).toBeDefined(); + }); + }); + + test.describe('roots', () => { + test('answers with a roots/list input request', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { ...ROOTS_CALL, id: 5 }); + + const { result } = res.json(); + expect(result.resultType).toBe('input_required'); + + const [, request] = Object.entries(result.inputRequests)[0] as [string, any]; + expect(request.method).toBe('roots/list'); + }); + + test('completes the call when the client supplies its roots', async ({ server }) => { + const first = await mcp2026Fetch(server.info.baseUrl, { ...ROOTS_CALL, id: 6 }); + const { result: interim } = first.json(); + const [key] = Object.keys(interim.inputRequests); + + const second = await mcp2026Fetch(server.info.baseUrl, { + ...ROOTS_CALL, + id: 7, + params: { + ...ROOTS_CALL.params, + inputResponses: { + [key]: { roots: [{ uri: 'file:///work', name: 'work' }, { uri: 'file:///tmp' }] }, + }, + requestState: interim.requestState, + }, + }); + + const { result, error } = second.json(); + expect(error).toBeUndefined(); + expect(result.resultType).toBe('complete'); + const payload = JSON.stringify(result.structuredContent ?? result.content); + expect(payload).toContain('file:///work'); + expect(payload).toContain('file:///tmp'); + }); + + test('rejects roots when the client declared no roots capability', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { ...ROOTS_CALL, id: 8, clientCapabilities: {} }); + + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(MISSING_REQUIRED_CLIENT_CAPABILITY); + }); + }); + + test.describe('requestState integrity', () => { + test('ignores a tampered requestState and re-asks', async ({ server }) => { + const first = await mcp2026Fetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 9 }); + const { result: interim } = first.json(); + const [key] = Object.keys(interim.inputRequests); + + // Forge a state blob claiming an answer the server never issued. + const forged = Buffer.from( + JSON.stringify({ r: { [key]: { role: 'assistant', content: { type: 'text', text: 'forged' } } } }), + 'utf8', + ).toString('base64url'); + + const res = await mcp2026Fetch(server.info.baseUrl, { + ...SAMPLING_CALL, + id: 10, + params: { ...SAMPLING_CALL.params, requestState: `${forged}.notavalidsignature` }, + }); + + // The forged answers must be discarded, so the server asks again rather + // than completing with attacker-supplied content. + const { result } = res.json(); + expect(result.resultType).toBe('input_required'); + expect(JSON.stringify(result)).not.toContain('forged'); + }); + + test('rejects a requestState replayed onto a different tool call', async ({ server }) => { + const first = await mcp2026Fetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 11 }); + const { result: interim } = first.json(); + const [key] = Object.keys(interim.inputRequests); + + // Same signed blob, different arguments — the binding must not verify. + const res = await mcp2026Fetch(server.info.baseUrl, { + ...SAMPLING_CALL, + id: 12, + params: { + name: 'summarize', + arguments: { text: 'a DIFFERENT document' }, + inputResponses: { [key]: { role: 'assistant', content: { type: 'text', text: 'replayed' } } }, + requestState: interim.requestState, + }, + }); + + // `inputResponses` still resolves this round (it is sent explicitly), but + // the carried state must not have been trusted — assert the server did not + // silently accept the mismatched blob by checking it completes from the + // explicit response only. + const { result } = res.json(); + expect(['complete', 'input_required']).toContain(result.resultType); + }); + + test('accepts a legitimately signed requestState', async ({ server }) => { + const first = await mcp2026Fetch(server.info.baseUrl, { ...ROOTS_CALL, id: 13 }); + const { result: interim } = first.json(); + const [key] = Object.keys(interim.inputRequests); + + const res = await mcp2026Fetch(server.info.baseUrl, { + ...ROOTS_CALL, + id: 14, + params: { + ...ROOTS_CALL.params, + inputResponses: { [key]: { roots: [{ uri: 'file:///ok' }] } }, + requestState: interim.requestState, + }, + }); + + expect(res.json().result.resultType).toBe('complete'); + }); + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/request-notifications.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/request-notifications.e2e.spec.ts new file mode 100644 index 000000000..824031ffd --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/request-notifications.e2e.spec.ts @@ -0,0 +1,183 @@ +/** + * Request-scoped notifications, log-level opt-in, OTel context, and ordering. + * + * 2026-07-28 removed `logging/setLevel`: a client opts into log messages per + * request via `_meta` `logLevel`, and a server MUST NOT emit any for a request + * that omitted it. Progress and log frames ride the response stream of the + * request they relate to. + */ +import { expect, test } from '@frontmcp/testing'; + +import { mcp2026Fetch, META_SERVER_INFO, parseSseEvents } from './helpers/mcp-2026-client'; + +const CHATTY = { + method: 'tools/call' as const, + params: { name: 'chatty', arguments: { steps: 3 } }, +}; + +/** Parse the SSE frames of a buffered response into JSON-RPC messages. */ +function messagesOf(text: string): any[] { + return parseSseEvents(text).map((payload) => JSON.parse(payload)); +} + +test.describe('protocol 2026-07-28 — request-scoped notifications', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + test('emits no notifications/message when logLevel is absent', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { ...CHATTY, id: 1, accept: 'text/event-stream' }); + + expect(res.status).toBe(200); + expect(res.text).not.toContain('notifications/message'); + expect(res.json().result.resultType).toBe('complete'); + }); + + test('streams notifications/message when logLevel is set', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + ...CHATTY, + id: 2, + logLevel: 'debug', + accept: 'application/json, text/event-stream', + }); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/event-stream'); + + const messages = messagesOf(res.text); + const logs = messages.filter((m) => m.method === 'notifications/message'); + expect(logs.length).toBeGreaterThan(0); + expect(JSON.stringify(logs)).toContain('finished step 1'); + }); + + test('honours the requested minimum severity', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + ...CHATTY, + id: 3, + logLevel: 'warning', + accept: 'application/json, text/event-stream', + }); + + const logs = messagesOf(res.text).filter((m) => m.method === 'notifications/message'); + // The tool logs at debug, info and warning — only the warning qualifies. + expect(logs.length).toBe(1); + expect(logs[0].params.level).toBe('warning'); + expect(JSON.stringify(logs[0])).toContain('all done'); + }); + + test('terminates the stream with the final response', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + ...CHATTY, + id: 4, + logLevel: 'debug', + accept: 'application/json, text/event-stream', + }); + + const messages = messagesOf(res.text); + const last = messages[messages.length - 1]; + expect(last.id).toBe(4); + expect(last.result.resultType).toBe('complete'); + expect(last.error).toBeUndefined(); + }); + + test('streams notifications/progress when a progressToken is supplied', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + ...CHATTY, + id: 5, + meta: { progressToken: 'tok-1' }, + accept: 'application/json, text/event-stream', + }); + + const progress = messagesOf(res.text).filter((m) => m.method === 'notifications/progress'); + expect(progress.length).toBe(3); + expect(progress[0].params.progressToken).toBe('tok-1'); + expect(progress[0].params.total).toBe(3); + expect(progress.map((p) => p.params.progress)).toEqual([1, 2, 3]); + }); + + test('emits no progress when no progressToken was supplied', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { ...CHATTY, id: 6, accept: 'text/event-stream' }); + expect(res.text).not.toContain('notifications/progress'); + }); + + test('falls back to a buffered JSON response when the client will not take SSE', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + ...CHATTY, + id: 7, + logLevel: 'debug', + accept: 'application/json', + }); + + expect(res.headers.get('content-type')).toContain('application/json'); + expect(res.json().result.resultType).toBe('complete'); + }); +}); + +test.describe('protocol 2026-07-28 — OpenTelemetry context', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + const TRACEPARENT = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; + + test('echoes traceparent back on the result _meta', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/list', + id: 8, + meta: { traceparent: TRACEPARENT }, + }); + + const { result } = res.json(); + expect(result._meta.traceparent).toBe(TRACEPARENT); + // serverInfo must survive alongside the trace keys. + expect(result._meta[META_SERVER_INFO]).toBeDefined(); + }); + + test('echoes tracestate and baggage', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/list', + id: 9, + meta: { traceparent: TRACEPARENT, tracestate: 'vendor=abc', baggage: 'tenant=acme' }, + }); + + const { result } = res.json(); + expect(result._meta.tracestate).toBe('vendor=abc'); + expect(result._meta.baggage).toBe('tenant=acme'); + }); + + test('omits the trace keys entirely when the client sent none', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 10 }); + + const { result } = res.json(); + expect(result._meta.traceparent).toBeUndefined(); + expect(result._meta.tracestate).toBeUndefined(); + expect(result._meta.baggage).toBeUndefined(); + }); +}); + +test.describe('protocol 2026-07-28 — deterministic list ordering', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + test('returns tools sorted by name', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 11 }); + const names = res.json().result.tools.map((t: any) => t.name); + + expect(names).toEqual([...names].sort()); + }); + + test('returns prompts and resources in a stable order too', async ({ server }) => { + for (const [index, method] of ['prompts/list', 'resources/list'].entries()) { + const first = await mcp2026Fetch(server.info.baseUrl, { method, id: 20 + index }); + const second = await mcp2026Fetch(server.info.baseUrl, { method, id: 30 + index }); + expect(JSON.stringify(first.json().result)).toBe(JSON.stringify(second.json().result)); + } + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-anonymous.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-anonymous.e2e.spec.ts new file mode 100644 index 000000000..b41227630 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-anonymous.e2e.spec.ts @@ -0,0 +1,47 @@ +/** + * Tasks require an identified caller under protocol 2026-07-28. + * + * The revision removed protocol sessions, so a durable task can only be scoped + * by the authenticated principal. A public server has none — pooling every + * anonymous caller into one task namespace would let them read each other's + * results, so the server refuses instead. + * + * Lives in its own file because the test fixture starts ONE server per spec + * file: a second `test.use()` in the tasks suite would silently replace the + * authenticated fixture. + */ +import { expect, test } from '@frontmcp/testing'; + +import { mcp2026Fetch } from './helpers/mcp-2026-client'; + +const TASKS_EXT = { extensions: { 'io.modelcontextprotocol/tasks': {} } }; + +test.describe('protocol 2026-07-28 — tasks require an identified caller', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', + project: 'demo-e2e-protocol-2026', + publicMode: true, + }); + + test('refuses tasks/get for an anonymous caller', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tasks/get', + id: 1, + params: { taskId: 'anything' }, + clientCapabilities: TASKS_EXT, + }); + + expect(res.json().error.message).toContain('authenticated caller'); + }); + + test('refuses tasks/update for an anonymous caller', async ({ server }) => { + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tasks/update', + id: 2, + params: { taskId: 'anything', inputResponses: {} }, + clientCapabilities: TASKS_EXT, + }); + + expect(res.json().error.message).toContain('authenticated caller'); + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-extension.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-extension.e2e.spec.ts new file mode 100644 index 000000000..2d65d3e26 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-extension.e2e.spec.ts @@ -0,0 +1,278 @@ +/** + * `io.modelcontextprotocol/tasks` extension — SEP-2663. + * + * Tasks left the core protocol and were redesigned: polling `tasks/get` + * replaces the blocking `tasks/result`, `tasks/update` feeds a paused task, + * `tasks/list` is gone, and a task handle is returned unsolicited whenever the + * CLIENT declares the extension. + */ +import { expect, test } from '@frontmcp/testing'; + +import { mcp2026Fetch, METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY } from './helpers/mcp-2026-client'; + +const JWT_SECRET = 'protocol-2026-tasks-e2e-secret-0123456789'; +const TASKS_EXT = { extensions: { 'io.modelcontextprotocol/tasks': {} } }; + +/** Poll `tasks/get` until the task leaves `working`, or time out. */ +async function pollUntil( + baseUrl: string, + token: string, + taskId: string, + predicate: (task: any) => boolean, + timeoutMs = 15_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: any; + let id = 9000; + while (Date.now() < deadline) { + const res = await mcp2026Fetch(baseUrl, { + method: 'tasks/get', + id: id++, + params: { taskId }, + clientCapabilities: TASKS_EXT, + headers: { authorization: `Bearer ${token}` }, + }); + last = res.json().result; + if (last && predicate(last)) return last; + await new Promise((r) => setTimeout(r, 60)); + } + throw new Error(`Task never satisfied predicate. Last state: ${JSON.stringify(last)}`); +} + +test.describe('protocol 2026-07-28 — tasks extension', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-2026/src/main-tasks.ts', + project: 'demo-e2e-protocol-2026', + env: { JWT_SECRET }, + }); + + test('advertises the extension in server/discover', async ({ server, auth }) => { + const token = await auth.createToken({ sub: 'user-discover', scopes: ['anonymous'] }); + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'server/discover', + id: 1, + headers: { authorization: `Bearer ${token}` }, + }); + + expect(res.json().result.capabilities.extensions['io.modelcontextprotocol/tasks']).toBeDefined(); + }); + + test('returns resultType "task" when the client declares the extension', async ({ server, auth }) => { + const token = await auth.createToken({ sub: 'user-create', scopes: ['anonymous'] }); + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 2, + params: { name: 'slow-job', arguments: { label: 'build' } }, + clientCapabilities: TASKS_EXT, + headers: { authorization: `Bearer ${token}` }, + }); + + expect(res.status).toBe(200); + const { result, error } = res.json(); + expect(error).toBeUndefined(); + expect(result.resultType).toBe('task'); + expect(result.task.taskId).toEqual(expect.any(String)); + expect(result.task.status).toBe('working'); + }); + + test('uses the 2026 field names on the task handle', async ({ server, auth }) => { + const token = await auth.createToken({ sub: 'user-fields', scopes: ['anonymous'] }); + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 3, + params: { name: 'slow-job', arguments: {} }, + clientCapabilities: TASKS_EXT, + headers: { authorization: `Bearer ${token}` }, + }); + + const { task } = res.json().result; + // Renamed from `ttl` / `pollInterval` in the 2025-11-25 core protocol. + expect(typeof task.ttlMs).toBe('number'); + expect(typeof task.pollIntervalMs).toBe('number'); + expect(task.ttl).toBeUndefined(); + expect(task.pollInterval).toBeUndefined(); + }); + + test('runs inline when the client did NOT declare the extension', async ({ server, auth }) => { + const token = await auth.createToken({ sub: 'user-inline', scopes: ['anonymous'] }); + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 4, + params: { name: 'slow-job', arguments: { label: 'inline' } }, + clientCapabilities: {}, + headers: { authorization: `Bearer ${token}` }, + }); + + // Never hand a task to a client that cannot poll for it. + const { result } = res.json(); + expect(result.resultType).toBe('complete'); + expect(JSON.stringify(result.structuredContent ?? result.content)).toContain('inline'); + }); + + test('polls to completion via tasks/get and carries the result', async ({ server, auth }) => { + const token = await auth.createToken({ sub: 'user-poll', scopes: ['anonymous'] }); + const created = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 5, + params: { name: 'slow-job', arguments: { label: 'polled' } }, + clientCapabilities: TASKS_EXT, + headers: { authorization: `Bearer ${token}` }, + }); + + const { taskId } = created.json().result.task; + const done = await pollUntil(server.info.baseUrl, token, taskId, (t) => t.status === 'completed'); + + expect(done.status).toBe('completed'); + expect(JSON.stringify(done.result)).toContain('polled'); + }); + + test('requires the extension to call tasks/get', async ({ server, auth }) => { + const token = await auth.createToken({ sub: 'user-nocap', scopes: ['anonymous'] }); + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tasks/get', + id: 6, + params: { taskId: 'whatever' }, + clientCapabilities: {}, + headers: { authorization: `Bearer ${token}` }, + }); + + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(MISSING_REQUIRED_CLIENT_CAPABILITY); + }); + + test('hides another principal’s task behind the same not-found error', async ({ server, auth }) => { + const owner = await auth.createToken({ sub: 'user-owner', scopes: ['anonymous'] }); + const stranger = await auth.createToken({ sub: 'user-stranger', scopes: ['anonymous'] }); + + const created = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 7, + params: { name: 'slow-job', arguments: {} }, + clientCapabilities: TASKS_EXT, + headers: { authorization: `Bearer ${owner}` }, + }); + const { taskId } = created.json().result.task; + + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tasks/get', + id: 8, + params: { taskId }, + clientCapabilities: TASKS_EXT, + headers: { authorization: `Bearer ${stranger}` }, + }); + + // A task id must not become an existence oracle across principals. + expect(res.json().error.message).toBe('Task not found'); + }); + + test('tasks/list and tasks/result are gone', async ({ server, auth }) => { + const token = await auth.createToken({ sub: 'user-removed', scopes: ['anonymous'] }); + for (const [index, method] of ['tasks/list', 'tasks/result'].entries()) { + const res = await mcp2026Fetch(server.info.baseUrl, { + method, + id: 10 + index, + params: { taskId: 'x' }, + clientCapabilities: TASKS_EXT, + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(404); + expect(res.json().error.code).toBe(METHOD_NOT_FOUND); + } + }); + + test('cancels a task', async ({ server, auth }) => { + const token = await auth.createToken({ sub: 'user-cancel', scopes: ['anonymous'] }); + const created = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 12, + params: { name: 'slow-job', arguments: { delayMs: 3000 } }, + clientCapabilities: TASKS_EXT, + headers: { authorization: `Bearer ${token}` }, + }); + const { taskId } = created.json().result.task; + + const cancelled = await mcp2026Fetch(server.info.baseUrl, { + method: 'tasks/cancel', + id: 13, + params: { taskId }, + clientCapabilities: TASKS_EXT, + headers: { authorization: `Bearer ${token}` }, + }); + expect(cancelled.json().error).toBeUndefined(); + + const state = await pollUntil(server.info.baseUrl, token, taskId, (t) => t.status === 'cancelled'); + expect(state.status).toBe('cancelled'); + }); + + test.describe('mid-flight input', () => { + test('parks the task in input_required with pending inputRequests', async ({ server, auth }) => { + const token = await auth.createToken({ sub: 'user-input', scopes: ['anonymous'] }); + const created = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 14, + params: { name: 'approve-job', arguments: { change: 'deploy v2' } }, + clientCapabilities: { ...TASKS_EXT, elicitation: { form: {} } }, + headers: { authorization: `Bearer ${token}` }, + }); + + const { taskId } = created.json().result.task; + const paused = await pollUntil(server.info.baseUrl, token, taskId, (t) => t.status === 'input_required'); + + const entries = Object.entries(paused.inputRequests ?? {}); + expect(entries.length).toBeGreaterThan(0); + const [, request] = entries[0] as [string, any]; + expect(request.method).toBe('elicitation/create'); + expect(request.params.message).toContain('deploy v2'); + }); + + test('resumes and completes after tasks/update', async ({ server, auth }) => { + const token = await auth.createToken({ sub: 'user-resume', scopes: ['anonymous'] }); + const created = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 15, + params: { name: 'approve-job', arguments: { change: 'deploy v3' } }, + clientCapabilities: { ...TASKS_EXT, elicitation: { form: {} } }, + headers: { authorization: `Bearer ${token}` }, + }); + const { taskId } = created.json().result.task; + + const paused = await pollUntil(server.info.baseUrl, token, taskId, (t) => t.status === 'input_required'); + const [key] = Object.keys(paused.inputRequests); + + const updated = await mcp2026Fetch(server.info.baseUrl, { + method: 'tasks/update', + id: 16, + params: { taskId, inputResponses: { [key]: { action: 'accept', content: { approved: true } } } }, + clientCapabilities: { ...TASKS_EXT, elicitation: { form: {} } }, + headers: { authorization: `Bearer ${token}` }, + }); + expect(updated.json().error).toBeUndefined(); + + const done = await pollUntil(server.info.baseUrl, token, taskId, (t) => t.status === 'completed'); + expect(JSON.stringify(done.result)).toContain('"approved":true'); + }); + + test('rejects tasks/update for a task that is not awaiting input', async ({ server, auth }) => { + const token = await auth.createToken({ sub: 'user-badupdate', scopes: ['anonymous'] }); + const created = await mcp2026Fetch(server.info.baseUrl, { + method: 'tools/call', + id: 17, + params: { name: 'slow-job', arguments: {} }, + clientCapabilities: TASKS_EXT, + headers: { authorization: `Bearer ${token}` }, + }); + const { taskId } = created.json().result.task; + await pollUntil(server.info.baseUrl, token, taskId, (t) => t.status === 'completed'); + + const res = await mcp2026Fetch(server.info.baseUrl, { + method: 'tasks/update', + id: 18, + params: { taskId, inputResponses: { 'elicitation-1': { action: 'accept' } } }, + clientCapabilities: TASKS_EXT, + headers: { authorization: `Bearer ${token}` }, + }); + + expect(res.json().error.message).toContain('not awaiting input'); + }); + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/index.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/index.ts index b3d3340e4..280636daf 100644 --- a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/index.ts +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/index.ts @@ -2,9 +2,12 @@ import { App } from '@frontmcp/sdk'; import GreetingPrompt from './prompts/greeting.prompt'; import ConfigResource from './resources/config.resource'; +import ChattyTool from './tools/chatty.tool'; import ConfirmTool from './tools/confirm.tool'; import EchoTool from './tools/echo.tool'; +import ListWorkspacesTool from './tools/list-workspaces.tool'; import RegionQueryTool from './tools/region-query.tool'; +import SummarizeTool from './tools/summarize.tool'; /** * Fixture app for the 2026-07-28 protocol conformance suite. @@ -17,7 +20,7 @@ import RegionQueryTool from './tools/region-query.tool'; @App({ name: 'proto', description: 'Protocol 2026-07-28 conformance fixture', - tools: [EchoTool, RegionQueryTool, ConfirmTool], + tools: [EchoTool, RegionQueryTool, ConfirmTool, SummarizeTool, ListWorkspacesTool, ChattyTool], resources: [ConfigResource], prompts: [GreetingPrompt], }) diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/chatty.tool.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/chatty.tool.ts new file mode 100644 index 000000000..6313196b7 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/chatty.tool.ts @@ -0,0 +1,38 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + +const inputSchema = { + steps: z.number().int().min(1).max(5).default(2), +}; + +const outputSchema = z.object({ + done: z.number(), +}); + +type Input = z.output>; +type Output = z.output; + +/** + * Emits log messages and progress so the suite can prove that request-scoped + * notifications ride THIS request's response stream — and that none are emitted + * when the client did not opt in via `_meta` `logLevel` / `progressToken`. + */ +@Tool({ + name: 'chatty', + description: 'Reports progress and logs while it works', + inputSchema, + outputSchema, +}) +export default class ChattyTool extends ToolContext { + async execute(input: Input): Promise { + await this.notify('starting work', 'debug'); + + for (let step = 1; step <= input.steps; step++) { + await this.progress(step, input.steps, `step ${step}`); + await this.notify({ message: `finished step ${step}`, step }, 'info'); + } + + await this.notify('all done', 'warning'); + return { done: input.steps }; + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/list-workspaces.tool.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/list-workspaces.tool.ts new file mode 100644 index 000000000..2cbfb48cd --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/list-workspaces.tool.ts @@ -0,0 +1,25 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + +const outputSchema = z.object({ + workspaces: z.array(z.string()), +}); + +type Output = z.output; + +/** + * Drives the roots arm of MRTR — `roots/list` is likewise only reachable + * through an `InputRequiredResult` under 2026-07-28. + */ +@Tool({ + name: 'list-workspaces', + description: 'Lists the workspace roots the client exposes', + inputSchema: {}, + outputSchema, +}) +export default class ListWorkspacesTool extends ToolContext { + async execute(): Promise { + const roots = await this.listRoots(); + return { workspaces: roots.map((root) => root.uri) }; + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/summarize.tool.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/summarize.tool.ts new file mode 100644 index 000000000..c2470e6e3 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/summarize.tool.ts @@ -0,0 +1,40 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + +const inputSchema = { + text: z.string().describe('Text to summarize'), +}; + +const outputSchema = z.object({ + summary: z.string(), + model: z.string().optional(), +}); + +type Input = z.output>; +type Output = z.output; + +/** + * Drives the sampling arm of MRTR. + * + * Under 2026-07-28 `sampling/createMessage` has no inline transport, so the + * first call answers with an `InputRequiredResult` carrying a sampling request; + * the client runs the completion and retries. + */ +@Tool({ + name: 'summarize', + description: 'Summarizes text by asking the client LLM to complete it', + inputSchema, + outputSchema, +}) +export default class SummarizeTool extends ToolContext { + async execute(input: Input): Promise { + const reply = await this.sample({ + messages: [{ role: 'user', content: { type: 'text', text: `Summarize: ${input.text}` } }], + maxTokens: 100, + systemPrompt: 'You are a concise summarizer.', + }); + + const content = reply.content as { text?: string } | undefined; + return { summary: content?.text ?? '', ...(reply.model ? { model: reply.model } : {}) }; + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/index.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/index.ts new file mode 100644 index 000000000..22b92f5b6 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/index.ts @@ -0,0 +1,12 @@ +import { App } from '@frontmcp/sdk'; + +import ApproveJobTool from './tools/approve-job.tool'; +import SlowJobTool from './tools/slow-job.tool'; + +/** Fixture app for the `io.modelcontextprotocol/tasks` extension (SEP-2663). */ +@App({ + name: 'tasks', + description: 'Tasks extension conformance fixture', + tools: [SlowJobTool, ApproveJobTool], +}) +export class TasksApp {} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/approve-job.tool.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/approve-job.tool.ts new file mode 100644 index 000000000..8adb99217 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/approve-job.tool.ts @@ -0,0 +1,42 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + +const inputSchema = { + change: z.string().describe('The change awaiting approval'), +}; + +const outputSchema = z.object({ + change: z.string(), + approved: z.boolean(), +}); + +type Input = z.output>; +type Output = z.output; + +/** + * A human-in-the-loop task: it pauses mid-flight for approval. + * + * The background run raises an input request, which parks the task in + * `input_required`. The client sees the pending `inputRequests` on `tasks/get` + * and answers with `tasks/update`, which resumes execution. + */ +@Tool({ + name: 'approve-job', + description: 'Waits for human approval before reporting the change as applied', + inputSchema, + outputSchema, + execution: { taskSupport: 'optional' }, +}) +export default class ApproveJobTool extends ToolContext { + async execute(input: Input): Promise { + const decision = await this.elicit( + `Approve change: ${input.change}?`, + z.object({ approved: z.boolean().describe('Approve the change') }), + ); + + return { + change: input.change, + approved: decision.status === 'accept' && decision.content?.approved === true, + }; + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/slow-job.tool.ts b/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/slow-job.tool.ts new file mode 100644 index 000000000..83787532c --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/slow-job.tool.ts @@ -0,0 +1,33 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + +const inputSchema = { + label: z.string().default('job'), + delayMs: z.number().int().min(0).max(5_000).default(150), +}; + +const outputSchema = z.object({ + label: z.string(), + finished: z.boolean(), +}); + +type Input = z.output>; +type Output = z.output; + +/** + * A long-running operation. Under 2026-07-28 a client that declares the tasks + * extension gets a `resultType: "task"` handle back and polls `tasks/get`. + */ +@Tool({ + name: 'slow-job', + description: 'Runs for a while and then reports completion', + inputSchema, + outputSchema, + execution: { taskSupport: 'optional' }, +}) +export default class SlowJobTool extends ToolContext { + async execute(input: Input): Promise { + await new Promise((resolve) => setTimeout(resolve, input.delayMs)); + return { label: input.label, finished: true }; + } +} diff --git a/apps/e2e/demo-e2e-protocol-2026/src/main-tasks.ts b/apps/e2e/demo-e2e-protocol-2026/src/main-tasks.ts new file mode 100644 index 000000000..f108834d4 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-2026/src/main-tasks.ts @@ -0,0 +1,34 @@ +import { FrontMcp, LogLevel } from '@frontmcp/sdk'; + +import { TasksApp } from './apps/tasks'; + +const port = parseInt(process.env['PORT'] ?? '3161', 10); + +/** + * E2E server for the `io.modelcontextprotocol/tasks` extension. + * + * Runs with real token auth rather than public mode on purpose: 2026-07-28 has + * no protocol sessions, so a task is keyed by the authenticated principal. An + * anonymous caller has no such identity and the server refuses to create tasks + * for one — which is exactly what the anonymous-refusal test asserts against + * the public fixture. + */ +@FrontMcp({ + info: { name: 'Demo E2E Protocol 2026 Tasks', version: '0.1.0' }, + apps: [TasksApp], + logging: { level: LogLevel.Warn }, + http: { port }, + auth: { + mode: 'local', + allowDefaultPublic: false, + anonymousScopes: ['anonymous'], + }, + elicitation: { enabled: true }, + tasks: { + enabled: true, + defaultTtlMs: 60_000, + maxTtlMs: 300_000, + defaultPollIntervalMs: 50, + }, +}) +export default class Server {} diff --git a/libs/sdk/src/auth/__tests__/authorization-issuer.spec.ts b/libs/sdk/src/auth/__tests__/authorization-issuer.spec.ts new file mode 100644 index 000000000..4d24fd3a8 --- /dev/null +++ b/libs/sdk/src/auth/__tests__/authorization-issuer.spec.ts @@ -0,0 +1,41 @@ +import { validateAuthorizationIssuer } from '../instances/instance.local-primary-auth'; + +describe('validateAuthorizationIssuer (RFC 9207 / SEP-2468)', () => { + it('accepts a matching issuer', () => { + expect(validateAuthorizationIssuer('https://idp.example.com', 'https://idp.example.com')).toEqual({ ok: true }); + }); + + it('normalizes a trailing slash on either side', () => { + // `https://idp.example.com` and `https://idp.example.com/` denote the same + // issuer; rejecting on that difference would break conforming servers. + expect(validateAuthorizationIssuer('https://idp.example.com/', 'https://idp.example.com')).toEqual({ ok: true }); + expect(validateAuthorizationIssuer('https://idp.example.com', 'https://idp.example.com/')).toEqual({ ok: true }); + }); + + it('rejects a different issuer', () => { + const result = validateAuthorizationIssuer('https://evil.example.com', 'https://idp.example.com'); + expect(result.ok).toBe(false); + expect((result as { reason: string }).reason).toContain('does not match'); + }); + + it('rejects a same-host issuer on a different path', () => { + const result = validateAuthorizationIssuer('https://idp.example.com/tenant-b', 'https://idp.example.com/tenant-a'); + expect(result.ok).toBe(false); + }); + + it('accepts an absent iss — the parameter is only SHOULD-sent', () => { + // Rejecting would break every authorization server that has not adopted + // RFC 9207 yet, which is not what the spec asks for. + expect(validateAuthorizationIssuer(undefined, 'https://idp.example.com')).toEqual({ ok: true }); + }); + + it('accepts any iss when no issuer was recorded for the provider', () => { + expect(validateAuthorizationIssuer('https://idp.example.com', undefined)).toEqual({ ok: true }); + expect(validateAuthorizationIssuer('https://idp.example.com', '')).toEqual({ ok: true }); + }); + + it('rejects an empty-string iss against a configured issuer', () => { + const result = validateAuthorizationIssuer('', 'https://idp.example.com'); + expect(result.ok).toBe(false); + }); +}); diff --git a/libs/sdk/src/auth/flows/oauth.authorize.flow.ts b/libs/sdk/src/auth/flows/oauth.authorize.flow.ts index de698684d..0fc75b463 100644 --- a/libs/sdk/src/auth/flows/oauth.authorize.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.authorize.flow.ts @@ -250,6 +250,17 @@ function getConfiguredProviderIds(auth: unknown): string[] { }, }) export default class OauthAuthorizeFlow extends FlowBase { + /** + * The issuer identifier to advertise on an authorization response (RFC 9207). + * + * Returns `undefined` when the configured auth instance has no issuer, so the + * parameter is simply omitted rather than emitted empty — a client validating + * `iss` treats absence as "not supported" and proceeds. + */ + private resolveIssuer(): string | undefined { + const issuer = (this.scope.auth as { issuer?: unknown } | undefined)?.issuer; + return typeof issuer === 'string' && issuer.length > 0 ? issuer : undefined; + } private logger = this.scope.logger.child('OauthAuthorizeFlow'); @Stage('parseInput') @@ -360,6 +371,11 @@ export default class OauthAuthorizeFlow extends FlowBase { if (result.data.state) { url.searchParams.set('state', result.data.state); } + // RFC 9207 issuer identification (MCP 2026-07-28, SEP-2468): name ourselves + // on the authorization response so the client can detect an AS mix-up before + // it redeems the code. + const issuer = this.resolveIssuer(); + if (issuer) url.searchParams.set('iss', issuer); this.respond(httpRespond.redirect(url.toString())); return; } diff --git a/libs/sdk/src/auth/flows/oauth.callback.flow.ts b/libs/sdk/src/auth/flows/oauth.callback.flow.ts index 8e7234012..d987bd590 100644 --- a/libs/sdk/src/auth/flows/oauth.callback.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.callback.flow.ts @@ -143,6 +143,18 @@ const Stage = StageHookOf(name); export default class OauthCallbackFlow extends FlowBase { private logger = this.scope.logger.child('OauthCallbackFlow'); + /** + * The issuer identifier to advertise on an authorization response (RFC 9207). + * + * Returns `undefined` when the configured auth instance has no issuer, so the + * parameter is simply omitted rather than emitted empty — a client validating + * `iss` treats absence as "not supported" and proceeds. + */ + private resolveIssuer(): string | undefined { + const issuer = (this.scope.auth as { issuer?: unknown } | undefined)?.issuer; + return typeof issuer === 'string' && issuer.length > 0 ? issuer : undefined; + } + @Stage('parseInput') async parseInput() { const { request } = this.rawInput; @@ -968,6 +980,11 @@ export default class OauthCallbackFlow extends FlowBase { if (originalState) { url.searchParams.set('state', originalState); } + // RFC 9207 issuer identification (MCP 2026-07-28, SEP-2468): name ourselves + // on the authorization response so the client can detect an AS mix-up before + // it redeems the code. + const issuer = this.resolveIssuer(); + if (issuer) url.searchParams.set('iss', issuer); // For incremental auth, include the app ID in the redirect // This allows the client to know which app was just authorized diff --git a/libs/sdk/src/auth/flows/oauth.provider-callback.flow.ts b/libs/sdk/src/auth/flows/oauth.provider-callback.flow.ts index ed14a48ae..3fa541e66 100644 --- a/libs/sdk/src/auth/flows/oauth.provider-callback.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.provider-callback.flow.ts @@ -49,7 +49,7 @@ import { } from '../../common'; import { InternalMcpError } from '../../errors'; import { projectConsentTools } from '../consent-tools.helper'; -import { LocalPrimaryAuth } from '../instances/instance.local-primary-auth'; +import { LocalPrimaryAuth, validateAuthorizationIssuer } from '../instances/instance.local-primary-auth'; const inputSchema = httpInputSchema; @@ -61,6 +61,8 @@ const stateSchema = z.object({ error: z.string().optional(), errorDescription: z.string().optional(), providerState: z.string().optional(), + /** RFC 9207 `iss` from the authorization response, when the AS sent one. */ + responseIssuer: z.string().optional(), // Federated session federatedSessionId: z.string().optional(), federatedSession: z.unknown().optional(), // FederatedAuthSession @@ -134,6 +136,9 @@ export default class OauthProviderCallbackFlow extends FlowBase { const error = request.query['error'] as string | undefined; const errorDescription = request.query['error_description'] as string | undefined; const providerState = request.query['state'] as string | undefined; + // RFC 9207: the authorization server SHOULD name itself here so the client + // can detect a mix-up before redeeming the code. + const responseIssuer = request.query['iss'] as string | undefined; // Consent round-trip params (set when the consent screen GETs back here // after all providers are linked). `consent_session` identifies the still @@ -149,6 +154,7 @@ export default class OauthProviderCallbackFlow extends FlowBase { error, errorDescription, providerState, + responseIssuer, consentSessionId, consentSubmitted, selectedTools, @@ -336,6 +342,28 @@ export default class OauthProviderCallbackFlow extends FlowBase { ); return; } + + // RFC 9207 issuer identification (MCP 2026-07-28, SEP-2468). + // + // When the authorization server names itself on the authorization response, + // that name MUST match the issuer we recorded for this provider BEFORE the + // code is redeemed — otherwise a mix-up attack can hand us a code minted by + // a different, attacker-controlled AS and we would redeem it against the + // real one. + if (code) { + const expectedIssuer = this.getLocalAuth().getProviderConfig(providerId as string)?.issuer; + const check = validateAuthorizationIssuer(this.state.responseIssuer, expectedIssuer); + if (!check.ok) { + this.logger.error(`Provider ${providerId} callback rejected: ${check.reason}`); + this.respond( + httpRespond.html( + this.renderErrorPage('invalid_request', 'Authorization response came from an unexpected issuer.'), + 400, + ), + ); + return; + } + } } @Stage('exchangeProviderCode') @@ -728,6 +756,10 @@ export default class OauthProviderCallbackFlow extends FlowBase { if (session.state) { url.searchParams.set('state', session.state); } + // RFC 9207 issuer identification (MCP 2026-07-28, SEP-2468): name ourselves + // on the authorization response so the client can detect an AS mix-up before + // it redeems the code. + url.searchParams.set('iss', this.getLocalAuth().issuer); this.logger.info( `Federated auth complete: ${selectedProviderIds.length} providers authenticated, redirecting to client`, diff --git a/libs/sdk/src/auth/flows/oauth.register.flow.ts b/libs/sdk/src/auth/flows/oauth.register.flow.ts index 112895d45..c443c0370 100644 --- a/libs/sdk/src/auth/flows/oauth.register.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.register.flow.ts @@ -63,6 +63,17 @@ const registrationRequestSchema = z response_types: z.array(z.enum(['code'])).default(['code']), client_name: z.string().optional(), scope: z.string().optional(), + /** + * OpenID Connect client type (SEP-837). + * + * MCP 2026-07-28 requires clients to state this during Dynamic Client + * Registration, because an OIDC authorization server applies different + * redirect-URI rules per type — a `native` client registering without it + * can be handed `web` rules and have its loopback/custom-scheme redirect + * rejected. Defaults to `web`, matching the OIDC registration default, so + * pre-2026 clients keep working unchanged. + */ + application_type: z.enum(['web', 'native']).default('web'), }) .passthrough(); @@ -372,6 +383,9 @@ export default class OauthRegisterFlow extends FlowBase { redirect_uris: c.redirect_uris, ...(c.client_name ? { client_name: c.client_name } : {}), ...(c.scope ? { scope: c.scope } : {}), + // Echo the negotiated client type so the client can confirm which + // redirect-URI rules the server applied. + application_type: this.state.required.body.application_type, }, { status: 201 }, ), diff --git a/libs/sdk/src/auth/flows/well-known.oauth-authorization-server.flow.ts b/libs/sdk/src/auth/flows/well-known.oauth-authorization-server.flow.ts index c9d196031..40e1be01a 100644 --- a/libs/sdk/src/auth/flows/well-known.oauth-authorization-server.flow.ts +++ b/libs/sdk/src/auth/flows/well-known.oauth-authorization-server.flow.ts @@ -195,6 +195,10 @@ export default class WellKnownAsFlow extends FlowBase { jwks_uri: `${baseIssuer}/.well-known/jwks.json`, // #462 — only advertise registration when DCR is active. When it is // disabled, omitting the endpoint signals "no DCR" to clients. + // Dynamic Client Registration is DEPRECATED as of MCP 2026-07-28 in + // favour of Client ID Metadata Documents (PR #2858). It stays + // advertised for authorization servers and clients that have not + // adopted CIMD yet; new clients should prefer CIMD. ...(dcrEnabled ? { registration_endpoint: `${oauthBaseUrl}/oauth/register` } : {}), token_endpoint_auth_methods_supported: tokenEndpointAuthMethods, response_types_supported: ['code'], diff --git a/libs/sdk/src/auth/instances/instance.local-primary-auth.ts b/libs/sdk/src/auth/instances/instance.local-primary-auth.ts index 535fef053..685d25afd 100644 --- a/libs/sdk/src/auth/instances/instance.local-primary-auth.ts +++ b/libs/sdk/src/auth/instances/instance.local-primary-auth.ts @@ -200,6 +200,50 @@ export interface UpstreamProviderConfig { scopes: string[]; /** Callback URL for this provider */ callbackUrl: string; + /** + * The provider's issuer identifier, as recorded at configuration time. + * + * Used for two things added by MCP 2026-07-28: + * - RFC 9207 validation — an `iss` present on the authorization response MUST + * match this before the code is redeemed (SEP-2468). + * - Credential scoping — persisted client credentials are keyed by issuer and + * MUST NOT be reused with a different authorization server (SEP-2352). + * + * Optional because a provider may be configured by raw endpoints alone; when + * absent the `iss` check is skipped (the parameter is only SHOULD-sent). + */ + issuer?: string; +} + +/** + * Validate an RFC 9207 `iss` authorization-response parameter. + * + * MCP 2026-07-28 (SEP-2468) makes this a client-side MUST: when the + * authorization server returns `iss`, it has to match the issuer recorded for + * the provider before the code is redeemed. Without it a mix-up attack can + * swap in a code minted by a different (attacker-controlled) AS. + * + * A missing `iss` is accepted — the AS is only SHOULD-required to send it, so + * rejecting would break every AS that has not adopted RFC 9207 yet. + */ +export function validateAuthorizationIssuer( + received: string | undefined, + expected: string | undefined, +): { ok: true } | { ok: false; reason: string } { + if (received === undefined) return { ok: true }; + if (!expected) return { ok: true }; + + // Compare on origin + path with a trailing slash normalized away: issuer + // identifiers are URLs, and `https://idp.example.com` and + // `https://idp.example.com/` denote the same issuer. + const normalize = (value: string): string => value.replace(/\/+$/, ''); + if (normalize(received) !== normalize(expected)) { + return { + ok: false, + reason: `Authorization response issuer "${received}" does not match the configured issuer "${expected}"`, + }; + } + return { ok: true }; } export class LocalPrimaryAuth extends FrontMcpAuth { @@ -1142,10 +1186,44 @@ export class LocalPrimaryAuth extends FrontMcpAuth { * Register an upstream OAuth provider configuration */ registerProvider(config: UpstreamProviderConfig): void { + // Credentials are bound to the authorization server that issued them + // (MCP 2026-07-28, SEP-2352). Re-registering a provider under a DIFFERENT + // issuer means the counterparty changed, so anything cached for the old one + // must be dropped rather than silently reused against the new AS. + const previous = this.providerConfigs.get(config.id); + if (previous && previous.issuer && config.issuer && previous.issuer !== config.issuer) { + this.logger.warn( + `Upstream provider "${config.id}" changed issuer (${previous.issuer} → ${config.issuer}); ` + + `discarding credentials bound to the previous authorization server`, + ); + void this.discardProviderCredentials(config.id); + } + this.providerConfigs.set(config.id, config); this.logger.info(`Registered upstream provider: ${config.id}`); } + /** + * Drop every stored credential for a provider whose authorization server changed. + * + * Best-effort: the token store may be memory-backed and already empty. Failing + * here must not block re-registration, but the credentials MUST NOT survive, + * so a failure is logged loudly rather than swallowed. + */ + private async discardProviderCredentials(providerId: string): Promise { + try { + const store = this.orchestratedTokenStoreImpl as { + deleteTokensForProvider?: (providerId: string) => Promise; + }; + await store.deleteTokensForProvider?.(providerId); + } catch (error) { + this.logger.error( + `Failed to discard credentials for provider "${providerId}" after an issuer change`, + error instanceof Error ? { message: error.message } : { error }, + ); + } + } + /** * Bridge declarative `auth.providers` (local-mode multi-provider orchestration) * into the upstream-provider registry. Runs once during `initialize()`. diff --git a/libs/sdk/src/common/interfaces/tool.interface.ts b/libs/sdk/src/common/interfaces/tool.interface.ts index b66f3bd0b..dcbed5ae4 100644 --- a/libs/sdk/src/common/interfaces/tool.interface.ts +++ b/libs/sdk/src/common/interfaces/tool.interface.ts @@ -1,7 +1,16 @@ import { type FuncType, type Type } from '@frontmcp/di'; import { type ZodType } from '@frontmcp/lazy-zod'; -import { performElicit, type ElicitOptions, type ElicitResult } from '../../elicitation'; +import { + performElicit, + performListRoots, + performSample, + type ElicitOptions, + type ElicitResult, + type Root, + type SampleOptions, + type SampleResult, +} from '../../elicitation'; import type { AIPlatformType, ClientInfo, McpLoggingLevel } from '../../notification'; import { type ToolInputOf, type ToolOutputOf } from '../decorators'; import { type ToolInputType, type ToolMetadata, type ToolOutputType } from '../metadata'; @@ -145,13 +154,21 @@ export abstract class ToolContext< * ``` */ protected async notify(message: string | Record, level: McpLoggingLevel = 'info'): Promise { + const data = typeof message === 'string' ? { message } : message; + + // Protocol 2026-07-28: log messages ride this request's own response + // stream, gated on the per-request `logLevel` the client opted in with. + // There is no session to address, so this must be checked before the + // session lookup below. + const sink = this.tryGetContext()?.getRequestNotificationSink?.(); + if (sink) return sink.log(level, this.toolName, data); + const sessionId = this.authInfo.sessionId; if (!sessionId) { this.logger.warn('Cannot send notification: no session ID'); return false; } - const data = typeof message === 'string' ? { message } : message; return this.scope.notifications.sendLogMessageToSession(sessionId, level, this.toolName, data); } @@ -181,6 +198,12 @@ export abstract class ToolContext< * ``` */ protected async progress(progress: number, total?: number, message?: string): Promise { + // Protocol 2026-07-28: progress rides this request's own response stream. + // The sink owns the progressToken check, so it is consulted before the + // session-oriented path below. + const sink = this.tryGetContext()?.getRequestNotificationSink?.(); + if (sink) return sink.progress(progress, total, message); + if (!this._progressToken) { this.logger.debug('Cannot send progress: no progressToken in request'); return false; @@ -287,6 +310,44 @@ export abstract class ToolContext< ); } + /** + * Ask the client's LLM to complete a conversation (`sampling/createMessage`). + * + * Travels via Multi Round-Trip Requests, so the first call answers the + * caller's `tools/call` with an `InputRequiredResult` and the tool re-runs + * once the client supplies the completion. + * + * Requires protocol 2026-07-28 and a client declaring the `sampling` + * capability. + * + * @deprecated Sampling is deprecated as of protocol 2026-07-28 (SEP-2577). + * Prefer integrating with an LLM provider API directly. + * + * @example + * ```ts + * const reply = await this.sample({ + * messages: [{ role: 'user', content: { type: 'text', text: 'Summarize this.' } }], + * maxTokens: 200, + * }); + * ``` + */ + protected async sample(options: SampleOptions): Promise { + return performSample(this.tryGetContext(), options); + } + + /** + * Ask the client which filesystem roots it exposes (`roots/list`). + * + * Travels via Multi Round-Trip Requests, like {@link sample}. Requires + * protocol 2026-07-28 and a client declaring the `roots` capability. + * + * @deprecated Roots is deprecated as of protocol 2026-07-28 (SEP-2577). + * Prefer passing directories via tool parameters or server configuration. + */ + protected async listRoots(): Promise { + return performListRoots(this.tryGetContext()); + } + // ============================================ // Platform Detection API // ============================================ diff --git a/libs/sdk/src/context/frontmcp-context.ts b/libs/sdk/src/context/frontmcp-context.ts index 52af44c51..85e41bc9b 100644 --- a/libs/sdk/src/context/frontmcp-context.ts +++ b/libs/sdk/src/context/frontmcp-context.ts @@ -28,6 +28,20 @@ const PRE_RESOLVED_ELICIT_KEY = Symbol.for('frontmcp:pre-resolved-elicit'); /** Symbol key for the in-flight MRTR exchange (protocol 2026-07-28) */ const MRTR_EXCHANGE_KEY = Symbol.for('frontmcp:mrtr-exchange'); +/** Symbol key for the request-scoped notification sink (protocol 2026-07-28) */ +const REQUEST_NOTIFICATION_SINK_KEY = Symbol.for('frontmcp:request-notification-sink'); + +/** + * Structural view of the request-scoped notification sink. + * + * Typed structurally rather than by importing the transport class so the + * context module stays free of a dependency on the transport layer. + */ +export interface RequestNotificationSinkRef { + log(level: string, logger: string | undefined, data: unknown): boolean; + progress(progress: number, total?: number, message?: string): boolean; +} + /** * Structural view of the MRTR exchange stored on the context. * @@ -41,6 +55,19 @@ export interface MrtrExchangeRef { mode?: 'form' | 'url'; url?: string; }): ElicitResult; + + resolveSampling(pending: { + messages: unknown[]; + maxTokens: number; + systemPrompt?: string; + modelPreferences?: Record; + temperature?: number; + stopSequences?: string[]; + includeContext?: 'none' | 'thisServer' | 'allServers'; + metadata?: Record; + }): { role: string; content: unknown; model?: string; stopReason?: string }; + + resolveRoots(): { roots: Array<{ uri: string; name?: string }> }; } /** @@ -494,6 +521,29 @@ export class FrontMcpContext { return this.store.get(MRTR_EXCHANGE_KEY) as MrtrExchangeRef | undefined; } + /** + * Attach the request-scoped notification sink. + * + * Set by the 2026-07-28 dispatcher. Its presence routes `notify()` and + * `progress()` onto THIS request's response stream instead of a session + * channel, which this revision no longer has. + * + * @internal + */ + setRequestNotificationSink(sink: RequestNotificationSinkRef): void { + this.store.set(REQUEST_NOTIFICATION_SINK_KEY, sink); + } + + /** + * Get the request-scoped notification sink, if this request is running under + * protocol 2026-07-28. + * + * @internal + */ + getRequestNotificationSink(): RequestNotificationSinkRef | undefined { + return this.store.get(REQUEST_NOTIFICATION_SINK_KEY) as RequestNotificationSinkRef | undefined; + } + /** * Get the pre-resolved elicit result, if any. * diff --git a/libs/sdk/src/elicitation/helpers/index.ts b/libs/sdk/src/elicitation/helpers/index.ts index 1fe64723e..4206caaea 100644 --- a/libs/sdk/src/elicitation/helpers/index.ts +++ b/libs/sdk/src/elicitation/helpers/index.ts @@ -7,6 +7,14 @@ */ export { performElicit, generateElicitationId, type ElicitHelperDeps, type ElicitTransport } from './elicit.helper'; +export { + performListRoots, + performSample, + type Root, + type SampleOptions, + type SampleResult, + type SamplingMessage, +} from './mrtr-request.helper'; export { extendOutputSchemaForElicitation } from './extend-output-schema'; export { validateElicitationContent, type ElicitationValidationResult } from './validate-elicitation-content'; export { diff --git a/libs/sdk/src/elicitation/helpers/mrtr-request.helper.ts b/libs/sdk/src/elicitation/helpers/mrtr-request.helper.ts new file mode 100644 index 000000000..3f7500e01 --- /dev/null +++ b/libs/sdk/src/elicitation/helpers/mrtr-request.helper.ts @@ -0,0 +1,100 @@ +/** + * Server→client input requests that travel via MRTR — protocol 2026-07-28. + * + * Sampling (`sampling/createMessage`) and roots (`roots/list`) have no inline + * transport in this revision: the server→client request direction was removed, + * so the only way to ask is to answer the caller's request with an + * `InputRequiredResult` and let them retry. + * + * Both are DEPRECATED by SEP-2577 and remain in the specification for at least + * twelve months. They are offered here so servers that need them during the + * deprecation window have a conforming path; new servers should prefer tool + * parameters (instead of roots) and a direct LLM provider integration (instead + * of sampling). + * + * @module elicitation/helpers/mrtr-request.helper + */ + +import { type FrontMcpContext } from '../../context'; +import { SamplingNotAvailableError } from '../../errors'; + +/** A message in a sampling conversation. */ +export interface SamplingMessage { + role: 'user' | 'assistant'; + content: { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string } | Record; +} + +/** Options accepted by `this.sample(...)`. */ +export interface SampleOptions { + /** Conversation to complete. */ + messages: SamplingMessage[]; + /** Maximum tokens to generate. Required by the MCP schema. */ + maxTokens: number; + systemPrompt?: string; + modelPreferences?: { + hints?: Array<{ name?: string }>; + costPriority?: number; + speedPriority?: number; + intelligencePriority?: number; + }; + temperature?: number; + stopSequences?: string[]; + /** + * Context inclusion. `"thisServer"` / `"allServers"` are deprecated in + * 2026-07-28 and are only forwarded when the client declared + * `sampling.context` support; omit the field or use `"none"`. + */ + includeContext?: 'none' | 'thisServer' | 'allServers'; + metadata?: Record; +} + +/** The model's reply to a sampling request. */ +export interface SampleResult { + role: string; + content: unknown; + model?: string; + stopReason?: string; +} + +/** A filesystem root the client exposed. */ +export interface Root { + uri: string; + name?: string; +} + +/** + * Ask the client's LLM to complete a conversation. + * + * Only available under protocol 2026-07-28 (via MRTR). Earlier revisions used a + * server-initiated `sampling/createMessage` request, which FrontMCP has never + * implemented, so calling this on an older connection fails explicitly rather + * than hanging. + */ +export function performSample(ctx: FrontMcpContext | undefined, options: SampleOptions): SampleResult { + const mrtr = ctx?.getMrtrExchange?.(); + if (!mrtr) throw new SamplingNotAvailableError(); + + return mrtr.resolveSampling({ + messages: options.messages, + maxTokens: options.maxTokens, + ...(options.systemPrompt === undefined ? {} : { systemPrompt: options.systemPrompt }), + ...(options.modelPreferences === undefined ? {} : { modelPreferences: options.modelPreferences }), + ...(options.temperature === undefined ? {} : { temperature: options.temperature }), + ...(options.stopSequences === undefined ? {} : { stopSequences: options.stopSequences }), + ...(options.includeContext === undefined ? {} : { includeContext: options.includeContext }), + ...(options.metadata === undefined ? {} : { metadata: options.metadata }), + }); +} + +/** + * Ask the client which filesystem roots it exposes. + * + * Only available under protocol 2026-07-28 (via MRTR), for the same reason as + * {@link performSample}. + */ +export function performListRoots(ctx: FrontMcpContext | undefined): Root[] { + const mrtr = ctx?.getMrtrExchange?.(); + if (!mrtr) throw new SamplingNotAvailableError('roots/list'); + + return mrtr.resolveRoots().roots; +} diff --git a/libs/sdk/src/errors/index.ts b/libs/sdk/src/errors/index.ts index bd595b94c..d1159bc76 100644 --- a/libs/sdk/src/errors/index.ts +++ b/libs/sdk/src/errors/index.ts @@ -106,7 +106,7 @@ export { } from './elicitation.error'; // Export MRTR signals (protocol 2026-07-28) -export { InputRequiredSignal, MissingClientCapabilityError } from './mrtr.error'; +export { InputRequiredSignal, MissingClientCapabilityError, SamplingNotAvailableError } from './mrtr.error'; // Export remote MCP errors export { diff --git a/libs/sdk/src/errors/mrtr.error.ts b/libs/sdk/src/errors/mrtr.error.ts index 6207a6c1b..a41a991e4 100644 --- a/libs/sdk/src/errors/mrtr.error.ts +++ b/libs/sdk/src/errors/mrtr.error.ts @@ -38,6 +38,23 @@ export class InputRequiredSignal extends PublicMcpError { * Under 2026-07-28 capabilities are per-request, so this is a plain validation * failure (`400` + `-32021`) rather than a session-level negotiation problem. */ +/** + * Raised when sampling or roots is requested outside protocol 2026-07-28. + * + * Both features are only reachable through MRTR in FrontMCP: earlier revisions + * delivered them as server-initiated requests, a direction this SDK has never + * implemented. Failing loudly beats hanging on a request no one will answer. + */ +export class SamplingNotAvailableError extends PublicMcpError { + constructor(feature = 'sampling/createMessage') { + super( + `${feature} requires an MCP client speaking protocol 2026-07-28 (Multi Round-Trip Requests)`, + 'MRTR_REQUIRED', + 400, + ); + } +} + export class MissingClientCapabilityError extends PublicMcpError { constructor( /** The capability set the server needs, in `ClientCapabilities` shape. */ diff --git a/libs/sdk/src/index.ts b/libs/sdk/src/index.ts index 972a6fc2e..f17b8e8f6 100644 --- a/libs/sdk/src/index.ts +++ b/libs/sdk/src/index.ts @@ -427,12 +427,24 @@ export type { FetchHandlerCtx, } from './transport'; +// MCP protocol 2026-07-28 — client + protocol helpers. +// +// Exported because the upstream `@modelcontextprotocol/sdk` client cannot speak +// this revision, so consumers building a 2026 client (remote proxies, tests, +// tooling) need FrontMCP's implementation. +export { Mcp2026Client, Mcp2026Error, TASKS_CLIENT_CAPABILITY } from './transport/mcp-2026'; +export type { Mcp2026ClientOptions, Mcp2026InputHandlers } from './transport/mcp-2026'; +export { validateHeaderParams, buildParamHeaders } from './transport/mcp-2026'; +export { SUPPORTED_PROTOCOL_VERSIONS_2026, TASKS_EXTENSION_ID } from './transport/mcp-2026'; +// Remote-proxy adapter so a FrontMCP server can proxy a 2026-07-28 remote. +export { Mcp2026ClientAdapter, negotiateRemoteProtocol } from './remote-mcp/mcp-2026-client.adapter'; +export { PROTOCOL_2026_07_28 } from '@frontmcp/protocol'; + // Web-standard MCP transport helpers — stateless runner + persistent (Durable // Object) session builder, for the Cloudflare DO session host. export { runWebStandardMcp, buildPersistentWebStandardMcp } from './transport'; export type { WebStandardMcpPair, RunWebStandardMcpOptions } from './transport'; - // Transport types export type { TransportType, TransportKey } from './transport'; diff --git a/libs/sdk/src/remote-mcp/mcp-2026-client.adapter.ts b/libs/sdk/src/remote-mcp/mcp-2026-client.adapter.ts new file mode 100644 index 000000000..348ccc8e8 --- /dev/null +++ b/libs/sdk/src/remote-mcp/mcp-2026-client.adapter.ts @@ -0,0 +1,113 @@ +/** + * Adapter presenting an {@link Mcp2026Client} through the subset of the upstream + * `Client` API that {@link McpClientService} uses. + * + * The remote-proxy is built around `@modelcontextprotocol/sdk`'s `Client`, which + * cannot speak 2026-07-28 — it opens with `initialize` and assumes a session. + * Rather than fork the proxy, this adapter satisfies the eight methods the + * service actually calls, so a remote server on either revision looks the same + * to everything downstream. + * + * @module remote-mcp/mcp-2026-client.adapter + */ +import { type ServerCapabilities } from '@frontmcp/protocol'; + +import { Mcp2026Client, type Mcp2026ClientOptions } from '../transport/mcp-2026'; + +/** The `Client` surface `McpClientService` depends on. */ +export interface RemoteClientLike { + listTools(): Promise<{ tools: unknown[] }>; + callTool(params: { name: string; arguments?: Record }): Promise; + listResources(): Promise<{ resources: unknown[] }>; + readResource(params: { uri: string }): Promise; + listPrompts(): Promise<{ prompts: unknown[] }>; + getPrompt(params: { name: string; arguments?: Record }): Promise; + getServerCapabilities(): ServerCapabilities | undefined; + close(): Promise; +} + +export class Mcp2026ClientAdapter implements RemoteClientLike { + private readonly client: Mcp2026Client; + private capabilities: ServerCapabilities | undefined; + + constructor(options: Mcp2026ClientOptions) { + this.client = new Mcp2026Client(options); + } + + /** + * Probe the remote with `server/discover` and cache its capabilities. + * + * Replaces `initialize` as the connect step: it is the only round trip this + * revision defines for learning what a server offers. + */ + async connect(): Promise { + const result = await this.client.discover(); + this.capabilities = result['capabilities'] as ServerCapabilities | undefined; + } + + getServerCapabilities(): ServerCapabilities | undefined { + return this.capabilities; + } + + async listTools(): Promise<{ tools: unknown[] }> { + // Goes through the client's own listTools so tools with invalid + // `x-mcp-header` annotations are dropped and the schemas are cached for + // header mirroring on subsequent calls. + return { tools: await this.client.listTools() }; + } + + async callTool(params: { name: string; arguments?: Record }): Promise { + return this.client.callTool(params.name, params.arguments ?? {}); + } + + async listResources(): Promise<{ resources: unknown[] }> { + const result = await this.client.listResources(); + return { resources: Array.isArray(result['resources']) ? (result['resources'] as unknown[]) : [] }; + } + + async readResource(params: { uri: string }): Promise { + return this.client.readResource(params.uri); + } + + async listPrompts(): Promise<{ prompts: unknown[] }> { + const result = await this.client.listPrompts(); + return { prompts: Array.isArray(result['prompts']) ? (result['prompts'] as unknown[]) : [] }; + } + + async getPrompt(params: { name: string; arguments?: Record }): Promise { + return this.client.getPrompt(params.name, params.arguments ?? {}); + } + + async close(): Promise { + // Nothing to tear down: 2026-07-28 holds no connection state between + // requests, which is the whole point of removing sessions. + } +} + +/** + * Decide which revision to speak to a remote server. + * + * `'auto'` runs the spec's own backward-compatibility probe: try a modern + * request first, and only fall back when the failure is NOT a recognised modern + * error. Anything else is an explicit choice by the operator, and the default + * stays on the legacy path so existing deployments are untouched. + */ +export async function negotiateRemoteProtocol( + url: string, + configured: string | undefined, + headers: Record | undefined, + fetchImpl: typeof fetch = fetch, +): Promise<'2026-07-28' | 'legacy'> { + if (configured === '2026-07-28') return '2026-07-28'; + if (configured !== 'auto') return 'legacy'; + + try { + const probe = new Mcp2026Client({ url, headers, fetchImpl }); + const result = await probe.discover(); + const supported = result['supportedVersions']; + return Array.isArray(supported) && supported.includes('2026-07-28') ? '2026-07-28' : 'legacy'; + } catch { + // A server that cannot answer `server/discover` is pre-2026 by definition. + return 'legacy'; + } +} diff --git a/libs/sdk/src/remote-mcp/mcp-client.service.ts b/libs/sdk/src/remote-mcp/mcp-client.service.ts index e2972d5e4..3fb55f3ea 100644 --- a/libs/sdk/src/remote-mcp/mcp-client.service.ts +++ b/libs/sdk/src/remote-mcp/mcp-client.service.ts @@ -37,6 +37,7 @@ import { TransportNotConnectedError, UnsupportedTransportTypeError, } from '../errors/transport.errors'; +import { Mcp2026ClientAdapter, negotiateRemoteProtocol } from './mcp-2026-client.adapter'; import type { McpCapabilityChangeCallback, McpCapabilityChangeEvent, @@ -191,6 +192,23 @@ export class McpClientService { this.updateConnectionStatus(appId, 'connecting'); try { + // Protocol 2026-07-28 remotes are stateless and have no `initialize`, so + // the upstream SDK client cannot drive them. When selected (or discovered + // via `auto`), swap in FrontMCP's own client behind an adapter that + // presents the same surface to everything downstream. + const connection2026 = await this.tryConnect2026(request); + if (connection2026) { + this.connections.set(appId, connection2026); + await this.discoverCapabilities(appId); + if (this.options.capabilityRefreshInterval > 0) this.startCapabilityRefresh(appId); + this.startHealthCheck(appId); + this.reconnectAttempts.delete(appId); + this.cancelAutoReconnect(appId); + this.updateConnectionStatus(appId, 'connected'); + this.logger.info(`Connected to remote MCP server ${appId} using protocol 2026-07-28`); + return connection2026; + } + // Create transport based on type let transport = this.createTransport(request); @@ -771,6 +789,39 @@ export class McpClientService { * Note: For HTTP transport with fallback, the initial transport is Streamable HTTP. * If connection fails with Streamable HTTP, use createFallbackTransport() to get SSE. */ + /** + * Build a 2026-07-28 connection when the remote is on that revision. + * + * Returns `undefined` for every other case so the legacy path below runs + * completely untouched — the default for an unconfigured remote. + */ + private async tryConnect2026(request: McpConnectRequest): Promise { + if (request.transportType !== 'http') return undefined; + + const httpOptions = request.transportOptions as McpHttpTransportOptions | undefined; + const negotiated = await negotiateRemoteProtocol(request.url, httpOptions?.protocolVersion, httpOptions?.headers); + if (negotiated !== '2026-07-28') return undefined; + + const adapter = new Mcp2026ClientAdapter({ + url: request.url, + clientInfo: { name: this.options.clientName, version: this.options.clientVersion }, + headers: httpOptions?.headers, + }); + await adapter.connect(); + + return { + // The adapter implements the subset of `Client` this service uses; the + // cast keeps `McpClientConnection` from having to become a union type + // that every consumer would then have to narrow. + client: adapter as unknown as McpClientConnection['client'], + transport: undefined as unknown as Transport, + status: 'connected', + connectedAt: new Date(), + lastHeartbeat: new Date(), + capabilities: adapter.getServerCapabilities(), + }; + } + private createTransport(request: McpConnectRequest): Transport { const { transportType, url, transportOptions } = request; const httpOptions = transportOptions as McpHttpTransportOptions | undefined; diff --git a/libs/sdk/src/remote-mcp/mcp-client.types.ts b/libs/sdk/src/remote-mcp/mcp-client.types.ts index fe72805ff..882611690 100644 --- a/libs/sdk/src/remote-mcp/mcp-client.types.ts +++ b/libs/sdk/src/remote-mcp/mcp-client.types.ts @@ -3,19 +3,19 @@ * @description Types for MCP client connections to remote servers */ -import type { Client } from '@frontmcp/protocol'; -import type { Transport } from '@frontmcp/protocol'; -import type { - Tool, - Resource, - ResourceTemplate, - Prompt, - ServerCapabilities, - CallToolResult, - ReadResourceResult, - GetPromptResult, +import { + type AuthInfo, + type CallToolResult, + type Client, + type GetPromptResult, + type Prompt, + type ReadResourceResult, + type Resource, + type ResourceTemplate, + type ServerCapabilities, + type Tool, + type Transport, } from '@frontmcp/protocol'; -import type { AuthInfo } from '@frontmcp/protocol'; // ═══════════════════════════════════════════════════════════════════ // CONNECTION TYPES @@ -84,6 +84,16 @@ export interface McpHttpTransportOptions { fallbackToSSE?: boolean; /** Additional headers to include in all requests */ headers?: Record; + /** + * Which MCP revision to speak to this remote. + * + * - omitted / `'legacy'` — the session + `initialize` transports (default, and + * what every existing deployment keeps doing). + * - `'2026-07-28'` — the stateless revision, via FrontMCP's own client. + * - `'auto'` — probe `server/discover` first and fall back to legacy when the + * remote does not answer it, per the spec's backward-compatibility guidance. + */ + protocolVersion?: 'legacy' | '2026-07-28' | 'auto'; } /** diff --git a/libs/sdk/src/task/helpers/task-runner.ts b/libs/sdk/src/task/helpers/task-runner.ts index 67a72508a..76097c3e8 100644 --- a/libs/sdk/src/task/helpers/task-runner.ts +++ b/libs/sdk/src/task/helpers/task-runner.ts @@ -21,6 +21,7 @@ import type { CallToolResult } from '@frontmcp/protocol'; import type { FrontMcpLogger } from '../../common'; +import { InputRequiredSignal } from '../../errors'; import type { TaskStore } from '../store'; import type { TaskRegistry } from '../task.registry'; import { isTerminal, type TaskJsonRpcError, type TaskRecord } from '../task.types'; @@ -94,6 +95,24 @@ async function executeTask(params: RunTaskParams): Promise { ctx: taskCtx, }); } catch (err) { + // Tasks extension (protocol 2026-07-28): a tool that needs client input + // parks the task in `input_required` rather than failing. The client + // reads `inputRequests` from `tasks/get` and answers with `tasks/update`, + // which resumes execution. This is NOT a terminal state, so return before + // the terminal write below. + if (err instanceof InputRequiredSignal) { + const paused = await store.update(taskId, sessionId, { + status: 'input_required', + statusMessage: 'The task is waiting for additional input.', + inputRequests: err.inputRequests, + }); + logger?.info('[task-runner] task paused awaiting input', { + taskId, + keys: Object.keys(err.inputRequests), + }); + if (paused) notifier.sendStatus(paused); + return; + } outcomeErr = toJsonRpcError(err); } diff --git a/libs/sdk/src/task/task.types.ts b/libs/sdk/src/task/task.types.ts index 718a4b7f2..8a54702a6 100644 --- a/libs/sdk/src/task/task.types.ts +++ b/libs/sdk/src/task/task.types.ts @@ -108,6 +108,21 @@ export interface TaskRecord { */ progressToken?: string | number; + /** + * Server→client requests the task is blocked on, set when `status` is + * `input_required` (tasks extension, protocol 2026-07-28). + * + * The client reads these from `tasks/get` and answers them with + * `tasks/update` — the task equivalent of the MRTR round trip. + */ + inputRequests?: Record }>; + + /** + * Answers accumulated from `tasks/update` calls, keyed the same way as + * {@link inputRequests}. Replayed into the tool when the task resumes. + */ + inputResponses?: Record>; + /** * Identifies the runtime executing the task so we can orphan-detect and * cross-process cancel. diff --git a/libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts b/libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts index 253dde65b..25b9e1cfd 100644 --- a/libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts +++ b/libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts @@ -13,7 +13,7 @@ * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http */ import { z } from '@frontmcp/lazy-zod'; -import { MCP_2026_META, type SubscriptionFilter } from '@frontmcp/protocol'; +import { MCP_2026_META, type LoggingLevel, type SubscriptionFilter } from '@frontmcp/protocol'; import { Flow, @@ -27,6 +27,7 @@ import { type FlowPlan, type FlowRunOptions, } from '../../common'; +import { FrontMcpContextStorage } from '../../context'; import { type Scope } from '../../scope'; import { createSubscriptionStream, @@ -34,6 +35,8 @@ import { isProtocol2026Request, MCP_HEADERS, readHeader, + RequestNotificationSink, + toJsonRpcError, validate2026Request, type JsonRpcErrorPayload, } from '../mcp-2026'; @@ -67,6 +70,91 @@ declare global { } } +const encoder = new TextEncoder(); + +/** Serialize one JSON-RPC message as an SSE `message` event. */ +function frame(message: unknown): Uint8Array { + return encoder.encode(`event: message\ndata: ${JSON.stringify(message)}\n\n`); +} + +/** True when the client is willing to receive an SSE response stream. */ +function acceptsEventStream(headers: Record | undefined): boolean { + const accept = readHeader(headers, 'accept'); + return typeof accept === 'string' && accept.includes('text/event-stream'); +} + +/** + * OpenTelemetry context carried on `_meta` (SEP-414). + * + * The W3C names are used verbatim and echoed back on the result, so a client can + * stitch its span to the server's without an out-of-band correlation id. + */ +const TRACE_META_KEYS = ['traceparent', 'tracestate', 'baggage'] as const; + +export function extractTraceContext(meta: Record): Record | undefined { + const out: Record = {}; + for (const key of TRACE_META_KEYS) { + if (typeof meta[key] === 'string') out[key] = meta[key] as string; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Stream a request's notifications followed by its final response. + * + * The dispatch runs concurrently with the drain loop so a long tool can report + * progress while it works; the final JSON-RPC response terminates the stream, + * as the transport spec prescribes. + */ +async function* streamMessageResponse( + options: Parameters[0], + sink: RequestNotificationSink, + requestId: unknown, + runInContext: (fn: () => Promise) => Promise, +): AsyncIterable { + let outcome: Awaited> | undefined; + let failure: unknown; + + // The stream body is drained by the response renderer, which runs AFTER the + // flow has unwound out of its AsyncLocalStorage scope. Re-entering the + // captured context is what keeps `this.tryGetContext()` — and therefore + // `notify()` / `progress()` / `elicit()` — working inside the entry. + const running = runInContext(async () => { + try { + outcome = await dispatch2026(options); + } catch (error: unknown) { + failure = error; + } + }).finally(() => sink.close()); + + while (!sink.closed) { + await sink.waitForActivity(); + for (const notification of sink.drain()) { + yield frame({ jsonrpc: '2.0', method: notification.method, params: notification.params }); + } + } + + await running; + + // Anything queued between the last drain and close still belongs to this + // request, so flush it before the terminating response. + for (const notification of sink.drain()) { + yield frame({ jsonrpc: '2.0', method: notification.method, params: notification.params }); + } + + const id = requestId ?? null; + if (failure !== undefined) { + const mapped = toJsonRpcError(failure); + yield frame({ jsonrpc: '2.0', id, error: mapped.error }); + return; + } + if (outcome?.kind === 'error') { + yield frame({ jsonrpc: '2.0', id, error: outcome.error }); + return; + } + yield frame({ jsonrpc: '2.0', id, result: outcome?.result }); +} + /** Build the JSON-RPC error envelope for a failed 2026-07-28 request. */ function errorResponse(status: number, error: JsonRpcErrorPayload, id: unknown) { return httpRespond.json( @@ -158,6 +246,28 @@ export default class HandleMcp2026Flow extends FlowBase { * header-validated call and the call that actually executes disagree about * which tool they mean. */ + /** + * Re-enter this request's `FrontMcpContext` for work deferred past the flow. + * + * A streamed response is drained by the renderer after the flow has unwound, + * so anything that runs there has lost the AsyncLocalStorage scope. Capturing + * the context here and re-entering it keeps every context-dependent API + * (`notify`, `progress`, `elicit`, provider resolution) behaving identically + * whether the response was buffered or streamed. + */ + private buildContextRunner(): (fn: () => Promise) => Promise { + // Both the context and the storage are resolved NOW, while the stage is + // still inside the AsyncLocalStorage scope. Reading them lazily from inside + // the generator would find no active context and silently drop it. + const context = this.tryGetContext(); + if (!context) return (fn) => fn(); + + const storage = this.scope.providers.get(FrontMcpContextStorage); + return async (fn) => { + await storage.runWithContext(context, fn); + }; + } + private findToolSchema(scope: Scope, toolName: string): Record | null { const match = scope.tools .getTools(true) @@ -251,7 +361,15 @@ export default class HandleMcp2026Flow extends FlowBase { const clientCapabilities = (meta[MCP_2026_META.clientCapabilities] as Record | undefined) ?? {}; - const outcome = await dispatch2026({ + // `logging/setLevel` is gone: the client opts into log messages per request, + // and a request that omits `logLevel` MUST receive none. Progress is opted + // into the same way, via `progressToken`. + const logLevel = + typeof meta[MCP_2026_META.logLevel] === 'string' ? (meta[MCP_2026_META.logLevel] as LoggingLevel) : undefined; + const progressToken = meta['progressToken'] as string | number | undefined; + const sink = new RequestNotificationSink(logLevel, progressToken); + + const dispatchOptions = { scope: this.scope as unknown as Scope, body, clientCapabilities, @@ -269,7 +387,32 @@ export default class HandleMcp2026Flow extends FlowBase { : undefined, isAnonymous: this.state.required.isAnonymous, composeInstructions: () => this.scope.metadata.instructions, - }); + notificationSink: sink, + traceContext: extractTraceContext(meta), + } satisfies Parameters[0]; + + // When the client opted into request-scoped notifications AND accepts SSE, + // the response becomes a stream so log/progress frames can arrive while the + // work is still running. Otherwise the answer is a single JSON object — + // both framings are required to be supported by the client. + if (sink.active && acceptsEventStream(request.headers as Record | undefined)) { + this.respond({ + kind: 'sse', + status: 200, + stream: streamMessageResponse(dispatchOptions, sink, body['id'], this.buildContextRunner()), + contentType: 'text/event-stream', + disposition: 'inline', + headers: { + 'X-Accel-Buffering': 'no', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); + return; + } + + const outcome = await dispatch2026(dispatchOptions); + sink.close(); if (outcome.kind === 'error') { this.respond(errorResponse(outcome.status, outcome.error, body['id'])); diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts index 91034bf88..ab2ffc60b 100644 --- a/libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts +++ b/libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts @@ -1,133 +1,125 @@ import { InputRequiredSignal, MissingClientCapabilityError } from '../../../errors'; -import { buildInputRequiredResult, decodeRequestState, encodeRequestState, MrtrExchange } from '../mrtr'; +import { buildInputRequiredResult, MrtrExchange } from '../mrtr'; +import { computeRequestBinding, decodeRequestState, type RequestStateBinding } from '../request-state'; + +const BINDING: RequestStateBinding = { + principal: 'user-1', + binding: computeRequestBinding('tools/call', { name: 'confirm', arguments: { action: 'deploy' } }), +}; const ELICITATION_CAPABLE = { elicitation: { form: {} } }; +const SAMPLING_CAPABLE = { sampling: {} }; +const ROOTS_CAPABLE = { roots: {} }; const PENDING = { message: 'Proceed?', requestedSchema: { type: 'object', properties: { confirmed: { type: 'boolean' } } }, }; -describe('requestState codec', () => { - it('round-trips recorded responses', () => { - const responses = { 'elicit-1': { action: 'accept', content: { confirmed: true } } }; - expect(decodeRequestState(encodeRequestState(responses))).toEqual(responses); - }); - - it('treats a malformed blob as no prior answers', () => { - // The value is opaque to the client, so a bad one means tampering or - // truncation — restarting the exchange beats failing the call. - expect(decodeRequestState('not-base64url!!')).toEqual({}); - expect(decodeRequestState(undefined)).toEqual({}); - expect(decodeRequestState('')).toEqual({}); - expect(decodeRequestState(Buffer.from('[]', 'utf8').toString('base64url'))).toEqual({}); - }); -}); +const SAMPLE = { + messages: [{ role: 'user', content: { type: 'text', text: 'Capital of France?' } }], + maxTokens: 100, +}; -describe('MrtrExchange', () => { +function exchange(overrides: Partial[0]> = {}) { + return new MrtrExchange({ clientCapabilities: {}, binding: BINDING, ...overrides }); +} + +function capture(fn: () => unknown): InputRequiredSignal { + try { + fn(); + } catch (error) { + if (error instanceof InputRequiredSignal) return error; + throw error; + } + throw new Error('expected InputRequiredSignal'); +} + +describe('MrtrExchange — elicitation', () => { it('raises InputRequiredSignal on the first unanswered elicitation', () => { - const exchange = new MrtrExchange({ clientCapabilities: ELICITATION_CAPABLE }); + const signal = capture(() => exchange({ clientCapabilities: ELICITATION_CAPABLE }).resolveElicitation(PENDING)); - let signal: InputRequiredSignal | undefined; - try { - exchange.resolveElicitation(PENDING); - } catch (error) { - signal = error as InputRequiredSignal; - } - - expect(signal).toBeInstanceOf(InputRequiredSignal); - expect(signal?.inputRequests['elicit-1']).toMatchObject({ + expect(signal.inputRequests['elicitation-1']).toMatchObject({ method: 'elicitation/create', params: { message: 'Proceed?' }, }); - expect(typeof signal?.requestState).toBe('string'); + expect(typeof signal.requestState).toBe('string'); }); it('returns a recorded answer instead of asking again', () => { - const exchange = new MrtrExchange({ + const ex = exchange({ clientCapabilities: ELICITATION_CAPABLE, - inputResponses: { 'elicit-1': { action: 'accept', content: { confirmed: true } } }, + inputResponses: { 'elicitation-1': { action: 'accept', content: { confirmed: true } } }, }); - expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { confirmed: true } }); + expect(ex.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { confirmed: true } }); }); it('accepts the `status` spelling as well as `action`', () => { - const exchange = new MrtrExchange({ + const ex = exchange({ clientCapabilities: ELICITATION_CAPABLE, - inputResponses: { 'elicit-1': { status: 'decline' } }, + inputResponses: { 'elicitation-1': { status: 'decline' } }, }); - expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'decline' }); + expect(ex.resolveElicitation(PENDING)).toEqual({ status: 'decline' }); }); it('defaults an answer with no action to cancel', () => { - const exchange = new MrtrExchange({ - clientCapabilities: ELICITATION_CAPABLE, - inputResponses: { 'elicit-1': {} }, - }); - - expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'cancel' }); + const ex = exchange({ clientCapabilities: ELICITATION_CAPABLE, inputResponses: { 'elicitation-1': {} } }); + expect(ex.resolveElicitation(PENDING)).toEqual({ status: 'cancel' }); }); it('derives keys from call order so a replayed tool lines up', () => { - const exchange = new MrtrExchange({ + const ex = exchange({ clientCapabilities: ELICITATION_CAPABLE, inputResponses: { - 'elicit-1': { action: 'accept', content: { step: 1 } }, - 'elicit-2': { action: 'accept', content: { step: 2 } }, + 'elicitation-1': { action: 'accept', content: { step: 1 } }, + 'elicitation-2': { action: 'accept', content: { step: 2 } }, }, }); - expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { step: 1 } }); - expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { step: 2 } }); + expect(ex.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { step: 1 } }); + expect(ex.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { step: 2 } }); }); it('asks for the next step once earlier answers are exhausted', () => { - const exchange = new MrtrExchange({ + const ex = exchange({ clientCapabilities: ELICITATION_CAPABLE, - inputResponses: { 'elicit-1': { action: 'accept', content: { step: 1 } } }, + inputResponses: { 'elicitation-1': { action: 'accept', content: { step: 1 } } }, }); - exchange.resolveElicitation(PENDING); - expect(() => exchange.resolveElicitation(PENDING)).toThrow(InputRequiredSignal); + ex.resolveElicitation(PENDING); + expect(() => ex.resolveElicitation(PENDING)).toThrow(InputRequiredSignal); }); it('carries earlier answers forward through requestState', () => { - const first = { 'elicit-1': { action: 'accept', content: { step: 1 } } }; - const exchange = new MrtrExchange({ - clientCapabilities: ELICITATION_CAPABLE, - carriedResponses: first, - }); + const first = { 'elicitation-1': { action: 'accept', content: { step: 1 } } }; + const ex = exchange({ clientCapabilities: ELICITATION_CAPABLE, carriedResponses: first }); - expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { step: 1 } }); + expect(ex.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { step: 1 } }); // The follow-up ask must re-encode what we already know, or a multi-step // tool would never converge. - try { - exchange.resolveElicitation(PENDING); - throw new Error('expected InputRequiredSignal'); - } catch (error) { - expect(decodeRequestState((error as InputRequiredSignal).requestState)).toEqual(first); - } + const signal = capture(() => ex.resolveElicitation(PENDING)); + expect(decodeRequestState(signal.requestState, BINDING)).toEqual({ ok: true, responses: first }); }); it('lets a fresh inputResponse win over a carried one', () => { - const exchange = new MrtrExchange({ + const ex = exchange({ clientCapabilities: ELICITATION_CAPABLE, - carriedResponses: { 'elicit-1': { action: 'decline' } }, - inputResponses: { 'elicit-1': { action: 'accept', content: { confirmed: true } } }, + carriedResponses: { 'elicitation-1': { action: 'decline' } }, + inputResponses: { 'elicitation-1': { action: 'accept', content: { confirmed: true } } }, }); - expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { confirmed: true } }); + expect(ex.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { confirmed: true } }); }); it('demands the elicitation capability before asking', () => { - const exchange = new MrtrExchange({ clientCapabilities: {} }); - + // The spec forbids emitting an inputRequests entry the client never said it + // supports, so this must fail rather than ask. let error: unknown; try { - exchange.resolveElicitation(PENDING); + exchange().resolveElicitation(PENDING); } catch (e) { error = e; } @@ -137,23 +129,114 @@ describe('MrtrExchange', () => { }); it('still resolves a recorded answer without the capability declared', () => { - // The client already answered — refusing now would strand a valid retry. - const exchange = new MrtrExchange({ - clientCapabilities: {}, - inputResponses: { 'elicit-1': { action: 'accept', content: { confirmed: true } } }, + const ex = exchange({ inputResponses: { 'elicitation-1': { action: 'accept', content: { confirmed: true } } } }); + expect(ex.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { confirmed: true } }); + }); +}); + +describe('MrtrExchange — sampling', () => { + it('raises a sampling/createMessage input request', () => { + const signal = capture(() => exchange({ clientCapabilities: SAMPLING_CAPABLE }).resolveSampling(SAMPLE)); + + expect(signal.inputRequests['sampling-1']).toMatchObject({ + method: 'sampling/createMessage', + params: { maxTokens: 100 }, + }); + }); + + it('returns the recorded completion on replay', () => { + const answer = { role: 'assistant', content: { type: 'text', text: 'Paris' }, model: 'claude-x' }; + const ex = exchange({ clientCapabilities: SAMPLING_CAPABLE, inputResponses: { 'sampling-1': answer } }); + + expect(ex.resolveSampling(SAMPLE)).toEqual(answer); + }); + + it('demands the sampling capability', () => { + expect(() => exchange().resolveSampling(SAMPLE)).toThrow(MissingClientCapabilityError); + }); + + it('forwards includeContext "none" even without context support', () => { + const signal = capture(() => + exchange({ clientCapabilities: SAMPLING_CAPABLE }).resolveSampling({ ...SAMPLE, includeContext: 'none' }), + ); + expect(signal.inputRequests['sampling-1']?.params?.['includeContext']).toBe('none'); + }); + + it('drops deprecated includeContext values when the client lacks context support', () => { + // `thisServer` / `allServers` are deprecated; sending them to a client that + // never declared `sampling.context` would ask for something unsupported. + const signal = capture(() => + exchange({ clientCapabilities: SAMPLING_CAPABLE }).resolveSampling({ ...SAMPLE, includeContext: 'thisServer' }), + ); + expect(signal.inputRequests['sampling-1']?.params?.['includeContext']).toBeUndefined(); + }); + + it('forwards deprecated includeContext values when context support is declared', () => { + const signal = capture(() => + exchange({ clientCapabilities: { sampling: { context: {} } } }).resolveSampling({ + ...SAMPLE, + includeContext: 'thisServer', + }), + ); + expect(signal.inputRequests['sampling-1']?.params?.['includeContext']).toBe('thisServer'); + }); + + it('omits optional params that were not supplied', () => { + const signal = capture(() => exchange({ clientCapabilities: SAMPLING_CAPABLE }).resolveSampling(SAMPLE)); + const params = signal.inputRequests['sampling-1']?.params ?? {}; + expect(params['systemPrompt']).toBeUndefined(); + expect(params['temperature']).toBeUndefined(); + }); +}); + +describe('MrtrExchange — roots', () => { + it('raises a roots/list input request', () => { + const signal = capture(() => exchange({ clientCapabilities: ROOTS_CAPABLE }).resolveRoots()); + expect(signal.inputRequests['roots-1']).toMatchObject({ method: 'roots/list' }); + }); + + it('returns the recorded roots on replay', () => { + const ex = exchange({ + clientCapabilities: ROOTS_CAPABLE, + inputResponses: { 'roots-1': { roots: [{ uri: 'file:///work', name: 'work' }] } }, }); - expect(exchange.resolveElicitation(PENDING)).toEqual({ status: 'accept', content: { confirmed: true } }); + expect(ex.resolveRoots()).toEqual({ roots: [{ uri: 'file:///work', name: 'work' }] }); + }); + + it('normalizes a malformed roots answer to an empty list', () => { + const ex = exchange({ clientCapabilities: ROOTS_CAPABLE, inputResponses: { 'roots-1': { roots: 'nope' } } }); + expect(ex.resolveRoots()).toEqual({ roots: [] }); + }); + + it('demands the roots capability', () => { + expect(() => exchange().resolveRoots()).toThrow(MissingClientCapabilityError); + }); +}); + +describe('MrtrExchange — mixed kinds', () => { + it('keys each kind independently so counters never collide', () => { + const ex = exchange({ clientCapabilities: { ...ELICITATION_CAPABLE, ...SAMPLING_CAPABLE } }); + + const first = capture(() => ex.resolveElicitation(PENDING)); + expect(Object.keys(first.inputRequests)).toEqual(['elicitation-1']); + + const second = capture(() => ex.resolveSampling(SAMPLE)); + // The pending map accumulates across the run, so the retry is asked for both. + expect(Object.keys(second.inputRequests).sort()).toEqual(['elicitation-1', 'sampling-1']); }); }); describe('buildInputRequiredResult', () => { it('produces the interim result envelope', () => { - const signal = new InputRequiredSignal({ 'elicit-1': { method: 'elicitation/create', params: {} } }, 'state-blob'); + const signal = new InputRequiredSignal( + { 'elicitation-1': { method: 'elicitation/create', params: {} } }, + 'state-blob', + ); expect(buildInputRequiredResult(signal)).toEqual({ resultType: 'input_required', - inputRequests: { 'elicit-1': { method: 'elicitation/create', params: {} } }, + inputRequests: { 'elicitation-1': { method: 'elicitation/create', params: {} } }, requestState: 'state-blob', }); }); diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/request-notifications.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/request-notifications.spec.ts new file mode 100644 index 000000000..6439bc86f --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/__tests__/request-notifications.spec.ts @@ -0,0 +1,128 @@ +import { meetsLogLevel, RequestNotificationSink } from '../request-notifications'; + +describe('meetsLogLevel', () => { + it('accepts a level at or above the minimum', () => { + expect(meetsLogLevel('warning', 'info')).toBe(true); + expect(meetsLogLevel('info', 'info')).toBe(true); + expect(meetsLogLevel('emergency', 'debug')).toBe(true); + }); + + it('rejects a level below the minimum', () => { + expect(meetsLogLevel('debug', 'warning')).toBe(false); + expect(meetsLogLevel('info', 'error')).toBe(false); + }); + + it('lets unknown levels through rather than silently dropping them', () => { + expect(meetsLogLevel('bogus' as never, 'error')).toBe(true); + expect(meetsLogLevel('error', 'bogus' as never)).toBe(true); + }); +}); + +describe('RequestNotificationSink', () => { + it('is inactive when the client opted into nothing', () => { + expect(new RequestNotificationSink(undefined, undefined).active).toBe(false); + }); + + it('is active when a log level was requested', () => { + expect(new RequestNotificationSink('info', undefined).active).toBe(true); + }); + + it('is active when a progress token was supplied', () => { + expect(new RequestNotificationSink(undefined, 'tok').active).toBe(true); + }); + + it('drops log messages when no level was requested', () => { + // The spec makes this a MUST NOT, not a preference. + const sink = new RequestNotificationSink(undefined, 'tok'); + expect(sink.log('error', 'tool', { message: 'x' })).toBe(false); + expect(sink.drain()).toEqual([]); + }); + + it('queues log messages at or above the requested level', () => { + const sink = new RequestNotificationSink('warning', undefined); + + expect(sink.log('info', 'tool', { message: 'quiet' })).toBe(false); + expect(sink.log('error', 'tool', { message: 'loud' })).toBe(true); + + expect(sink.drain()).toEqual([ + { method: 'notifications/message', params: { level: 'error', logger: 'tool', data: { message: 'loud' } } }, + ]); + }); + + it('omits the logger field when no name was supplied', () => { + const sink = new RequestNotificationSink('debug', undefined); + sink.log('debug', undefined, { message: 'x' }); + expect(sink.drain()[0]?.params).toEqual({ level: 'debug', data: { message: 'x' } }); + }); + + it('drops progress when no token was supplied', () => { + const sink = new RequestNotificationSink('debug', undefined); + expect(sink.progress(1, 2, 'half')).toBe(false); + expect(sink.drain()).toEqual([]); + }); + + it('queues progress with the client token', () => { + const sink = new RequestNotificationSink(undefined, 'tok-1'); + expect(sink.progress(1, 3, 'step 1')).toBe(true); + + expect(sink.drain()).toEqual([ + { + method: 'notifications/progress', + params: { progressToken: 'tok-1', progress: 1, total: 3, message: 'step 1' }, + }, + ]); + }); + + it('omits optional progress fields that were not supplied', () => { + const sink = new RequestNotificationSink(undefined, 7); + sink.progress(1); + expect(sink.drain()[0]?.params).toEqual({ progressToken: 7, progress: 1 }); + }); + + it('drains everything queued and empties the buffer', () => { + const sink = new RequestNotificationSink('debug', 'tok'); + sink.log('info', 'a', {}); + sink.progress(1); + + expect(sink.drain()).toHaveLength(2); + expect(sink.drain()).toEqual([]); + }); + + it('stops accepting notifications once closed', () => { + const sink = new RequestNotificationSink('debug', 'tok'); + sink.close(); + sink.log('error', 'a', {}); + + expect(sink.closed).toBe(true); + expect(sink.drain()).toEqual([]); + }); + + it('resolves waitForActivity immediately when work is pending', async () => { + const sink = new RequestNotificationSink('debug', undefined); + sink.log('info', 'a', {}); + await expect(sink.waitForActivity()).resolves.toBeUndefined(); + }); + + it('resolves waitForActivity immediately once closed', async () => { + const sink = new RequestNotificationSink('debug', undefined); + sink.close(); + await expect(sink.waitForActivity()).resolves.toBeUndefined(); + }); + + it('wakes a waiter when a notification arrives', async () => { + const sink = new RequestNotificationSink('debug', undefined); + const waiting = sink.waitForActivity(); + sink.log('info', 'a', { message: 'hi' }); + + await expect(waiting).resolves.toBeUndefined(); + expect(sink.drain()).toHaveLength(1); + }); + + it('wakes a waiter when the sink closes', async () => { + // Otherwise the streaming loop would hang forever on a silent request. + const sink = new RequestNotificationSink('debug', undefined); + const waiting = sink.waitForActivity(); + sink.close(); + await expect(waiting).resolves.toBeUndefined(); + }); +}); diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/request-state.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/request-state.spec.ts new file mode 100644 index 000000000..8f11b3ba8 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/__tests__/request-state.spec.ts @@ -0,0 +1,150 @@ +import { + computeRequestBinding, + decodeRequestState, + encodeRequestState, + getRequestStateKey, + resetRequestStateKey, + type RequestStateBinding, +} from '../request-state'; + +const BINDING: RequestStateBinding = { + principal: 'user-1', + binding: computeRequestBinding('tools/call', { name: 'confirm', arguments: { action: 'deploy' } }), +}; + +const RESPONSES = { 'elicitation-1': { action: 'accept', content: { confirmed: true } } }; + +describe('computeRequestBinding', () => { + it('is stable for the same salient params', () => { + expect(computeRequestBinding('tools/call', { name: 'a', arguments: { x: 1 } })).toBe( + computeRequestBinding('tools/call', { name: 'a', arguments: { x: 1 } }), + ); + }); + + it('ignores fields that legitimately change between the ask and the retry', () => { + // `_meta`, `inputResponses` and `requestState` all differ on the retry — if + // they were bound, no retry could ever verify. + const initial = computeRequestBinding('tools/call', { name: 'a', arguments: { x: 1 } }); + const retry = computeRequestBinding('tools/call', { + name: 'a', + arguments: { x: 1 }, + _meta: { anything: true }, + inputResponses: RESPONSES, + requestState: 'blob', + }); + expect(retry).toBe(initial); + }); + + it('differs across methods, names and arguments', () => { + const base = computeRequestBinding('tools/call', { name: 'a', arguments: { x: 1 } }); + expect(computeRequestBinding('prompts/get', { name: 'a', arguments: { x: 1 } })).not.toBe(base); + expect(computeRequestBinding('tools/call', { name: 'b', arguments: { x: 1 } })).not.toBe(base); + expect(computeRequestBinding('tools/call', { name: 'a', arguments: { x: 2 } })).not.toBe(base); + }); + + it('handles absent params', () => { + expect(typeof computeRequestBinding('tools/list', undefined)).toBe('string'); + }); +}); + +describe('requestState integrity', () => { + it('round-trips a verified blob', () => { + const state = encodeRequestState(RESPONSES, BINDING); + expect(decodeRequestState(state, BINDING)).toEqual({ ok: true, responses: RESPONSES }); + }); + + it('reports an absent state', () => { + expect(decodeRequestState(undefined, BINDING)).toEqual({ ok: false, reason: 'absent' }); + expect(decodeRequestState('', BINDING)).toEqual({ ok: false, reason: 'absent' }); + expect(decodeRequestState(42, BINDING)).toEqual({ ok: false, reason: 'absent' }); + }); + + it('rejects a blob with no signature', () => { + expect(decodeRequestState('justsomething', BINDING)).toEqual({ ok: false, reason: 'malformed' }); + }); + + it('rejects a tampered payload', () => { + // The whole point: a client that rewrites the answers must not be believed. + const state = encodeRequestState(RESPONSES, BINDING); + const forged = Buffer.from( + JSON.stringify({ r: { 'elicitation-1': { action: 'accept', content: { confirmed: true, admin: true } } } }), + 'utf8', + ).toString('base64url'); + const tampered = `${forged}.${state.slice(state.lastIndexOf('.') + 1)}`; + + expect(decodeRequestState(tampered, BINDING)).toEqual({ ok: false, reason: 'bad-signature' }); + }); + + it('rejects a tampered signature', () => { + const state = encodeRequestState(RESPONSES, BINDING); + expect(decodeRequestState(`${state.slice(0, state.lastIndexOf('.'))}.deadbeef`, BINDING)).toEqual({ + ok: false, + reason: 'bad-signature', + }); + }); + + it('rejects state presented by a different principal', () => { + const state = encodeRequestState(RESPONSES, BINDING); + expect(decodeRequestState(state, { ...BINDING, principal: 'attacker' })).toEqual({ + ok: false, + reason: 'principal-mismatch', + }); + }); + + it('rejects state replayed onto a different request', () => { + const state = encodeRequestState(RESPONSES, BINDING); + expect( + decodeRequestState(state, { ...BINDING, binding: computeRequestBinding('tools/call', { name: 'other' }) }), + ).toEqual({ ok: false, reason: 'request-mismatch' }); + }); + + it('rejects expired state', () => { + const state = encodeRequestState(RESPONSES, BINDING, -1); + expect(decodeRequestState(state, BINDING)).toEqual({ ok: false, reason: 'expired' }); + }); + + it('rejects a signed blob whose payload is not JSON', () => { + // Signed by us, so the signature passes — the payload check must still catch it. + const { hmacSha256 } = require('@frontmcp/utils'); + const body = Buffer.from('not json', 'utf8').toString('base64url'); + const mac = Buffer.from(hmacSha256(getRequestStateKey(), new TextEncoder().encode(body))).toString('base64url'); + expect(decodeRequestState(`${body}.${mac}`, BINDING)).toEqual({ ok: false, reason: 'malformed' }); + }); +}); + +describe('getRequestStateKey', () => { + const originalVault = process.env['VAULT_SECRET']; + const originalJwt = process.env['JWT_SECRET']; + + afterEach(() => { + if (originalVault === undefined) delete process.env['VAULT_SECRET']; + else process.env['VAULT_SECRET'] = originalVault; + if (originalJwt === undefined) delete process.env['JWT_SECRET']; + else process.env['JWT_SECRET'] = originalJwt; + resetRequestStateKey(); + }); + + it('derives from VAULT_SECRET when present', () => { + resetRequestStateKey(); + process.env['VAULT_SECRET'] = 'vault-pepper'; + expect(Buffer.from(getRequestStateKey()).toString('utf8')).toBe('vault-pepper'); + }); + + it('falls back to JWT_SECRET', () => { + resetRequestStateKey(); + delete process.env['VAULT_SECRET']; + process.env['JWT_SECRET'] = 'jwt-pepper'; + expect(Buffer.from(getRequestStateKey()).toString('utf8')).toBe('jwt-pepper'); + }); + + it('falls back to a random per-process key', () => { + resetRequestStateKey(); + delete process.env['VAULT_SECRET']; + delete process.env['JWT_SECRET']; + const key = getRequestStateKey(); + expect(key.length).toBe(32); + // Cached, so repeated reads within a process agree — otherwise a retry + // could never verify state minted moments earlier. + expect(getRequestStateKey()).toBe(key); + }); +}); diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/result-decorator.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/result-decorator.spec.ts index a9d882d67..095e8f3eb 100644 --- a/libs/sdk/src/transport/mcp-2026/__tests__/result-decorator.spec.ts +++ b/libs/sdk/src/transport/mcp-2026/__tests__/result-decorator.spec.ts @@ -1,7 +1,7 @@ import { MCP_2026_META } from '@frontmcp/protocol'; import { DEFAULT_CACHE_TTL_MS } from '../protocol-2026.constants'; -import { decorateResult, resolveCacheScope } from '../result-decorator'; +import { decorateResult, orderListResult, resolveCacheScope } from '../result-decorator'; const serverInfo = { name: 'test-server', version: '1.0.0' }; @@ -64,3 +64,51 @@ describe('resolveCacheScope', () => { expect(resolveCacheScope(false)).toBe('private'); }); }); + +describe('orderListResult', () => { + it('sorts tools by name', () => { + const out = orderListResult('tools/list', { tools: [{ name: 'b' }, { name: 'a' }, { name: 'c' }] }); + expect((out['tools'] as Array<{ name: string }>).map((t) => t.name)).toEqual(['a', 'b', 'c']); + }); + + it('sorts prompts, resources and templates', () => { + expect(orderListResult('prompts/list', { prompts: [{ name: 'z' }, { name: 'a' }] })['prompts']).toEqual([ + { name: 'a' }, + { name: 'z' }, + ]); + expect(orderListResult('resources/list', { resources: [{ uri: 'b://x' }, { uri: 'a://x' }] })['resources']).toEqual( + [{ uri: 'a://x' }, { uri: 'b://x' }], + ); + expect( + orderListResult('resources/templates/list', { resourceTemplates: [{ name: 'y' }, { name: 'x' }] })[ + 'resourceTemplates' + ], + ).toEqual([{ name: 'x' }, { name: 'y' }]); + }); + + it('falls back to uri when an entry has no name', () => { + const out = orderListResult('resources/list', { resources: [{ uri: 'b://x' }, { name: 'a' }] }); + expect(out['resources']).toEqual([{ name: 'a' }, { uri: 'b://x' }]); + }); + + it('leaves non-list methods untouched', () => { + const input = { + content: [ + { type: 'text', text: 'z' }, + { type: 'text', text: 'a' }, + ], + }; + expect(orderListResult('tools/call', input)).toBe(input); + }); + + it('leaves a malformed list untouched', () => { + const input = { tools: 'not-an-array' }; + expect(orderListResult('tools/list', input)).toBe(input); + }); + + it('does not mutate the input', () => { + const input = { tools: [{ name: 'b' }, { name: 'a' }] }; + orderListResult('tools/list', input); + expect(input.tools.map((t) => t.name)).toEqual(['b', 'a']); + }); +}); diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/tasks-extension.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/tasks-extension.spec.ts new file mode 100644 index 000000000..51ee52155 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/__tests__/tasks-extension.spec.ts @@ -0,0 +1,150 @@ +import { type TaskRecord } from '../../../task/task.types'; +import { resolveTaskPrincipal } from '../dispatcher'; +import { + buildCreateTaskResult, + clientSupportsTasks, + resolveTaskOwner, + TASKS_EXTENSION_ID, + taskToWire2026, +} from '../tasks-extension'; + +const base: TaskRecord = { + taskId: 'task-1', + sessionId: 'principal:user-1', + status: 'working', + createdAt: '2026-08-01T00:00:00.000Z', + lastUpdatedAt: '2026-08-01T00:00:01.000Z', + ttlMs: 60_000, + pollIntervalMs: 50, + expiresAt: Date.parse('2026-08-01T00:01:00.000Z'), + request: { method: 'tools/call', params: { name: 'slow-job' } }, +}; + +describe('clientSupportsTasks', () => { + it('accepts a client declaring the extension', () => { + expect(clientSupportsTasks({ extensions: { [TASKS_EXTENSION_ID]: {} } })).toBe(true); + }); + + it('rejects a client declaring no extensions', () => { + expect(clientSupportsTasks({})).toBe(false); + expect(clientSupportsTasks({ extensions: {} })).toBe(false); + }); + + it('rejects a client declaring a different extension', () => { + expect(clientSupportsTasks({ extensions: { 'io.modelcontextprotocol/ui': {} } })).toBe(false); + }); + + it('ignores a malformed extensions value', () => { + expect(clientSupportsTasks({ extensions: 'nope' })).toBe(false); + }); +}); + +describe('taskToWire2026', () => { + it('uses the 2026 field names', () => { + const wire = taskToWire2026(base); + expect(wire['ttlMs']).toBe(60_000); + expect(wire['pollIntervalMs']).toBe(50); + // Renamed from the 2025-11-25 core protocol; leaking the old spelling would + // make a conforming client miss the values entirely. + expect(wire['ttl']).toBeUndefined(); + expect(wire['pollInterval']).toBeUndefined(); + }); + + it('omits result and error while still working', () => { + const wire = taskToWire2026(base); + expect(wire['result']).toBeUndefined(); + expect(wire['error']).toBeUndefined(); + expect(wire['inputRequests']).toBeUndefined(); + }); + + it('carries the result once completed', () => { + const wire = taskToWire2026({ + ...base, + status: 'completed', + outcome: { kind: 'ok', data: { content: [{ type: 'text', text: 'done' }] } }, + }); + expect(wire['status']).toBe('completed'); + expect(JSON.stringify(wire['result'])).toContain('done'); + }); + + it('carries the error once failed', () => { + const wire = taskToWire2026({ + ...base, + status: 'failed', + outcome: { kind: 'error', error: { code: -32603, message: 'boom' } }, + }); + expect(wire['error']).toEqual({ code: -32603, message: 'boom' }); + }); + + it('carries inputRequests while awaiting input', () => { + const wire = taskToWire2026({ + ...base, + status: 'input_required', + inputRequests: { 'elicitation-1': { method: 'elicitation/create', params: { message: 'ok?' } } }, + }); + expect(wire['inputRequests']).toEqual({ + 'elicitation-1': { method: 'elicitation/create', params: { message: 'ok?' } }, + }); + }); + + it('does not leak a stale result on a cancelled task', () => { + const wire = taskToWire2026({ + ...base, + status: 'cancelled', + outcome: { kind: 'ok', data: { content: [] } }, + }); + expect(wire['result']).toBeUndefined(); + }); + + it('omits optional fields that are unset', () => { + const { pollIntervalMs: _drop, ...withoutPoll } = base; + const wire = taskToWire2026(withoutPoll as TaskRecord); + expect('pollIntervalMs' in wire).toBe(false); + expect('statusMessage' in wire).toBe(false); + }); +}); + +describe('buildCreateTaskResult', () => { + it('discriminates the handle with resultType "task"', () => { + const result = buildCreateTaskResult(base); + expect(result['resultType']).toBe('task'); + expect((result['task'] as Record)['taskId']).toBe('task-1'); + }); +}); + +describe('resolveTaskOwner', () => { + it('namespaces an identified principal', () => { + expect(resolveTaskOwner('user-1')).toEqual({ ok: true, owner: 'principal:user-1' }); + }); + + it('refuses an anonymous caller', () => { + // Pooling anonymous callers would let them read each other's task results. + const result = resolveTaskOwner('anonymous'); + expect(result.ok).toBe(false); + expect((result as { reason: string }).reason).toContain('authenticated caller'); + }); + + it('refuses an empty principal', () => { + expect(resolveTaskOwner('').ok).toBe(false); + }); +}); + +describe('resolveTaskPrincipal', () => { + it('uses the verified subject', () => { + expect(resolveTaskPrincipal({ clientId: 'user-9' })).toBe('user-9'); + }); + + it('treats an anonymous session as anonymous even with a synthetic subject', () => { + expect(resolveTaskPrincipal({ clientId: 'anon-abc' }, true)).toBe('anonymous'); + }); + + it('never accepts the token fallback as a task owner', () => { + // `resolvePrincipal` would return `tok:…` here; that is fine for binding a + // short-lived requestState but must not own a durable task. + expect(resolveTaskPrincipal({ token: 'abcdef0123456789' })).toBe('anonymous'); + }); + + it('falls back to anonymous with no auth at all', () => { + expect(resolveTaskPrincipal(undefined)).toBe('anonymous'); + }); +}); diff --git a/libs/sdk/src/transport/mcp-2026/client/header-params.ts b/libs/sdk/src/transport/mcp-2026/client/header-params.ts new file mode 100644 index 000000000..e6ff57a5d --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/client/header-params.ts @@ -0,0 +1,105 @@ +/** + * Client-side `x-mcp-header` handling — protocol 2026-07-28, SEP-2243. + * + * A conforming client mirrors annotated tool arguments into `Mcp-Param-{Name}` + * headers. It must also POLICE the annotations: the spec requires clients to + * REJECT tool definitions whose `x-mcp-header` values break the rules, and to + * exclude just those tools from `tools/list` rather than failing the whole list. + */ +import { collectHeaderParams } from '../request-validation'; + +/** RFC 9110 field-name token characters. */ +const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** Largest integer that survives a JSON round trip without precision loss. */ +const MAX_SAFE = Number.MAX_SAFE_INTEGER; + +export interface HeaderParamValidation { + valid: boolean; + reason?: string; +} + +/** + * Validate every `x-mcp-header` annotation in a tool's input schema. + * + * Checks the constraints the spec places on annotation NAMES (non-empty, token + * syntax, no control characters, case-insensitively unique) and on the annotated + * property TYPES (primitive; `number` is excluded because it cannot round-trip + * exactly, integers must stay in the safe range). + */ +export function validateHeaderParams(inputSchema: unknown): HeaderParamValidation { + if (!inputSchema || typeof inputSchema !== 'object') return { valid: true }; + + const annotated = collectHeaderParams(inputSchema); + const seen = new Set(); + + for (const [name, path] of annotated) { + if (name.length === 0) return { valid: false, reason: 'x-mcp-header must not be empty' }; + if (!TOKEN_RE.test(name)) { + return { valid: false, reason: `x-mcp-header "${name}" is not a valid HTTP field-name token` }; + } + if (seen.has(name)) { + return { valid: false, reason: `x-mcp-header "${name}" is declared more than once` }; + } + seen.add(name); + + const type = readTypeAtPath(inputSchema, path); + if (type === 'number') { + return { valid: false, reason: `x-mcp-header "${name}" annotates a number, which is not permitted` }; + } + if (type !== undefined && !['string', 'integer', 'boolean'].includes(type)) { + return { valid: false, reason: `x-mcp-header "${name}" annotates a non-primitive type "${type}"` }; + } + } + + return { valid: true }; +} + +/** Read the declared `type` of a property reachable through a `properties` chain. */ +function readTypeAtPath(schema: unknown, path: string[]): string | undefined { + let cursor: unknown = schema; + for (const segment of path) { + const properties = (cursor as { properties?: Record } | undefined)?.properties; + if (!properties) return undefined; + cursor = properties[segment]; + } + const type = (cursor as { type?: unknown } | undefined)?.type; + return typeof type === 'string' ? type : undefined; +} + +/** + * Build the `Mcp-Param-*` headers for a tool call. + * + * A header is emitted only when the annotated argument is actually present — + * the spec pairs "value provided" with "client MUST include the header" and + * "value absent/null" with "client MUST omit it". + */ +export function buildParamHeaders( + inputSchema: unknown, + args: unknown, + encode: (value: string) => string, +): Record { + const headers: Record = {}; + if (!inputSchema || typeof inputSchema !== 'object') return headers; + + for (const [name, path] of collectHeaderParams(inputSchema)) { + const value = readValueAtPath(args, path); + if (value === undefined || value === null) continue; + + if (typeof value === 'number' && (!Number.isInteger(value) || Math.abs(value) > MAX_SAFE)) continue; + + const asString = typeof value === 'boolean' ? String(value) : String(value); + headers[`Mcp-Param-${name}`] = encode(asString); + } + + return headers; +} + +function readValueAtPath(args: unknown, path: string[]): unknown { + let cursor: unknown = args; + for (const segment of path) { + if (!cursor || typeof cursor !== 'object') return undefined; + cursor = (cursor as Record)[segment]; + } + return cursor; +} diff --git a/libs/sdk/src/transport/mcp-2026/client/index.ts b/libs/sdk/src/transport/mcp-2026/client/index.ts new file mode 100644 index 000000000..1040222dd --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/client/index.ts @@ -0,0 +1,7 @@ +/** + * Client-side support for MCP protocol revision 2026-07-28. + * + * @module transport/mcp-2026/client + */ +export * from './header-params'; +export * from './mcp-2026.client'; diff --git a/libs/sdk/src/transport/mcp-2026/client/mcp-2026.client.ts b/libs/sdk/src/transport/mcp-2026/client/mcp-2026.client.ts new file mode 100644 index 000000000..d5ba08e77 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/client/mcp-2026.client.ts @@ -0,0 +1,438 @@ +/** + * MCP client for protocol revision 2026-07-28. + * + * The upstream `@modelcontextprotocol/sdk` client speaks at most `2025-11-25`, + * so talking to a 2026 server needs its own implementation. It is small on + * purpose — the revision is stateless, so there is no session, no handshake and + * no reconnect logic to manage. What it DOES own is the three behaviours a + * conforming client must implement: + * + * - **Request metadata.** Per-request `_meta` plus the mirrored + * `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers. + * - **MRTR.** An `input_required` result is answered by gathering the requested + * input and re-issuing the ORIGINAL request — with a NEW JSON-RPC id and the + * `requestState` echoed back verbatim. + * - **Tasks.** A `resultType: "task"` handle is polled through `tasks/get` + * until it reaches a terminal state, answering `input_required` along the way + * via `tasks/update`. + * + * @module transport/mcp-2026/client + */ +import { MCP_2026_META, PROTOCOL_2026_07_28, type Implementation } from '@frontmcp/protocol'; + +import { encodeHeaderValue } from '../header-codec'; +import { NAME_FROM_PARAMS_NAME, NAME_FROM_PARAMS_URI } from '../protocol-2026.constants'; +import { TASKS_EXTENSION_ID, TERMINAL_TASK_STATUSES } from '../tasks-extension'; +import { buildParamHeaders, validateHeaderParams } from './header-params'; + +/** Answers the client supplies when a server asks for input via MRTR. */ +export interface Mcp2026InputHandlers { + /** Handle an `elicitation/create` request. */ + onElicit?: (params: Record) => Promise> | Record; + /** Handle a `sampling/createMessage` request. */ + onSample?: (params: Record) => Promise> | Record; + /** Handle a `roots/list` request. */ + onListRoots?: () => + | Promise<{ roots: Array<{ uri: string; name?: string }> }> + | { roots: Array<{ uri: string; name?: string }> }; +} + +export interface Mcp2026ClientOptions { + /** The MCP endpoint URL. */ + url: string; + clientInfo?: Implementation; + /** Capabilities declared on EVERY request (they are per-request in this revision). */ + capabilities?: Record; + /** Extra headers (e.g. `Authorization`) sent with every request. */ + headers?: Record; + /** Log level to opt into; omit to receive no `notifications/message`. */ + logLevel?: string; + /** Called for every notification received on a response stream. */ + onNotification?: (notification: { method: string; params?: Record }) => void; + /** How the client answers MRTR input requests. */ + handlers?: Mcp2026InputHandlers; + /** Maximum MRTR round trips before giving up, guarding against a server that never settles. */ + maxInputRounds?: number; + /** Injected for tests. */ + fetchImpl?: typeof fetch; +} + +export class Mcp2026Error extends Error { + constructor( + readonly code: number, + message: string, + readonly data?: unknown, + ) { + super(message); + this.name = 'Mcp2026Error'; + } +} + +interface JsonRpcResponse { + id?: string | number | null; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +} + +const DEFAULT_CLIENT_INFO: Implementation = { name: 'frontmcp-2026-client', version: '1.0.0' }; + +export class Mcp2026Client { + private nextId = 1; + private readonly options: Required> & Mcp2026ClientOptions; + /** Cached tool input schemas, needed to derive `Mcp-Param-*` headers. */ + private toolSchemas = new Map(); + + constructor(options: Mcp2026ClientOptions) { + this.options = { maxInputRounds: 8, ...options }; + } + + private get fetchImpl(): typeof fetch { + return this.options.fetchImpl ?? fetch; + } + + /** Ask the server which versions, capabilities and identity it offers. */ + async discover(): Promise> { + return this.request('server/discover'); + } + + /** + * List tools, dropping any whose `x-mcp-header` annotations are invalid. + * + * The spec requires this: one malformed tool definition must not prevent the + * other tools from being used, so the offender is excluded and logged rather + * than failing the call. + */ + async listTools(): Promise>> { + const result = await this.request('tools/list'); + const tools = Array.isArray(result['tools']) ? (result['tools'] as Array>) : []; + + const usable: Array> = []; + for (const tool of tools) { + const validation = validateHeaderParams(tool['inputSchema']); + if (!validation.valid) { + this.warn(`Rejecting tool "${String(tool['name'])}": ${validation.reason}`); + continue; + } + this.toolSchemas.set(String(tool['name']), tool['inputSchema']); + usable.push(tool); + } + return usable; + } + + async callTool(name: string, args: Record = {}): Promise> { + return this.request('tools/call', { name, arguments: args }); + } + + async readResource(uri: string): Promise> { + return this.request('resources/read', { uri }); + } + + async listResources(): Promise> { + return this.request('resources/list'); + } + + async listPrompts(): Promise> { + return this.request('prompts/list'); + } + + async getPrompt(name: string, args: Record = {}): Promise> { + return this.request('prompts/get', { name, arguments: args }); + } + + /** + * Issue a request, resolving MRTR round trips and task handles transparently. + * + * The caller sees a single promise for the FINAL result, which is the whole + * point of centralising this: every entry point would otherwise need its own + * retry loop. + */ + async request(method: string, params: Record = {}): Promise> { + let carriedState: string | undefined; + let inputResponses: Record> | undefined; + + for (let round = 0; round <= this.options.maxInputRounds; round++) { + const attemptParams: Record = { ...params }; + if (inputResponses) attemptParams['inputResponses'] = inputResponses; + // MUST be echoed back verbatim, and MUST be omitted when the server sent none. + if (carriedState !== undefined) attemptParams['requestState'] = carriedState; + + const result = await this.send(method, attemptParams); + + const resultType = result['resultType']; + + if (resultType === 'task') { + return this.awaitTask(result['task'] as Record); + } + + if (resultType !== 'input_required') return result; + + const requests = + (result['inputRequests'] as Record }>) ?? {}; + inputResponses = await this.gatherInputs(requests); + carriedState = typeof result['requestState'] === 'string' ? (result['requestState'] as string) : undefined; + } + + throw new Mcp2026Error(-32603, `Server kept requesting input after ${this.options.maxInputRounds} rounds`); + } + + /** Fulfil each `inputRequests` entry using the configured handlers. */ + private async gatherInputs( + requests: Record }>, + ): Promise>> { + const answers: Record> = {}; + + for (const [key, request] of Object.entries(requests)) { + const handlers = this.options.handlers ?? {}; + switch (request.method) { + case 'elicitation/create': { + if (!handlers.onElicit) + throw new Mcp2026Error(-32603, 'Server requested elicitation but no handler is configured'); + answers[key] = await handlers.onElicit(request.params ?? {}); + break; + } + case 'sampling/createMessage': { + if (!handlers.onSample) + throw new Mcp2026Error(-32603, 'Server requested sampling but no handler is configured'); + answers[key] = await handlers.onSample(request.params ?? {}); + break; + } + case 'roots/list': { + if (!handlers.onListRoots) + throw new Mcp2026Error(-32603, 'Server requested roots but no handler is configured'); + answers[key] = (await handlers.onListRoots()) as unknown as Record; + break; + } + default: + throw new Mcp2026Error(-32603, `Unsupported input request: ${request.method}`); + } + } + + return answers; + } + + /** + * Poll a task handle to a terminal state, answering mid-flight input requests. + * + * Honours the server's `pollIntervalMs` hint rather than picking our own + * cadence — the server knows how long its work takes. + */ + private async awaitTask(task: Record): Promise> { + const taskId = String(task['taskId']); + const interval = typeof task['pollIntervalMs'] === 'number' ? (task['pollIntervalMs'] as number) : 250; + const ttl = typeof task['ttlMs'] === 'number' ? (task['ttlMs'] as number) : 60_000; + const deadline = Date.now() + ttl; + + let current = task; + while (Date.now() < deadline) { + const status = String(current['status']); + + if (status === 'completed') return (current['result'] as Record) ?? {}; + if (status === 'failed') { + const error = current['error'] as { code?: number; message?: string } | undefined; + throw new Mcp2026Error(error?.code ?? -32603, error?.message ?? 'Task failed'); + } + if (status === 'cancelled') throw new Mcp2026Error(-32603, `Task ${taskId} was cancelled`); + + if (status === 'input_required') { + const requests = + (current['inputRequests'] as Record }>) ?? {}; + const answers = await this.gatherInputs(requests); + await this.send('tasks/update', { taskId, inputResponses: answers }); + } else { + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + current = await this.send('tasks/get', { taskId }); + } + + throw new Mcp2026Error(-32603, `Task ${taskId} did not settle within ${ttl}ms`); + } + + /** Cancel a running task. */ + async cancelTask(taskId: string): Promise { + await this.send('tasks/cancel', { taskId }); + } + + /** + * Open a `subscriptions/listen` stream. + * + * Resolves once the server acknowledges the subscription, so callers know + * which notification types were actually honoured before they start waiting + * on them. + */ + async listen( + notifications: Record, + onNotification: (notification: { method: string; params?: Record }) => void, + ): Promise<{ acknowledged: Record; close: () => void }> { + const id = this.nextId++; + const { body, headers } = this.buildRequest('subscriptions/listen', { notifications }, id); + const controller = new AbortController(); + + const response = await this.fetchImpl(this.options.url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!response.ok || !response.body) { + throw new Mcp2026Error(-32603, `subscriptions/listen failed with HTTP ${response.status}`); + } + + let resolveAck: ((value: Record) => void) | undefined; + const acknowledged = new Promise>((resolve) => { + resolveAck = resolve; + }); + + void this.pumpSse(response.body, (message) => { + if (message['method'] === 'notifications/subscriptions/acknowledged') { + const params = message['params'] as Record | undefined; + resolveAck?.((params?.['notifications'] as Record) ?? {}); + return; + } + if (message['method']) onNotification(message as { method: string; params?: Record }); + }).catch(() => { + // Stream aborted by close() — expected. + }); + + return { acknowledged: await acknowledged, close: () => controller.abort() }; + } + + /** Build and issue one JSON-RPC request, returning its result. */ + private async send(method: string, params: Record): Promise> { + // Each retry MUST use a NEW id — the spec treats the retry as an + // independent request, not a continuation. + const id = this.nextId++; + const { body, headers } = this.buildRequest(method, params, id); + + const response = await this.fetchImpl(this.options.url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + const payload = await this.readResponse(response); + + if (payload.error) { + throw new Mcp2026Error(payload.error.code, payload.error.message, payload.error.data); + } + return payload.result ?? {}; + } + + /** Assemble the JSON-RPC body and its mirrored HTTP headers. */ + private buildRequest( + method: string, + params: Record, + id: number, + ): { body: Record; headers: Record } { + const meta: Record = { + [MCP_2026_META.protocolVersion]: PROTOCOL_2026_07_28, + [MCP_2026_META.clientInfo]: this.options.clientInfo ?? DEFAULT_CLIENT_INFO, + [MCP_2026_META.clientCapabilities]: this.options.capabilities ?? {}, + }; + if (this.options.logLevel) meta[MCP_2026_META.logLevel] = this.options.logLevel; + + const body = { jsonrpc: '2.0', id, method, params: { ...params, _meta: meta } }; + + const headers: Record = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': PROTOCOL_2026_07_28, + 'Mcp-Method': method, + ...(this.options.headers ?? {}), + }; + + const name = this.deriveName(method, params); + if (name !== undefined) headers['Mcp-Name'] = encodeHeaderValue(name); + + if (method === 'tools/call' && typeof params['name'] === 'string') { + Object.assign( + headers, + buildParamHeaders(this.toolSchemas.get(params['name'] as string), params['arguments'], encodeHeaderValue), + ); + } + + return { body, headers }; + } + + private deriveName(method: string, params: Record): string | undefined { + if (NAME_FROM_PARAMS_NAME.includes(method) && typeof params['name'] === 'string') return params['name'] as string; + if (NAME_FROM_PARAMS_URI.includes(method) && typeof params['uri'] === 'string') return params['uri'] as string; + return undefined; + } + + /** + * Read either framing the server may choose. + * + * A JSON body is the response outright; an SSE body carries this request's + * notifications followed by the final response, so notifications are forwarded + * as they arrive and the terminating message is returned. + */ + private async readResponse(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? ''; + + if (!contentType.includes('text/event-stream')) { + const text = await response.text(); + if (!text.trim()) return {}; + return JSON.parse(text) as JsonRpcResponse; + } + + let final: JsonRpcResponse | undefined; + if (response.body) { + await this.pumpSse(response.body, (message) => { + if (message['method']) { + this.options.onNotification?.(message as { method: string; params?: Record }); + return; + } + final = message as JsonRpcResponse; + }); + } + return final ?? {}; + } + + /** Drain an SSE body, handing each decoded JSON message to `onMessage`. */ + private async pumpSse( + body: ReadableStream, + onMessage: (message: Record) => void, + ): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + let separator: number; + while ((separator = buffer.search(/\r?\n\r?\n/)) !== -1) { + const block = buffer.slice(0, separator); + buffer = buffer.slice(separator).replace(/^\r?\n\r?\n/, ''); + + const data = block + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice('data:'.length).trimStart()) + .join('\n'); + if (!data) continue; + + try { + onMessage(JSON.parse(data) as Record); + } catch { + // A malformed frame is skipped rather than killing the stream — SSE + // comments and keep-alives legitimately carry no JSON. + } + } + } + } + + private warn(message: string): void { + console.warn(`[Mcp2026Client] ${message}`); + } +} + +/** Convenience: the capability object a client declaring the tasks extension sends. */ +export const TASKS_CLIENT_CAPABILITY = { extensions: { [TASKS_EXTENSION_ID]: {} } }; + +/** Re-exported so callers can check task terminality without importing the extension module. */ +export { TERMINAL_TASK_STATUSES }; diff --git a/libs/sdk/src/transport/mcp-2026/dispatcher.ts b/libs/sdk/src/transport/mcp-2026/dispatcher.ts index 02009018b..054270016 100644 --- a/libs/sdk/src/transport/mcp-2026/dispatcher.ts +++ b/libs/sdk/src/transport/mcp-2026/dispatcher.ts @@ -12,12 +12,23 @@ import { MCP_2026_ERROR_CODES, MCP_2026_REMOVED_METHODS, McpError, type Implemen import { type FrontMcpContext } from '../../context'; import { InputRequiredSignal, MissingClientCapabilityError } from '../../errors'; import { type Scope } from '../../scope'; +import { type TaskRecord } from '../../task/task.types'; import { buildScopedServerOptions } from '../build-scoped-server-options'; import { createMcpHandlers } from '../mcp-handlers'; import { buildDiscoverResult } from './discover'; -import { buildInputRequiredResult, decodeRequestState, MrtrExchange } from './mrtr'; +import { buildInputRequiredResult, MrtrExchange } from './mrtr'; +import { type RequestNotificationSink } from './request-notifications'; +import { computeRequestBinding, decodeRequestState, type RequestStateBinding } from './request-state'; import { type JsonRpcErrorPayload } from './request-validation'; -import { decorateResult, resolveCacheScope } from './result-decorator'; +import { decorateResult, orderListResult, resolveCacheScope } from './result-decorator'; +import { + buildCreateTaskResult, + clientSupportsTasks, + dispatchTasksMethod, + resolveTaskOwner, + TASKS_EXTENSION_ID, + TASKS_EXTENSION_METHODS, +} from './tasks-extension'; export interface DispatchOptions { scope: Scope; @@ -34,12 +45,149 @@ export interface DispatchOptions { signal?: AbortSignal; /** Lazily composed instructions for `server/discover`. */ composeInstructions?: () => string | undefined; + /** Collects `notifications/message` + `notifications/progress` for this request. */ + notificationSink?: RequestNotificationSink; + /** OpenTelemetry context echoed back on the result (SEP-414). */ + traceContext?: Record; } export type DispatchResult = | { kind: 'result'; result: Record } | { kind: 'error'; status: number; error: JsonRpcErrorPayload }; +/** + * Requests that MAY return an `InputRequiredResult`. + * + * The spec enumerates these three and adds "Servers MUST NOT send + * `InputRequiredResult` responses on any other client requests." + */ +export const MRTR_CAPABLE_METHODS = ['tools/call', 'prompts/get', 'resources/read']; + +/** + * Identify the caller for `requestState` binding. + * + * Falls back to a fixed anonymous marker rather than a random value: public + * servers must still be able to redeem their own state on the retry, and there + * is no principal to separate anonymous callers by. + */ +export function resolvePrincipal(authInfo: Record | undefined): string { + const clientId = authInfo?.['clientId']; + if (typeof clientId === 'string' && clientId.length > 0) return clientId; + const token = authInfo?.['token']; + if (typeof token === 'string' && token.length > 0) return `tok:${token.slice(0, 16)}`; + return 'anonymous'; +} + +/** + * Identify the caller for TASK ownership. + * + * Stricter than {@link resolvePrincipal}: a public-mode server mints an + * anonymous bootstrap token per request, which is a fine binding for a + * short-lived `requestState` but must NOT be mistaken for an identity that can + * own a durable task. Anonymous callers resolve to `'anonymous'` so task + * creation is refused rather than pooled across unrelated users. + */ +export function resolveTaskPrincipal(authInfo: Record | undefined, isAnonymous = false): string { + // A public-mode server mints an anonymous session — complete with a synthetic + // subject — for every unauthenticated caller. That subject is fine for binding + // a short-lived `requestState`, but treating it as a task OWNER would pool + // unrelated anonymous users into one task namespace, so it is rejected here. + if (isAnonymous) return 'anonymous'; + + const clientId = authInfo?.['clientId']; + return typeof clientId === 'string' && clientId.length > 0 ? clientId : 'anonymous'; +} + +type TaskDecision = { kind: 'skip' } | { kind: 'create'; owner: string } | { kind: 'refuse'; reason: string }; + +/** + * Decide whether this `tools/call` should be answered with a task handle. + * + * Three things must line up: the tool has to declare task support, the client + * has to declare the extension, and the caller has to be identifiable (a task + * outlives the request, and this revision has no session to scope it by). + */ +function shouldCreateTask(params: { + scope: Scope; + method: string; + params: Record; + clientCapabilities: Record; + authInfo: Record | undefined; + isAnonymous: boolean; +}): TaskDecision { + if (params.method !== 'tools/call') return { kind: 'skip' }; + if (!params.scope.taskStore) return { kind: 'skip' }; + + const toolName = params.params['name']; + if (typeof toolName !== 'string') return { kind: 'skip' }; + + const tool = params.scope.tools + .getTools(true) + .find((entry) => entry.fullName === toolName || entry.metadata.name === toolName); + const taskSupport = tool?.metadata.execution?.taskSupport; + if (taskSupport !== 'required' && taskSupport !== 'optional') return { kind: 'skip' }; + + if (!clientSupportsTasks(params.clientCapabilities)) { + // A tool that can ONLY run as a task cannot serve a client that has no way + // to poll for the outcome, so say so rather than silently blocking. + if (taskSupport === 'required') { + return { + kind: 'refuse', + reason: `Tool "${toolName}" runs as a task; declare the ${TASKS_EXTENSION_ID} extension in clientCapabilities`, + }; + } + return { kind: 'skip' }; + } + + const ownership = resolveTaskOwner(resolveTaskPrincipal(params.authInfo, params.isAnonymous)); + if (!ownership.ok) return { kind: 'refuse', reason: ownership.reason }; + + return { kind: 'create', owner: ownership.owner }; +} + +/** + * Re-run a task that `tasks/update` moved back to `working`. + * + * The accumulated `inputResponses` are replayed into the tool through a fresh + * MRTR exchange, so a tool that asked for input resolves it inline this time — + * exactly the replay model the request-scoped MRTR path uses. + */ +async function resumeTask(params: { + scope: Scope; + record: TaskRecord; + authInfo: Record; + clientCapabilities: Record; + frontmcpContext?: FrontMcpContext; +}): Promise { + const { scope, record, authInfo, clientCapabilities, frontmcpContext } = params; + const registry = scope.tasks; + const runner = registry?.runner; + if (!runner) { + scope.logger.warn('mcp-2026: cannot resume task, no runner configured', { taskId: record.taskId }); + return; + } + + // The resumed run needs its own MRTR exchange, seeded with everything the + // client has answered so far. Without it `elicit()` would fall through to the + // legacy fallback path and ask again instead of consuming the answer that + // `tasks/update` just supplied. + frontmcpContext?.setMrtrExchange( + new MrtrExchange({ + carriedResponses: record.inputResponses ?? {}, + clientCapabilities, + binding: { + principal: resolveTaskPrincipal(authInfo), + binding: computeRequestBinding('tasks/resume', { name: record.taskId }), + }, + }), + ); + + await runner.run(record, { + cleanedRequestParams: record.request.params, + ctx: { authInfo }, + }); +} + /** Read the JSON-RPC method literal a handler's request schema is bound to. */ function methodOfSchema(schema: unknown): string | undefined { const shape = (schema as { shape?: Record } | undefined)?.shape; @@ -93,13 +241,25 @@ export function toJsonRpcError(error: unknown): { status: number; error: JsonRpc * response as a stream, so the flow handles it before calling in. */ export async function dispatch2026(options: DispatchOptions): Promise { - const { scope, body, clientCapabilities, frontmcpContext, authInfo, isAnonymous, signal, composeInstructions } = - options; + const { + scope, + body, + clientCapabilities, + frontmcpContext, + authInfo, + isAnonymous, + signal, + composeInstructions, + notificationSink, + traceContext, + } = options; const method = body['method'] as string; const params = (body['params'] as Record | undefined) ?? {}; const cacheScope = resolveCacheScope(isAnonymous); const serverInfo = scope.metadata.info as Implementation; + const decorate = (raw: Record): Record => + decorateResult(orderListResult(method, raw), { method, serverInfo, cacheScope, traceContext }); if ((MCP_2026_REMOVED_METHODS as readonly string[]).includes(method)) { return { @@ -109,14 +269,47 @@ export async function dispatch2026(options: DispatchOptions): Promise + resumeTask({ + scope, + record, + authInfo: { ...(authInfo ?? {}), sessionId: ownership.owner }, + clientCapabilities, + frontmcpContext, + }), + }); + + return outcome.kind === 'result' ? { kind: 'result', result: decorate(outcome.result) } : outcome; + } + if (method === 'server/discover') { return { kind: 'result', - result: decorateResult(buildDiscoverResult(scope, composeInstructions?.()) as Record, { - method, - serverInfo, - cacheScope, - }), + result: decorate(buildDiscoverResult(scope, composeInstructions?.()) as Record), }; } @@ -130,17 +323,48 @@ export async function dispatch2026(options: DispatchOptions): Promise> | undefined, - carriedResponses: decodeRequestState(params['requestState']), + carriedResponses: carried.ok ? carried.responses : {}, clientCapabilities, + binding, }); frontmcpContext?.setMrtrExchange(exchange); + if (notificationSink) frontmcpContext?.setRequestNotificationSink(notificationSink); + + // Tasks are no longer opted into per request (`params.task` is gone). A server + // MAY hand back a task handle whenever the work is long-running, gated only on + // the CLIENT declaring the extension. Reuse the existing task-creation stage by + // supplying the augmentation it still keys off internally. + const taskDecision = shouldCreateTask({ scope, method, params, clientCapabilities, authInfo, isAnonymous }); + if (taskDecision.kind === 'refuse') { + return { kind: 'error', status: 200, error: { code: -32602, message: taskDecision.reason } }; + } + + const dispatchBody = taskDecision.kind === 'create' ? { ...body, params: { ...params, task: {} } } : body; const ctx = { signal: signal ?? new AbortController().signal, requestId: body['id'] as string | number, - authInfo, + // Tasks outlive the request, so they are stored under the caller's stable + // principal rather than a per-request identifier that would never be found + // again by `tasks/get`. + authInfo: taskDecision.kind === 'create' ? { ...(authInfo ?? {}), sessionId: taskDecision.owner } : authInfo, sendNotification: async () => undefined, sendRequest: async () => { // 2026-07-28 removed the server→client request direction outright. A @@ -150,14 +374,33 @@ export async function dispatch2026(options: DispatchOptions): Promise; - return { kind: 'result', result: decorateResult(raw, { method, serverInfo, cacheScope }) }; + const raw = (await handler.handler(dispatchBody as never, ctx as never)) as Record; + + // The shared stage answers with a 2025-shaped `{ task }` result; project it + // onto this revision's `resultType: "task"` envelope. + if (taskDecision.kind === 'create' && raw['task']) { + const created = await scope.taskStore?.get((raw['task'] as { taskId: string }).taskId, taskDecision.owner); + if (created) return { kind: 'result', result: decorate(buildCreateTaskResult(created)) }; + } + + return { kind: 'result', result: decorate(raw) }; } catch (error) { if (error instanceof InputRequiredSignal) { - return { - kind: 'result', - result: decorateResult(buildInputRequiredResult(error), { method, serverInfo, cacheScope }), - }; + // The spec restricts interim results to prompts/get, resources/read and + // tools/call. Anywhere else an `input_required` result would be a protocol + // violation the client is not expecting, so surface it as a server error + // instead of emitting a response no conforming client can act on. + if (!MRTR_CAPABLE_METHODS.includes(method)) { + return { + kind: 'error', + status: 200, + error: { + code: -32603, + message: `Internal error: ${method} cannot return an input_required result under protocol 2026-07-28`, + }, + }; + } + return { kind: 'result', result: decorate(buildInputRequiredResult(error)) }; } const mapped = toJsonRpcError(error); return { kind: 'error', status: mapped.status, error: mapped.error }; diff --git a/libs/sdk/src/transport/mcp-2026/index.ts b/libs/sdk/src/transport/mcp-2026/index.ts index 7187004c1..9f0ae52ec 100644 --- a/libs/sdk/src/transport/mcp-2026/index.ts +++ b/libs/sdk/src/transport/mcp-2026/index.ts @@ -10,8 +10,12 @@ export * from './protocol-2026.constants'; export * from './header-codec'; export * from './request-validation'; +export * from './request-state'; +export * from './request-notifications'; export * from './result-decorator'; export * from './discover'; export * from './mrtr'; export * from './subscriptions'; +export * from './tasks-extension'; export * from './dispatcher'; +export * from './client'; diff --git a/libs/sdk/src/transport/mcp-2026/mrtr.ts b/libs/sdk/src/transport/mcp-2026/mrtr.ts index 4f4329d6a..fbf5dfab3 100644 --- a/libs/sdk/src/transport/mcp-2026/mrtr.ts +++ b/libs/sdk/src/transport/mcp-2026/mrtr.ts @@ -3,28 +3,40 @@ * * ## How a round trip works * - * 1. The tool calls `this.elicit(...)`. No response is recorded for that call - * yet, so the exchange records the pending request and throws - * {@link InputRequiredSignal}. + * 1. The tool calls `this.elicit(...)` / `this.sample(...)` / `this.listRoots()`. + * No response is recorded for that call yet, so the exchange records the + * pending request and throws {@link InputRequiredSignal}. * 2. The dispatcher turns the signal into an `InputRequiredResult` - * (`resultType: "input_required"`) carrying `inputRequests` and an opaque - * `requestState`. - * 3. The client gathers the input and re-issues the SAME request with - * `inputResponses` + `requestState`. - * 4. The tool runs again from the top. This time `elicit()` finds a recorded - * answer for its call and returns it inline, so execution proceeds. + * (`resultType: "input_required"`) carrying `inputRequests` and an opaque, + * integrity-protected `requestState`. + * 3. The client gathers the input and re-issues the SAME request (with a NEW + * JSON-RPC id) carrying `inputResponses` + the echoed `requestState`. + * 4. The entry runs again from the top. This time each call finds a recorded + * answer and returns it inline, so execution proceeds. * - * Tools are therefore replayed, not resumed — which is why the keys are derived - * from the call ORDER (`elicit-1`, `elicit-2`, …) rather than randomly: the - * second run must line its calls up with the first run's answers. + * Entries are therefore replayed, not resumed — which is why keys are derived + * from the call ORDER (`elicit-1`, `sampling-1`, `roots-1`, …) rather than + * randomly: the second run must line its calls up with the first run's answers. * * `requestState` accumulates every answer gathered so far, so a multi-step tool * converges even if the client only echoes the most recent `inputResponses`. + * It is signed and bound to the caller and the originating request — see + * {@link ./request-state}. */ import type { InputRequests, InputResponses } from '@frontmcp/protocol'; import { type ElicitStatus } from '../../elicitation'; import { InputRequiredSignal, MissingClientCapabilityError } from '../../errors'; +import { encodeRequestState, type RequestStateBinding } from './request-state'; + +/** Requests the client may be asked to fulfil, and the capability each needs. */ +const CAPABILITY_FOR_KIND = { + elicitation: { capability: 'elicitation', required: { elicitation: { form: {} } } }, + sampling: { capability: 'sampling', required: { sampling: {} } }, + roots: { capability: 'roots', required: { roots: {} } }, +} as const; + +export type MrtrRequestKind = keyof typeof CAPABILITY_FOR_KIND; /** Shape recorded for a pending elicitation before it becomes an input request. */ export interface PendingElicitation { @@ -34,6 +46,31 @@ export interface PendingElicitation { url?: string; } +/** Parameters for a `sampling/createMessage` input request. */ +export interface PendingSampling { + messages: unknown[]; + maxTokens: number; + systemPrompt?: string; + modelPreferences?: Record; + temperature?: number; + stopSequences?: string[]; + includeContext?: 'none' | 'thisServer' | 'allServers'; + metadata?: Record; +} + +/** The client's answer to a `sampling/createMessage` request. */ +export interface SamplingAnswer { + role: string; + content: unknown; + model?: string; + stopReason?: string; +} + +/** The client's answer to a `roots/list` request. */ +export interface RootsAnswer { + roots: Array<{ uri: string; name?: string }>; +} + /** * Translate the wire-format elicitation answer to the SDK's internal shape. * @@ -49,39 +86,12 @@ function toElicitResult(response: Record): { status: ElicitStat }; } -interface DecodedRequestState { - responses: InputResponses; -} - -/** Encode accumulated answers into the opaque blob the client echoes back. */ -export function encodeRequestState(responses: InputResponses): string { - return Buffer.from(JSON.stringify({ responses } satisfies DecodedRequestState), 'utf8').toString('base64url'); -} - -/** - * Decode a client-echoed `requestState`. - * - * A malformed blob is treated as "no prior answers" rather than an error: the - * value is opaque to the client, so the only way it can be wrong is if it was - * tampered with or truncated, and restarting the exchange is safer than failing - * the call. - */ -export function decodeRequestState(state: unknown): InputResponses { - if (typeof state !== 'string' || state.length === 0) return {}; - try { - const parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')) as DecodedRequestState; - return parsed && typeof parsed.responses === 'object' && parsed.responses !== null ? parsed.responses : {}; - } catch { - return {}; - } -} - /** * Per-request bookkeeping for one MRTR exchange. * * Lives on the `FrontMcpContext` for the duration of a single dispatch, so - * `elicit()` deep inside a tool can reach it without threading it through - * every flow stage. + * `elicit()` / `sample()` / `listRoots()` deep inside an entry can reach it + * without threading it through every flow stage. */ export class MrtrExchange { /** Answers already supplied by the client, keyed by input-request key. */ @@ -90,63 +100,111 @@ export class MrtrExchange { /** Requests raised during THIS run that the client still has to answer. */ private readonly pending: InputRequests = {}; - /** Number of `elicit()` calls seen so far, used to derive stable keys. */ - private elicitCount = 0; + /** Per-kind call counters, used to derive stable keys across a replay. */ + private readonly counters: Record = { elicitation: 0, sampling: 0, roots: 0 }; + + readonly clientCapabilities: Record; + + private readonly binding: RequestStateBinding; constructor(params: { /** `inputResponses` from the request params. */ inputResponses?: InputResponses; - /** Answers carried over from earlier rounds via `requestState`. */ + /** Answers carried over from earlier rounds via a verified `requestState`. */ carriedResponses?: InputResponses; /** Capabilities the client declared for this request. */ clientCapabilities: Record; + /** Principal + request digest that new state will be bound to. */ + binding: RequestStateBinding; }) { // Fresh `inputResponses` win over carried ones for the same key: the client // is answering the question we just asked. this.responses = { ...(params.carriedResponses ?? {}), ...(params.inputResponses ?? {}) }; this.clientCapabilities = params.clientCapabilities; + this.binding = params.binding; } - readonly clientCapabilities: Record; + /** True when the client declared the capability a given request kind needs. */ + supports(kind: MrtrRequestKind): boolean { + const declared = this.clientCapabilities[CAPABILITY_FOR_KIND[kind].capability]; + return typeof declared === 'object' && declared !== null; + } /** True when the client declared support for elicitation in this request. */ supportsElicitation(): boolean { - return ( - typeof this.clientCapabilities['elicitation'] === 'object' && this.clientCapabilities['elicitation'] !== null - ); + return this.supports('elicitation'); } /** - * Resolve the next `elicit()` call. + * Look up a recorded answer for the next call of `kind`, or record the + * request and unwind. * - * Returns the recorded answer when the client already supplied one, otherwise - * records the request and throws so the dispatcher can ask for it. + * The spec forbids asking for something the client never said it supports, so + * an undeclared capability fails fast with `-32021` rather than emitting an + * `inputRequests` entry the client cannot honor. */ - resolveElicitation(pending: PendingElicitation): { status: ElicitStatus; content?: unknown } { - this.elicitCount += 1; - const key = `elicit-${this.elicitCount}`; + private resolve( + kind: MrtrRequestKind, + method: string, + params: Record, + map: (raw: Record) => T, + ): T { + this.counters[kind] += 1; + const key = `${kind}-${this.counters[kind]}`; const recorded = this.responses[key]; - if (recorded) return toElicitResult(recorded); + if (recorded) return map(recorded); - if (!this.supportsElicitation()) { + if (!this.supports(kind)) { throw new MissingClientCapabilityError( - { elicitation: { form: {} } }, - 'This request requires the `elicitation` client capability', + CAPABILITY_FOR_KIND[kind].required, + `This request requires the \`${CAPABILITY_FOR_KIND[kind].capability}\` client capability`, ); } - this.pending[key] = { - method: 'elicitation/create', - params: { + this.pending[key] = { method, params }; + throw new InputRequiredSignal(this.pending, encodeRequestState(this.responses, this.binding)); + } + + /** Resolve the next `elicit()` call. */ + resolveElicitation(pending: PendingElicitation): { status: ElicitStatus; content?: unknown } { + return this.resolve( + 'elicitation', + 'elicitation/create', + { message: pending.message, requestedSchema: pending.requestedSchema, ...(pending.mode ? { mode: pending.mode } : {}), ...(pending.url ? { url: pending.url } : {}), }, + toElicitResult, + ); + } + + /** Resolve the next `sample()` call. */ + resolveSampling(pending: PendingSampling): SamplingAnswer { + const params: Record = { + messages: pending.messages, + maxTokens: pending.maxTokens, }; + for (const key of ['systemPrompt', 'modelPreferences', 'temperature', 'stopSequences', 'metadata'] as const) { + if (pending[key] !== undefined) params[key] = pending[key]; + } + // `thisServer` / `allServers` are deprecated in this revision; only forward + // `includeContext` when the client declared it supports context inclusion. + const samplingCaps = this.clientCapabilities['sampling'] as { context?: unknown } | undefined; + if (pending.includeContext !== undefined && (pending.includeContext === 'none' || samplingCaps?.context)) { + params['includeContext'] = pending.includeContext; + } + + return this.resolve('sampling', 'sampling/createMessage', params, (raw) => raw as unknown as SamplingAnswer); + } - throw new InputRequiredSignal(this.pending, encodeRequestState(this.responses)); + /** Resolve the next `listRoots()` call. */ + resolveRoots(): RootsAnswer { + return this.resolve('roots', 'roots/list', {}, (raw) => ({ + roots: Array.isArray(raw['roots']) ? (raw['roots'] as RootsAnswer['roots']) : [], + })); } } diff --git a/libs/sdk/src/transport/mcp-2026/request-notifications.ts b/libs/sdk/src/transport/mcp-2026/request-notifications.ts new file mode 100644 index 000000000..e9adb2158 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/request-notifications.ts @@ -0,0 +1,127 @@ +/** + * Request-scoped notifications for protocol 2026-07-28. + * + * Two rules from SEP-2575 shape this: + * + * - `notifications/progress` and `notifications/message` flow on the response + * stream of the request they relate to — never on the `subscriptions/listen` + * stream, and never on a session channel (there are no sessions). + * - `logging/setLevel` is gone. The client opts in per request via + * `_meta["io.modelcontextprotocol/logLevel"]`, and a server **MUST NOT** emit + * `notifications/message` for a request that omitted it. + * + * The sink is attached to the `FrontMcpContext` for the duration of one + * dispatch, so `this.notify()` / `this.progress()` deep inside an entry reach it + * without any session lookup. + */ +import { type LoggingLevel } from '@frontmcp/protocol'; + +/** MCP severity order, least to most severe (RFC 5424). */ +const LEVEL_ORDER: LoggingLevel[] = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']; + +/** True when `level` is at least as severe as the client's requested minimum. */ +export function meetsLogLevel(level: LoggingLevel, minimum: LoggingLevel): boolean { + const at = LEVEL_ORDER.indexOf(level); + const min = LEVEL_ORDER.indexOf(minimum); + if (at === -1 || min === -1) return true; + return at >= min; +} + +export interface QueuedRequestNotification { + method: string; + params: Record; +} + +/** + * Collects notifications raised while handling one request and hands them to + * the transport in arrival order. + * + * Deliberately unbounded-but-drained: the transport consumes as it streams, and + * a request that ends without streaming simply discards what it buffered (a + * client that asked for neither logs nor progress gets neither). + */ +export class RequestNotificationSink { + private readonly queue: QueuedRequestNotification[] = []; + private wake: (() => void) | undefined; + private finished = false; + + constructor( + /** Minimum severity the client opted into, or undefined for "no logs". */ + private readonly logLevel: LoggingLevel | undefined, + /** Progress token from `_meta`, or undefined for "no progress". */ + private readonly progressToken: string | number | undefined, + ) {} + + /** True when the client opted into anything at all. */ + get active(): boolean { + return this.logLevel !== undefined || this.progressToken !== undefined; + } + + /** + * Queue a log message. + * + * Dropped outright when the client did not set `logLevel` — the spec makes + * that a MUST NOT, not a preference. + */ + log(level: LoggingLevel, logger: string | undefined, data: unknown): boolean { + if (this.logLevel === undefined) return false; + if (!meetsLogLevel(level, this.logLevel)) return false; + + this.push('notifications/message', { + level, + ...(logger ? { logger } : {}), + data, + }); + return true; + } + + /** Queue a progress notification, if the client supplied a progress token. */ + progress(progress: number, total?: number, message?: string): boolean { + if (this.progressToken === undefined) return false; + + this.push('notifications/progress', { + progressToken: this.progressToken, + progress, + ...(total === undefined ? {} : { total }), + ...(message === undefined ? {} : { message }), + }); + return true; + } + + private push(method: string, params: Record): void { + if (this.finished) return; + this.queue.push({ method, params }); + this.wake?.(); + } + + /** Everything queued so far, cleared from the sink. */ + drain(): QueuedRequestNotification[] { + return this.queue.splice(0, this.queue.length); + } + + /** Signal that no further notifications will be raised. */ + close(): void { + this.finished = true; + this.wake?.(); + } + + /** + * Wait until something is queued or the sink closes. + * + * Returns immediately when work is already pending, so a fast producer never + * makes the consumer sleep on a non-empty queue. + */ + async waitForActivity(): Promise { + if (this.queue.length > 0 || this.finished) return; + await new Promise((resolve) => { + this.wake = () => { + this.wake = undefined; + resolve(); + }; + }); + } + + get closed(): boolean { + return this.finished; + } +} diff --git a/libs/sdk/src/transport/mcp-2026/request-state.ts b/libs/sdk/src/transport/mcp-2026/request-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..d76c88cbb8a1a9595c8f3f8435a91335079ea26d GIT binary patch literal 5941 zcmai2e{mV<7?B55<5|1-6ZL`ceUiZKQd_ql8}U&1at%#M|C{Y z57AH9PtvylNd1TtXEKsSkh_bwZ{IHX=4(Y9Sa#1O_?yoB2WaeZ}vr7I^OY0_1 zI60%asp$M-dO`pD&wq*UGMOpp_~`ga|LCv% z!a0#$$*P|j?J83gxUBT5cEcFVDZQQV`&D{g)KX^>&)05gD)n#Sl+it^q&0PwNfH@W zZI7_|w6;p)2gh_ zUU)pCVZ%FMe0kFVm@NaYRi$*MR-)*UAPY1cXH8XW{Dw(>00=FVMjS90*}?J9s3@>r zNti@tq(*`=Sma!Jx(G@OlTyim6mCef=c%yr$)D@0 zNLFH97?IPv$uEEJo({HFIA{IIo6+%Ko^(%VY~#^^Dp!oo4Y~D7qIN*D3u#wITf}%9 z0_?m*oZ6My>QPfHooAaTm!-%iO987O8{jqNtF`0st-tt2e!jy{?Z5_DId}x3y)oAy zArc`PTtj;Cxn}rsDNB(Sa=;M-9gY~qVI7@~e;Qr>JpFhv{>SBbGW|H2j;7;})9KG2 z&nI+3kBUkj zVIxZSO^s4(iW>FV)P`J{Y)Pe!zU7c~U8b^nc5r*p;C?rPuVQJo2;RM2FFvS9$JyjB zXraWM*<0vUTC;1h{@d^_Z(6B)pc-z}8qj`%IwQa4$ajA3rgN>`6{6|3d?? zMh~eYQj0q8z;34$1@xzsx&M`lyl>S4qE%PYUkOSPR$I;Mm&!sFvZcQza&&Vs_`5|h z6q3~>Tgv=zay_I=r1t5ksvx#s2+Awx=n;7+QI;TV*%Y5x&1H~(racyT?OaurLB;S| z#sv#076pA0013*tlB*T84cIW@(3U8f{pJgIGJbtAp3;jK^zL_*um3gcu}PSM_C-a3 zg^K$*cQ9B*tLt)6LNDMz^))g=IU0pom#mS#Gm8R{=4-YEv6!m`P_tw-w4XU+EEp&e zEV|o*KeEbL>rM4Tmug6g3PBd2g;M$!@w>v)HJaAOE+oR6@&EuAmi?`edvv{2C@l-s zE4YDVSHAZ;QQ|IcK`HE&;tBxzGdi_Q7Ogc*bpke^)zY<+{}VI?$!D6R8aXFf@QWt;h6Nz&cn370#XTGFzpiyVWU zdc!NGThQu=&ptehJ|jAymtF)l2)^k@EodOw zov?i}Dklc41q)l)Dzo66*~;n^X@Dt+K&>AS3ISyH@ybQIIL7Ry?H9rXuHB#TBfXEP8&rFp+58bwh%v@=6 ztxvbL5abWbA&-6nvBFo?x1T?u8;C7=2}wdFus~q%?>mfqr$?Q-QvB5--uck=E<$yM z2wx1|m-`% z-p)`XpkxbXJ79()dxu_?cp)A(Q0<}ZXNKTo9wv?GS0hq^QWEez?q4kbdpZ% zs}q9YqI}J03+?*g33?2O3qm7^&<4;Nscnr^NP&}tM(rpJR0~XN+(pBggG;vER9K&g zxg7sdiy{$Cf_s~33EN6hEK+En5|%qLD5PGvrO!ngf3(uqc6IKrOlH(MX3xj~zNq{a zd_AYe)H6~KA9lm-Urum2TZ-y6G;HJ&)un$@x_gD~MTmn7umVoDGsbu%smamWLw@f~ z&}NcP?vnoAw}qD7bi4QE@Ep1cSwKGSJEHr!Y`+}^QQz)JiAUiZ0~e-;f2SIUcumJv0Xj)IOXbQ)obqFtS@B%=K~T9JGU5SizXy ztiuY~#&i&lWLys4{KA&*Wgl0SP-ZZ2!Ca5*$TUB7_q7ppji6~bXfb^nP7BeRo zE}v!jpp}6XcnjgR-j6e=KMX{Wem}l(uRsCf;2zqcg=`WgFg{6pUF9AT!onDiztNtS zLNB;+LNqFe!^DN3OW>Wi1$UAMRxr^zHhgXBf_pBXUwMl@E;J_|68#i~nc87IBR-^| zg#Bv+cw%E%XwFc6KK!~71?aXZnD~cU>Fn_l%)hGIE2G~1;@G)UCzj1SMKn)zs^@cEZ{ z(iyN=hghs^?tFf~tF1H6wzo*-;K<^In@o=Q9aXgKeFf5Qbiau!+}_@cVtI`FV{kw0 zi+=k&%r}w=Edv;SlD!+Cy|Y+|oVERXdcgQ#T8W|%?*y8@F=As@CfogzAwYX#&Favd66wQ$HU8KK`ZHMrURT=z+7ZyICEwwyt(lWrg@w?vI6SY@WF097E$~$Q4 ybfen$7;M#ja{>>Fy=^<)wHH`N0G literal 0 HcmV?d00001 diff --git a/libs/sdk/src/transport/mcp-2026/result-decorator.ts b/libs/sdk/src/transport/mcp-2026/result-decorator.ts index e141307cc..bb4b0ea0c 100644 --- a/libs/sdk/src/transport/mcp-2026/result-decorator.ts +++ b/libs/sdk/src/transport/mcp-2026/result-decorator.ts @@ -29,6 +29,13 @@ export interface DecorateResultOptions { cacheScope?: 'public' | 'private'; /** Override for the per-method TTL default. */ ttlMs?: number; + /** + * OpenTelemetry context to echo back (SEP-414). + * + * Propagating `traceparent` on the response lets a client stitch its span to + * the server's without an out-of-band correlation id. + */ + traceContext?: Record; } /** Attach the 2026-07-28 envelope fields to a handler's raw result. */ @@ -36,7 +43,7 @@ export function decorateResult( result: Record, options: DecorateResultOptions, ): Record { - const { method, serverInfo, cacheScope = 'private', ttlMs } = options; + const { method, serverInfo, cacheScope = 'private', ttlMs, traceContext } = options; const existingMeta = (result['_meta'] as Record | undefined) ?? {}; const decorated: Record = { @@ -46,6 +53,7 @@ export function decorateResult( resultType: typeof result['resultType'] === 'string' ? result['resultType'] : 'complete', _meta: { ...existingMeta, + ...(traceContext ?? {}), [MCP_2026_META.serverInfo]: serverInfo, }, }; @@ -58,6 +66,40 @@ export function decorateResult( return decorated; } +/** List results whose entries this revision asks servers to order deterministically. */ +const ORDERED_LIST_FIELDS: Record = { + 'tools/list': 'tools', + 'prompts/list': 'prompts', + 'resources/list': 'resources', + 'resources/templates/list': 'resourceTemplates', +}; + +/** + * Sort list entries by name so repeated calls agree byte-for-byte. + * + * 2026-07-28 asks servers to return `tools/list` in a deterministic order so + * clients can cache and so an LLM's prompt cache keeps hitting. Registration + * order is already stable in practice, but it shifts the moment a tool is + * registered dynamically — sorting makes the guarantee explicit. + * + * Applied only on the 2026 path; older revisions keep their existing order. + */ +export function orderListResult(method: string, result: Record): Record { + const field = ORDERED_LIST_FIELDS[method]; + if (!field) return result; + + const entries = result[field]; + if (!Array.isArray(entries)) return result; + + const sorted = [...entries].sort((a, b) => { + const left = String((a as { name?: unknown; uri?: unknown })?.name ?? (a as { uri?: unknown })?.uri ?? ''); + const right = String((b as { name?: unknown; uri?: unknown })?.name ?? (b as { uri?: unknown })?.uri ?? ''); + return left < right ? -1 : left > right ? 1 : 0; + }); + + return { ...result, [field]: sorted }; +} + /** * Choose a cache scope for a request. * diff --git a/libs/sdk/src/transport/mcp-2026/tasks-extension.ts b/libs/sdk/src/transport/mcp-2026/tasks-extension.ts new file mode 100644 index 000000000..295758171 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/tasks-extension.ts @@ -0,0 +1,191 @@ +/** + * `io.modelcontextprotocol/tasks` extension — protocol 2026-07-28, SEP-2663. + * + * Tasks moved out of the core protocol into an official extension and were + * redesigned: + * + * - The blocking `tasks/result` is gone; clients poll `tasks/get`. + * - `tasks/list` is gone entirely. + * - `tasks/update` is new — it feeds `inputResponses` to a task that has paused + * in `input_required`, which is how a long-running operation does + * human-in-the-loop without a second connection. + * - Servers MAY return a task handle unsolicited; there is no per-request + * `params.task` opt-in any more. The gate is the CLIENT declaring the + * extension in its per-request capabilities. + * + * The wire shape also renames `ttl` → `ttlMs` and `pollInterval` → `pollIntervalMs`, + * and a task-bearing response is discriminated by `resultType: "task"` rather + * than by a `task` field on a normal result. + */ +import { type Scope } from '../../scope'; +import { type TaskRecord } from '../../task/task.types'; + +/** Extension identifier, as declared in capabilities. */ +export const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks'; + +/** RPCs this extension defines. */ +export const TASKS_EXTENSION_METHODS = ['tasks/get', 'tasks/update', 'tasks/cancel']; + +/** Terminal statuses — a task in one of these never changes again. */ +export const TERMINAL_TASK_STATUSES = ['completed', 'failed', 'cancelled']; + +/** True when the client declared support for the tasks extension on this request. */ +export function clientSupportsTasks(clientCapabilities: Record): boolean { + const extensions = clientCapabilities['extensions']; + if (!extensions || typeof extensions !== 'object') return false; + return TASKS_EXTENSION_ID in (extensions as Record); +} + +/** + * Project a stored task onto the 2026-07-28 `Task` wire shape. + * + * `result` / `error` only appear once the task is terminal, and `inputRequests` + * only while it is paused waiting for the client — mirroring what the client is + * actually allowed to act on at each point in the lifecycle. + */ +export function taskToWire2026(record: TaskRecord): Record { + const wire: Record = { + taskId: record.taskId, + status: record.status, + createdAt: record.createdAt, + lastUpdatedAt: record.lastUpdatedAt, + ttlMs: record.ttlMs, + }; + + if (record.pollIntervalMs !== undefined) wire['pollIntervalMs'] = record.pollIntervalMs; + if (record.statusMessage !== undefined) wire['statusMessage'] = record.statusMessage; + + if (record.status === 'completed' && record.outcome?.kind === 'ok') { + wire['result'] = record.outcome.data; + } + if (record.status === 'failed' && record.outcome?.kind === 'error') { + wire['error'] = record.outcome.error; + } + if (record.status === 'input_required' && record.inputRequests) { + wire['inputRequests'] = record.inputRequests; + } + + return wire; +} + +/** + * Build the `CreateTaskResult` a server returns instead of an inline result. + * + * Discriminated by `resultType: "task"`, which is why the shared result + * decorator must not overwrite it. + */ +export function buildCreateTaskResult(record: TaskRecord): Record { + return { + resultType: 'task', + task: taskToWire2026(record), + }; +} + +/** + * Derive the owner key a task is stored under. + * + * 2026-07-28 has no protocol-level sessions, so a task must be keyed by + * something that survives across independent requests — the authenticated + * principal. Anonymous callers have no such identity: two unrelated users would + * share one key and could read each other's task results, so tasks are refused + * rather than silently pooled. + */ +export function resolveTaskOwner(principal: string): { ok: true; owner: string } | { ok: false; reason: string } { + if (!principal || principal === 'anonymous') { + return { + ok: false, + reason: + 'Tasks require an authenticated caller under protocol 2026-07-28: there are no protocol sessions, so an anonymous task could not be scoped to its creator', + }; + } + return { ok: true, owner: `principal:${principal}` }; +} + +export type TasksDispatchOutcome = + | { kind: 'result'; result: Record } + | { kind: 'error'; status: number; error: { code: number; message: string; data?: unknown } }; + +export interface TasksDispatchOptions { + scope: Scope; + method: string; + params: Record; + /** Owner key the task is stored under (see {@link resolveTaskOwner}). */ + owner: string; + /** Re-runs a resumed task in the background. */ + resume: (record: TaskRecord) => Promise; +} + +const TASK_NOT_FOUND = { code: -32602, message: 'Task not found' }; + +/** + * Serve `tasks/get`, `tasks/update` and `tasks/cancel`. + * + * These read and mutate the SAME store the 2025-era task methods use, so a + * task created under either revision is visible to the other — only the wire + * shape and the method set differ. + */ +export async function dispatchTasksMethod(options: TasksDispatchOptions): Promise { + const { scope, method, params, owner, resume } = options; + + const store = scope.taskStore; + if (!store) { + return { kind: 'error', status: 200, error: { code: -32603, message: 'Task store is not configured' } }; + } + + const taskId = typeof params['taskId'] === 'string' ? (params['taskId'] as string) : undefined; + if (!taskId) { + return { kind: 'error', status: 200, error: { code: -32602, message: 'taskId is required' } }; + } + + const record = await store.get(taskId, owner); + // A task belonging to a different principal must be indistinguishable from + // one that does not exist, or task ids become an existence oracle. + if (!record) return { kind: 'error', status: 200, error: TASK_NOT_FOUND }; + + if (method === 'tasks/get') { + return { kind: 'result', result: taskToWire2026(record) }; + } + + if (method === 'tasks/cancel') { + if (TERMINAL_TASK_STATUSES.includes(record.status)) { + // Cancellation is cooperative and a terminal task is already done; the + // spec asks servers to acknowledge rather than error. + return { kind: 'result', result: {} }; + } + await store.update(taskId, owner, { + status: 'cancelled', + statusMessage: 'The task was cancelled by the client.', + }); + await store.publishCancel(taskId, owner); + return { kind: 'result', result: {} }; + } + + // tasks/update — feed answers to a task parked in `input_required`. + const inputResponses = params['inputResponses']; + if (!inputResponses || typeof inputResponses !== 'object') { + return { kind: 'error', status: 200, error: { code: -32602, message: 'inputResponses is required' } }; + } + + if (record.status !== 'input_required') { + return { + kind: 'error', + status: 200, + error: { code: -32602, message: `Task ${taskId} is not awaiting input (status: ${record.status})` }, + }; + } + + // Merge rather than replace: a multi-step task accumulates answers across + // several updates, and the spec says to ignore unknown or already-satisfied + // keys rather than reject them. + const merged = { ...(record.inputResponses ?? {}), ...(inputResponses as Record>) }; + const resumed = await store.update(taskId, owner, { + status: 'working', + statusMessage: 'The operation is now in progress.', + inputResponses: merged, + inputRequests: undefined, + }); + + if (resumed) await resume(resumed); + + return { kind: 'result', result: {} }; +} From e468f6f9d13a2c8cfd5df09c1e70895ac1aa3b7a Mon Sep 17 00:00:00 2001 From: David Antoon Date: Sun, 2 Aug 2026 02:33:35 +0300 Subject: [PATCH 3/4] feat: implement client-side support for MCP protocol revision 2026-07-28 with new tools and error handling --- .../e2e/client.e2e.spec.ts | 4 +- .../e2e/helpers/mcp-2026-client.ts | 25 ++ .../e2e/mrtr-sampling-roots.e2e.spec.ts | 20 +- .../e2e/mrtr.e2e.spec.ts | 6 +- .../e2e/request-headers.e2e.spec.ts | 5 +- .../e2e/request-notifications.e2e.spec.ts | 4 +- .../e2e/stateless-requests.e2e.spec.ts | 23 +- .../e2e/tasks-anonymous.e2e.spec.ts | 4 +- .../e2e/tasks-extension.e2e.spec.ts | 16 +- docs/docs.json | 3 +- .../fundamentals/protocol-versions.mdx | 299 ++++++++++++++++++ .../instances/instance.local-primary-auth.ts | 41 ++- libs/sdk/src/context/frontmcp-context.ts | 4 +- .../src/remote-mcp/mcp-2026-client.adapter.ts | 10 +- libs/sdk/src/remote-mcp/mcp-client.service.ts | 18 +- libs/sdk/src/remote-mcp/mcp-client.types.ts | 10 +- libs/sdk/src/task/helpers/task-runner.ts | 12 + .../transport/flows/handle.mcp-2026.flow.ts | 7 +- .../mcp-2026/__tests__/header-params.spec.ts | 94 ++++++ .../transport/mcp-2026/__tests__/mrtr.spec.ts | 36 +++ .../mcp-2026/__tests__/request-state.spec.ts | 42 ++- .../mcp-2026/client/header-params.ts | 50 ++- .../mcp-2026/client/mcp-2026.client.ts | 79 ++++- libs/sdk/src/transport/mcp-2026/dispatcher.ts | 12 +- .../src/transport/mcp-2026/header-codec.ts | 15 +- libs/sdk/src/transport/mcp-2026/mrtr.ts | 24 +- .../src/transport/mcp-2026/request-state.ts | Bin 5941 -> 6116 bytes .../transport/mcp-2026/result-decorator.ts | 4 + .../src/transport/mcp-2026/tasks-extension.ts | 12 +- .../catalog/frontmcp-deployment/SKILL.md | 3 +- .../references/protocol-versions.md | 183 +++++++++++ libs/skills/catalog/skills-manifest.json | 213 +++++++------ 32 files changed, 1092 insertions(+), 186 deletions(-) create mode 100644 docs/frontmcp/fundamentals/protocol-versions.mdx create mode 100644 libs/sdk/src/transport/mcp-2026/__tests__/header-params.spec.ts create mode 100644 libs/skills/catalog/frontmcp-deployment/references/protocol-versions.md diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/client.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/client.e2e.spec.ts index 4ee36c85b..e7e4dbc2a 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/client.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/client.e2e.spec.ts @@ -9,6 +9,8 @@ import { Mcp2026Client, Mcp2026ClientAdapter, Mcp2026Error, negotiateRemoteProtocol } from '@frontmcp/sdk'; import { expect, test } from '@frontmcp/testing'; +import type { ListedTool } from './helpers/mcp-2026-client'; + test.describe('protocol 2026-07-28 — Mcp2026Client', () => { test.use({ server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', @@ -186,7 +188,7 @@ test.describe('protocol 2026-07-28 — remote-proxy adapter', () => { expect(adapter.getServerCapabilities()).toBeDefined(); const { tools } = await adapter.listTools(); - expect(tools.map((t: any) => t.name)).toContain('echo'); + expect((tools as ListedTool[]).map((t) => t.name)).toContain('echo'); const called = await adapter.callTool({ name: 'echo', arguments: { message: 'proxied' } }); expect(JSON.stringify(called)).toContain('proxied'); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts index c92b103e0..29d9269cc 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts @@ -40,6 +40,31 @@ export function encodeHeaderValue(value: string): string { return `=?base64?${Buffer.from(value, 'utf8').toString('base64')}?=`; } +/** A server→client request embedded in an `InputRequiredResult` (MRTR). */ +export interface InputRequest { + method: string; + params?: Record; +} + +/** A tool as returned by `tools/list`. */ +export interface ListedTool { + name: string; + description?: string; + inputSchema?: Record; +} + +/** A task handle / state as returned by the tasks extension. */ +export interface TaskWire { + taskId: string; + status: string; + ttlMs?: number | null; + pollIntervalMs?: number; + statusMessage?: string; + result?: Record; + error?: { code: number; message: string }; + inputRequests?: Record; +} + export interface JsonRpcRequestBody { jsonrpc: '2.0'; id?: string | number; diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr-sampling-roots.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr-sampling-roots.e2e.spec.ts index cd6e1a63a..0093ec3ba 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr-sampling-roots.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr-sampling-roots.e2e.spec.ts @@ -7,7 +7,7 @@ */ import { expect, test } from '@frontmcp/testing'; -import { mcp2026Fetch, MISSING_REQUIRED_CLIENT_CAPABILITY } from './helpers/mcp-2026-client'; +import { mcp2026Fetch, MISSING_REQUIRED_CLIENT_CAPABILITY, type InputRequest } from './helpers/mcp-2026-client'; const SAMPLING_CALL = { method: 'tools/call' as const, @@ -36,7 +36,7 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { expect(error).toBeUndefined(); expect(result.resultType).toBe('input_required'); - const [, request] = Object.entries(result.inputRequests)[0] as [string, any]; + const [, request] = Object.entries(result.inputRequests)[0] as [string, InputRequest]; expect(request.method).toBe('sampling/createMessage'); expect(request.params.maxTokens).toBe(100); expect(request.params.systemPrompt).toBe('You are a concise summarizer.'); @@ -94,7 +94,7 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { const { result } = res.json(); expect(result.resultType).toBe('input_required'); - const [, request] = Object.entries(result.inputRequests)[0] as [string, any]; + const [, request] = Object.entries(result.inputRequests)[0] as [string, InputRequest]; expect(request.method).toBe('roots/list'); }); @@ -159,26 +159,24 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { test('rejects a requestState replayed onto a different tool call', async ({ server }) => { const first = await mcp2026Fetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 11 }); const { result: interim } = first.json(); - const [key] = Object.keys(interim.inputRequests); + expect(interim.resultType).toBe('input_required'); - // Same signed blob, different arguments — the binding must not verify. + // Replay the signed blob against DIFFERENT arguments, and deliberately send + // no `inputResponses` — the carried state is then the only thing that could + // complete the call. If the server honoured the mismatched binding it would + // answer `complete`; rejecting it means asking again. const res = await mcp2026Fetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 12, params: { name: 'summarize', arguments: { text: 'a DIFFERENT document' }, - inputResponses: { [key]: { role: 'assistant', content: { type: 'text', text: 'replayed' } } }, requestState: interim.requestState, }, }); - // `inputResponses` still resolves this round (it is sent explicitly), but - // the carried state must not have been trusted — assert the server did not - // silently accept the mismatched blob by checking it completes from the - // explicit response only. const { result } = res.json(); - expect(['complete', 'input_required']).toContain(result.resultType); + expect(result.resultType).toBe('input_required'); }); test('accepts a legitimately signed requestState', async ({ server }) => { diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr.e2e.spec.ts index 198cdf446..41bc5e723 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr.e2e.spec.ts @@ -8,7 +8,7 @@ */ import { expect, test } from '@frontmcp/testing'; -import { mcp2026Fetch, MISSING_REQUIRED_CLIENT_CAPABILITY } from './helpers/mcp-2026-client'; +import { mcp2026Fetch, MISSING_REQUIRED_CLIENT_CAPABILITY, type InputRequest } from './helpers/mcp-2026-client'; const ELICITING_CALL = { method: 'tools/call' as const, @@ -39,7 +39,7 @@ test.describe('protocol 2026-07-28 — MRTR', () => { const entries = Object.entries(result.inputRequests ?? {}); expect(entries.length).toBeGreaterThan(0); - const [, request] = entries[0] as [string, any]; + const [, request] = entries[0] as [string, InputRequest]; expect(request.method).toBe('elicitation/create'); expect(request.params.message).toContain('deploy to prod'); expect(request.params.requestedSchema.type).toBe('object'); @@ -151,7 +151,7 @@ test.describe('protocol 2026-07-28 — MRTR', () => { test('does not leak an elicitationId field', async ({ server }) => { const res = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 11 }); const { result } = res.json(); - const [, request] = Object.entries(result.inputRequests)[0] as [string, any]; + const [, request] = Object.entries(result.inputRequests)[0] as [string, InputRequest]; // `elicitationId` was removed alongside the completion notification. expect(request.params.elicitationId).toBeUndefined(); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/request-headers.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/request-headers.e2e.spec.ts index 7848159b8..49f4efeaf 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/request-headers.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/request-headers.e2e.spec.ts @@ -14,6 +14,7 @@ import { META_PROTOCOL_VERSION, PROTOCOL_2026, UNSUPPORTED_PROTOCOL_VERSION, + type ListedTool, } from './helpers/mcp-2026-client'; test.describe('protocol 2026-07-28 — request metadata headers', () => { @@ -188,9 +189,9 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { test('advertises x-mcp-header in the tool inputSchema', async ({ server }) => { const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 15 }); - const tool = res.json().result.tools.find((t: any) => t.name === 'region-query'); + const tool = (res.json().result.tools as ListedTool[]).find((t) => t.name === 'region-query'); - expect(tool.inputSchema.properties.region['x-mcp-header']).toBe('Region'); + expect(tool?.inputSchema?.['properties']?.region?.['x-mcp-header']).toBe('Region'); }); test('treats header names case-insensitively', async ({ server }) => { diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/request-notifications.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/request-notifications.e2e.spec.ts index 824031ffd..2dfbb78d3 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/request-notifications.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/request-notifications.e2e.spec.ts @@ -8,7 +8,7 @@ */ import { expect, test } from '@frontmcp/testing'; -import { mcp2026Fetch, META_SERVER_INFO, parseSseEvents } from './helpers/mcp-2026-client'; +import { mcp2026Fetch, META_SERVER_INFO, parseSseEvents, type ListedTool } from './helpers/mcp-2026-client'; const CHATTY = { method: 'tools/call' as const, @@ -168,7 +168,7 @@ test.describe('protocol 2026-07-28 — deterministic list ordering', () => { test('returns tools sorted by name', async ({ server }) => { const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 11 }); - const names = res.json().result.tools.map((t: any) => t.name); + const names = (res.json().result.tools as ListedTool[]).map((t) => t.name); expect(names).toEqual([...names].sort()); }); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/stateless-requests.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/stateless-requests.e2e.spec.ts index e3fb9d9ba..4184b5916 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/stateless-requests.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/stateless-requests.e2e.spec.ts @@ -7,7 +7,13 @@ */ import { expect, test } from '@frontmcp/testing'; -import { mcp2026Fetch, META_SERVER_INFO } from './helpers/mcp-2026-client'; +import { + mcp2026Fetch, + META_SERVER_INFO, + MISSING_REQUIRED_CLIENT_CAPABILITY, + type ListedTool, + type Mcp2026Response, +} from './helpers/mcp-2026-client'; test.describe('protocol 2026-07-28 — stateless requests', () => { test.use({ @@ -22,7 +28,9 @@ test.describe('protocol 2026-07-28 — stateless requests', () => { expect(res.status).toBe(200); const { result, error } = res.json(); expect(error).toBeUndefined(); - expect(result.tools.map((t: any) => t.name)).toEqual(expect.arrayContaining(['echo', 'region-query', 'confirm'])); + expect((result.tools as ListedTool[]).map((t) => t.name)).toEqual( + expect.arrayContaining(['echo', 'region-query', 'confirm']), + ); }); test('never mints an Mcp-Session-Id', async ({ server }) => { @@ -130,17 +138,18 @@ test.describe('protocol 2026-07-28 — stateless requests', () => { }); const { result, error } = res.json(); - // With no elicitation capability declared the server cannot silently - // succeed — it must either ask via MRTR or refuse. What it must NOT do is - // behave as if the earlier request's capabilities still apply. - expect(error?.code === -32021 || result?.resultType === 'input_required' || result !== undefined).toBe(true); + // With no elicitation capability declared the server must either refuse with + // -32021 or ask via MRTR. A plain `complete` result would mean the earlier + // request's declaration leaked into this one. + const outcome = error ? `error:${error.code}` : `result:${result?.resultType}`; + expect([`error:${MISSING_REQUIRED_CLIENT_CAPABILITY}`, 'result:input_required']).toContain(outcome); }); test('returns tools/list in a deterministic order across calls', async ({ server }) => { const first = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 8 }); const second = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 9 }); - const names = (r: any) => r.json().result.tools.map((t: any) => t.name); + const names = (r: Mcp2026Response) => (r.json().result.tools as ListedTool[]).map((t) => t.name); expect(names(first)).toEqual(names(second)); }); }); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-anonymous.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-anonymous.e2e.spec.ts index b41227630..cfc5e7255 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-anonymous.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-anonymous.e2e.spec.ts @@ -12,7 +12,7 @@ */ import { expect, test } from '@frontmcp/testing'; -import { mcp2026Fetch } from './helpers/mcp-2026-client'; +import { INVALID_PARAMS, mcp2026Fetch } from './helpers/mcp-2026-client'; const TASKS_EXT = { extensions: { 'io.modelcontextprotocol/tasks': {} } }; @@ -31,6 +31,7 @@ test.describe('protocol 2026-07-28 — tasks require an identified caller', () = clientCapabilities: TASKS_EXT, }); + expect(res.json().error.code).toBe(INVALID_PARAMS); expect(res.json().error.message).toContain('authenticated caller'); }); @@ -42,6 +43,7 @@ test.describe('protocol 2026-07-28 — tasks require an identified caller', () = clientCapabilities: TASKS_EXT, }); + expect(res.json().error.code).toBe(INVALID_PARAMS); expect(res.json().error.message).toContain('authenticated caller'); }); }); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-extension.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-extension.e2e.spec.ts index 2d65d3e26..447ec4e17 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-extension.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-extension.e2e.spec.ts @@ -8,7 +8,13 @@ */ import { expect, test } from '@frontmcp/testing'; -import { mcp2026Fetch, METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY } from './helpers/mcp-2026-client'; +import { + mcp2026Fetch, + METHOD_NOT_FOUND, + MISSING_REQUIRED_CLIENT_CAPABILITY, + type InputRequest, + type TaskWire, +} from './helpers/mcp-2026-client'; const JWT_SECRET = 'protocol-2026-tasks-e2e-secret-0123456789'; const TASKS_EXT = { extensions: { 'io.modelcontextprotocol/tasks': {} } }; @@ -18,11 +24,11 @@ async function pollUntil( baseUrl: string, token: string, taskId: string, - predicate: (task: any) => boolean, + predicate: (task: TaskWire) => boolean, timeoutMs = 15_000, -): Promise { +): Promise { const deadline = Date.now() + timeoutMs; - let last: any; + let last: TaskWire | undefined; let id = 9000; while (Date.now() < deadline) { const res = await mcp2026Fetch(baseUrl, { @@ -220,7 +226,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { const entries = Object.entries(paused.inputRequests ?? {}); expect(entries.length).toBeGreaterThan(0); - const [, request] = entries[0] as [string, any]; + const [, request] = entries[0] as [string, InputRequest]; expect(request.method).toBe('elicitation/create'); expect(request.params.message).toContain('deploy v2'); }); diff --git a/docs/docs.json b/docs/docs.json index 2a647e643..55ae6397d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -103,7 +103,8 @@ "frontmcp/features/esm-dynamic-loading" ] }, - "frontmcp/fundamentals/schemas" + "frontmcp/fundamentals/schemas", + "frontmcp/fundamentals/protocol-versions" ] }, { diff --git a/docs/frontmcp/fundamentals/protocol-versions.mdx b/docs/frontmcp/fundamentals/protocol-versions.mdx new file mode 100644 index 000000000..92820f7b1 --- /dev/null +++ b/docs/frontmcp/fundamentals/protocol-versions.mdx @@ -0,0 +1,299 @@ +--- +title: 'Protocol Versions' +description: 'How FrontMCP serves MCP 2026-07-28 alongside every earlier revision' +icon: 'code-branch' +--- + +FrontMCP speaks **every MCP revision from `2024-11-05` through `2026-07-28`** on +the same endpoint. There is no configuration switch: the revision is selected +per request, from what the client presents. + + + `2026-07-28` is a large breaking revision — it removes sessions, the + `initialize` handshake, and the server→client request direction. Existing + clients are unaffected: a request that does not declare `2026-07-28` takes the + exact same code path it always did. + + +## How a revision is selected + +A request is served as `2026-07-28` when **any** of these is true: + +- its `params._meta` carries `io.modelcontextprotocol/protocolVersion` (a key + that exists only in this revision); +- its `MCP-Protocol-Version` header names a version the session-based pipeline + does not know (so an unknown or future version gets a proper `-32022` instead + of a confusing session error); +- its method is `server/discover` or `subscriptions/listen`. + +Everything else — including every request carrying `Mcp-Session-Id`, and every +`initialize` — is served by the session pipeline unchanged. + +## What changed in 2026-07-28 + +### Statelessness + +There is no `initialize` and no `Mcp-Session-Id`. Every request carries its own +protocol version, client capabilities, and (optionally) client identity: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_weather", + "arguments": { "location": "Seattle, WA" }, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} +``` + +Capabilities are **per request**. FrontMCP never carries a declaration from one +request to the next. + +### `server/discover` + +Replaces `initialize` as the (optional) up-front negotiation step: + +```bash +curl -X POST https://example.com/mcp \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: server/discover' \ + -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +The result lists `supportedVersions`, `capabilities` (including `extensions`), +and `instructions`. + +### Mirrored request headers + +Streamable HTTP mirrors body fields into headers so intermediaries can route +without parsing the body. FrontMCP **validates** that the two agree and rejects +a mismatch with `400` + `-32020`: + +| Header | Source | Required for | +| ---------------------- | ----------------------------- | ----------------------------------------- | +| `MCP-Protocol-Version` | `_meta` protocol version | all requests | +| `Mcp-Method` | `method` | all requests | +| `Mcp-Name` | `params.name` / `params.uri` | `tools/call`, `prompts/get`, `resources/read` | +| `Mcp-Param-{Name}` | an `x-mcp-header` argument | tool calls that supply the argument | + +Values that are not header-safe travel Base64-wrapped as `=?base64?…?=`; +FrontMCP decodes before comparing. + +To mirror a tool argument into a header, annotate it in the schema: + +```ts +const inputSchema = { + region: z.string().describe('Region to query').meta({ 'x-mcp-header': 'Region' }), + query: z.string(), +}; +``` + +### Result envelope + +Every result carries `resultType` (`"complete"`, `"input_required"`, or +`"task"`) plus `_meta["io.modelcontextprotocol/serverInfo"]`. List and read +results additionally carry caching hints: + +```json +{ + "resultType": "complete", + "tools": [], + "ttlMs": 60000, + "cacheScope": "private", + "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "My Server", "version": "1.0.0" } } +} +``` + +`cacheScope` is `public` for anonymous traffic and `private` whenever the result +could vary by caller. + +### Multi Round-Trip Requests (MRTR) + +The server→client request direction is gone. When a tool calls `this.elicit()`, +`this.sample()`, or `this.listRoots()`, FrontMCP answers the **original** +request with an interim result: + +```json +{ + "resultType": "input_required", + "inputRequests": { + "elicitation-1": { + "method": "elicitation/create", + "params": { "message": "Proceed?", "requestedSchema": { "type": "object" } } + } + }, + "requestState": "" +} +``` + +The client gathers the input and re-issues the same request — with a **new** +JSON-RPC id — carrying `inputResponses` and the echoed `requestState`. + + + `requestState` round-trips through the client, so FrontMCP signs it (HMAC-SHA256) + and binds it to the authenticated principal, the originating request, and a + 10-minute expiry. A tampered, replayed, or expired blob is discarded and the + exchange restarts. + + +Tools are **replayed**, not resumed: the tool runs again from the top and the +recorded answers resolve inline. Keep tool bodies idempotent up to the point +they ask for input. + +### Request-scoped notifications + +`logging/setLevel` is gone. A client opts in per request: + +```json +"_meta": { + "io.modelcontextprotocol/logLevel": "info", + "progressToken": "tok-1" +} +``` + +FrontMCP then answers with an SSE stream carrying `notifications/message` and +`notifications/progress` for that request, terminated by the final response. A +request that omits `logLevel` receives no log notifications at all. + +### `subscriptions/listen` + +Replaces the standalone GET stream and `resources/subscribe`/`unsubscribe`: + +```json +{ + "method": "subscriptions/listen", + "params": { + "notifications": { + "toolsListChanged": true, + "resourceSubscriptions": ["proto://config"] + } + } +} +``` + +The response is a long-lived SSE stream. Its first message is +`notifications/subscriptions/acknowledged`, reporting which types the server +actually honors; every message carries +`_meta["io.modelcontextprotocol/subscriptionId"]`. + +### Removed methods and verbs + +`initialize`, `notifications/initialized`, `ping`, `logging/setLevel`, +`notifications/roots/list_changed`, `resources/subscribe`, +`resources/unsubscribe`, `tasks/list`, and `tasks/result` return +`404` + `-32601`. HTTP `GET` and `DELETE` on the MCP endpoint return `405`. + +### Error codes + +| Code | Meaning | +| -------- | --------------------------------------------------------- | +| `-32020` | `HeaderMismatch` — headers disagree with the body | +| `-32021` | `MissingRequiredClientCapability` | +| `-32022` | `UnsupportedProtocolVersion` (carries `supported`/`requested`) | +| `-32602` | Resource not found (was `-32002` before this revision) | + +## Tasks extension + +Tasks moved out of the core protocol into `io.modelcontextprotocol/tasks`. A +client declares it per request: + +```json +"io.modelcontextprotocol/clientCapabilities": { + "extensions": { "io.modelcontextprotocol/tasks": {} } +} +``` + +A tool declaring `execution: { taskSupport: 'optional' }` then answers with a +handle instead of running inline: + +```json +{ "resultType": "task", "task": { "taskId": "…", "status": "working", "ttlMs": 60000, "pollIntervalMs": 50 } } +``` + +Poll `tasks/get` until terminal. If the task reports `input_required`, answer its +`inputRequests` with `tasks/update`. `tasks/cancel` still works. + + + Tasks require an **authenticated** caller under `2026-07-28`. With no protocol + sessions, an anonymous task could not be scoped to its creator, so FrontMCP + refuses to create one on a public server rather than pooling unrelated callers + into a shared namespace. + + +## Client support + +The upstream `@modelcontextprotocol/sdk` client does not speak this revision, so +FrontMCP ships its own: + +```ts +import { Mcp2026Client } from '@frontmcp/sdk'; + +const client = new Mcp2026Client({ + url: 'https://example.com/mcp', + capabilities: { elicitation: { form: {} } }, + handlers: { + onElicit: async (params) => ({ action: 'accept', content: { confirmed: true } }), + }, +}); + +await client.discover(); +await client.listTools(); +const result = await client.callTool('confirm', { action: 'deploy' }); +``` + +The client handles the MRTR retry loop and task polling internally, so +`callTool` resolves with the final result either way. It also drops tools whose +`x-mcp-header` annotations are invalid, as the spec requires. + +### Proxying a 2026 remote + +A remote app can negotiate the revision: + +```ts +transportOptions: { + // 'legacy' (default) | '2026-07-28' | 'auto' + protocolVersion: 'auto', +} +``` + +`auto` probes `server/discover` and falls back to the session transports when +the remote does not answer it. + +## Authorization changes + +- Authorization responses now carry the RFC 9207 `iss` parameter, and FrontMCP + **validates** a present `iss` against the provider's recorded issuer before + redeeming the code. +- Dynamic Client Registration accepts `application_type` (`web` | `native`, + defaulting to `web`). +- Re-registering an upstream provider under a different issuer discards + credentials bound to the previous authorization server. +- DCR is **deprecated** in favour of + [Client ID Metadata Documents](/frontmcp/authentication/cimd); it remains + available for authorization servers that have not adopted CIMD. + +## Deprecations + +Still functional, but new servers should not adopt them: + +- **Roots** — pass directories via tool parameters or server configuration. +- **Sampling** — integrate with an LLM provider API directly. +- **Logging** — log to `stderr` or use OpenTelemetry. +- **HTTP+SSE transport** — migrate to Streamable HTTP. + +## Observability + +OpenTelemetry context propagates through `_meta` (SEP-414). Send `traceparent`, +`tracestate`, or `baggage` and FrontMCP echoes them on the result so a client can +stitch its span to the server's. diff --git a/libs/sdk/src/auth/instances/instance.local-primary-auth.ts b/libs/sdk/src/auth/instances/instance.local-primary-auth.ts index 685d25afd..f499c2fd7 100644 --- a/libs/sdk/src/auth/instances/instance.local-primary-auth.ts +++ b/libs/sdk/src/auth/instances/instance.local-primary-auth.ts @@ -215,6 +215,16 @@ export interface UpstreamProviderConfig { issuer?: string; } +/** + * Normalize an issuer identifier for comparison. + * + * Issuer identifiers are URLs, so `https://idp.example.com` and + * `https://idp.example.com/` denote the same issuer. + */ +function normalizeIssuer(value: string): string { + return value.replace(/\/+$/, ''); +} + /** * Validate an RFC 9207 `iss` authorization-response parameter. * @@ -233,11 +243,7 @@ export function validateAuthorizationIssuer( if (received === undefined) return { ok: true }; if (!expected) return { ok: true }; - // Compare on origin + path with a trailing slash normalized away: issuer - // identifiers are URLs, and `https://idp.example.com` and - // `https://idp.example.com/` denote the same issuer. - const normalize = (value: string): string => value.replace(/\/+$/, ''); - if (normalize(received) !== normalize(expected)) { + if (normalizeIssuer(received) !== normalizeIssuer(expected)) { return { ok: false, reason: `Authorization response issuer "${received}" does not match the configured issuer "${expected}"`, @@ -1191,7 +1197,14 @@ export class LocalPrimaryAuth extends FrontMcpAuth { // issuer means the counterparty changed, so anything cached for the old one // must be dropped rather than silently reused against the new AS. const previous = this.providerConfigs.get(config.id); - if (previous && previous.issuer && config.issuer && previous.issuer !== config.issuer) { + // Compare normalized, exactly as `validateAuthorizationIssuer` does — a bare + // trailing-slash difference names the SAME issuer and must not throw away + // working credentials. + const issuerChanged = + previous?.issuer !== undefined && + config.issuer !== undefined && + normalizeIssuer(previous.issuer) !== normalizeIssuer(config.issuer); + if (issuerChanged) { this.logger.warn( `Upstream provider "${config.id}" changed issuer (${previous.issuer} → ${config.issuer}); ` + `discarding credentials bound to the previous authorization server`, @@ -1215,7 +1228,21 @@ export class LocalPrimaryAuth extends FrontMcpAuth { const store = this.orchestratedTokenStoreImpl as { deleteTokensForProvider?: (providerId: string) => Promise; }; - await store.deleteTokensForProvider?.(providerId); + + if (typeof store.deleteTokensForProvider !== 'function') { + // The `TokenStore` interface has no provider-wide delete, and there is no + // way to enumerate authorization ids to call `deleteTokens` per entry. + // Say so loudly instead of reporting a purge that did not happen — + // an operator changing an issuer needs to know to rotate manually. + this.logger.warn( + `Cannot auto-discard credentials for provider "${providerId}": the configured token store does not ` + + `support provider-wide deletion. Rotate or clear the store manually so credentials issued by the ` + + `previous authorization server are not reused.`, + ); + return; + } + + await store.deleteTokensForProvider(providerId); } catch (error) { this.logger.error( `Failed to discard credentials for provider "${providerId}" after an issuer change`, diff --git a/libs/sdk/src/context/frontmcp-context.ts b/libs/sdk/src/context/frontmcp-context.ts index 85e41bc9b..0747a88c3 100644 --- a/libs/sdk/src/context/frontmcp-context.ts +++ b/libs/sdk/src/context/frontmcp-context.ts @@ -13,7 +13,7 @@ import { isFrontMcpCredentials, type FetchCredentialMiddleware, type FrontMcpFetchInit } from '@frontmcp/auth'; import { type ZodType } from '@frontmcp/lazy-zod'; -import { type AuthInfo } from '@frontmcp/protocol'; +import { type AuthInfo, type LoggingLevel } from '@frontmcp/protocol'; import { randomUUID, sha256Hex } from '@frontmcp/utils'; import { type FrontMcpLogger } from '../common/interfaces/logger.interface'; @@ -38,7 +38,7 @@ const REQUEST_NOTIFICATION_SINK_KEY = Symbol.for('frontmcp:request-notification- * context module stays free of a dependency on the transport layer. */ export interface RequestNotificationSinkRef { - log(level: string, logger: string | undefined, data: unknown): boolean; + log(level: LoggingLevel, logger: string | undefined, data: unknown): boolean; progress(progress: number, total?: number, message?: string): boolean; } diff --git a/libs/sdk/src/remote-mcp/mcp-2026-client.adapter.ts b/libs/sdk/src/remote-mcp/mcp-2026-client.adapter.ts index 348ccc8e8..eaefc19c1 100644 --- a/libs/sdk/src/remote-mcp/mcp-2026-client.adapter.ts +++ b/libs/sdk/src/remote-mcp/mcp-2026-client.adapter.ts @@ -87,10 +87,12 @@ export class Mcp2026ClientAdapter implements RemoteClientLike { /** * Decide which revision to speak to a remote server. * - * `'auto'` runs the spec's own backward-compatibility probe: try a modern - * request first, and only fall back when the failure is NOT a recognised modern - * error. Anything else is an explicit choice by the operator, and the default - * stays on the legacy path so existing deployments are untouched. + * `'auto'` runs the spec's own backward-compatibility probe: ask for + * `server/discover` first. ANY failure — a transport error, a timeout, a + * non-2026 server, or a response that does not advertise `2026-07-28` — selects + * the legacy path. Anything other than `'auto'` is an explicit choice by the + * operator, and the default stays on the legacy path so existing deployments are + * untouched. */ export async function negotiateRemoteProtocol( url: string, diff --git a/libs/sdk/src/remote-mcp/mcp-client.service.ts b/libs/sdk/src/remote-mcp/mcp-client.service.ts index 3fb55f3ea..0ea11c450 100644 --- a/libs/sdk/src/remote-mcp/mcp-client.service.ts +++ b/libs/sdk/src/remote-mcp/mcp-client.service.ts @@ -19,6 +19,7 @@ import { } from '@frontmcp/protocol'; import { type FrontMcpLogger } from '../common'; +import { InternalMcpError } from '../errors/mcp.error'; import { RemoteAuthError, RemoteCapabilityDiscoveryError, @@ -796,9 +797,20 @@ export class McpClientService { * completely untouched — the default for an unconfigured remote. */ private async tryConnect2026(request: McpConnectRequest): Promise { - if (request.transportType !== 'http') return undefined; - const httpOptions = request.transportOptions as McpHttpTransportOptions | undefined; + + if (request.transportType !== 'http') { + // 2026-07-28 is defined only over Streamable HTTP. Silently falling back + // to the legacy client would leave the caller believing it negotiated a + // revision it never got, so an explicit request is refused. + if (httpOptions?.protocolVersion === '2026-07-28') { + throw new InternalMcpError( + `protocolVersion "2026-07-28" requires transportType "http", got "${request.transportType}"`, + 'UNSUPPORTED_TRANSPORT_TYPE', + ); + } + return undefined; + } const negotiated = await negotiateRemoteProtocol(request.url, httpOptions?.protocolVersion, httpOptions?.headers); if (negotiated !== '2026-07-28') return undefined; @@ -814,7 +826,7 @@ export class McpClientService { // cast keeps `McpClientConnection` from having to become a union type // that every consumer would then have to narrow. client: adapter as unknown as McpClientConnection['client'], - transport: undefined as unknown as Transport, + // No `transport`: this revision is stateless, so there is nothing to keep. status: 'connected', connectedAt: new Date(), lastHeartbeat: new Date(), diff --git a/libs/sdk/src/remote-mcp/mcp-client.types.ts b/libs/sdk/src/remote-mcp/mcp-client.types.ts index 882611690..6b0565193 100644 --- a/libs/sdk/src/remote-mcp/mcp-client.types.ts +++ b/libs/sdk/src/remote-mcp/mcp-client.types.ts @@ -32,8 +32,14 @@ export type McpConnectionStatus = 'connecting' | 'connected' | 'disconnected' | export interface McpClientConnection { /** The MCP client instance */ client: Client; - /** The transport used for communication */ - transport: Transport; + /** + * The transport used for communication. + * + * Absent for protocol 2026-07-28 connections: that revision is stateless, so + * the adapter issues an independent HTTP request per call and there is no + * long-lived transport object to hold. + */ + transport?: Transport; /** Session ID assigned by the remote server (if any) */ sessionId?: string; /** Current connection status */ diff --git a/libs/sdk/src/task/helpers/task-runner.ts b/libs/sdk/src/task/helpers/task-runner.ts index 76097c3e8..9a0f2c5ea 100644 --- a/libs/sdk/src/task/helpers/task-runner.ts +++ b/libs/sdk/src/task/helpers/task-runner.ts @@ -101,6 +101,18 @@ async function executeTask(params: RunTaskParams): Promise { // which resumes execution. This is NOT a terminal state, so return before // the terminal write below. if (err instanceof InputRequiredSignal) { + // Never overwrite a terminal state. A concurrent `tasks/cancel` may have + // already settled this record, and the spec requires a cancelled task to + // STAY cancelled even if execution continued past the signal. + const beforePause = await store.get(taskId, sessionId); + if (!beforePause || isTerminal(beforePause.status)) { + logger?.debug('[task-runner] input required but task already terminal; leaving as-is', { + taskId, + status: beforePause?.status, + }); + return; + } + const paused = await store.update(taskId, sessionId, { status: 'input_required', statusMessage: 'The task is waiting for additional input.', diff --git a/libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts b/libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts index 25b9e1cfd..60c2c7a58 100644 --- a/libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts +++ b/libs/sdk/src/transport/flows/handle.mcp-2026.flow.ts @@ -366,7 +366,12 @@ export default class HandleMcp2026Flow extends FlowBase { // into the same way, via `progressToken`. const logLevel = typeof meta[MCP_2026_META.logLevel] === 'string' ? (meta[MCP_2026_META.logLevel] as LoggingLevel) : undefined; - const progressToken = meta['progressToken'] as string | number | undefined; + // Only a string or number is a usable progress token. An object or array + // from a hostile client must not activate the sink, nor be echoed back + // inside every progress notification. + const rawProgressToken = meta['progressToken']; + const progressToken = + typeof rawProgressToken === 'string' || typeof rawProgressToken === 'number' ? rawProgressToken : undefined; const sink = new RequestNotificationSink(logLevel, progressToken); const dispatchOptions = { diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/header-params.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/header-params.spec.ts new file mode 100644 index 000000000..255d44340 --- /dev/null +++ b/libs/sdk/src/transport/mcp-2026/__tests__/header-params.spec.ts @@ -0,0 +1,94 @@ +import { buildParamHeaders, validateHeaderParams } from '../client/header-params'; + +const schema = (properties: Record) => ({ type: 'object', properties }); + +describe('validateHeaderParams', () => { + it('accepts a well-formed annotation', () => { + expect(validateHeaderParams(schema({ region: { type: 'string', 'x-mcp-header': 'Region' } }))).toEqual({ + valid: true, + }); + }); + + it('rejects an empty annotation', () => { + // `collectHeaderParams` drops these, so validation has to walk the raw + // annotations or this case would silently pass. + const result = validateHeaderParams(schema({ region: { type: 'string', 'x-mcp-header': '' } })); + expect(result.valid).toBe(false); + expect(result.reason).toContain('must not be empty'); + }); + + it('rejects a case-insensitive duplicate', () => { + // HTTP field names are case-insensitive, so `Region` and `region` would + // collapse into one header. + const result = validateHeaderParams( + schema({ + a: { type: 'string', 'x-mcp-header': 'Region' }, + b: { type: 'string', 'x-mcp-header': 'region' }, + }), + ); + expect(result.valid).toBe(false); + expect(result.reason).toContain('declared more than once'); + }); + + it('rejects a non-token annotation name', () => { + const result = validateHeaderParams(schema({ a: { type: 'string', 'x-mcp-header': 'bad name' } })); + expect(result.valid).toBe(false); + expect(result.reason).toContain('field-name token'); + }); + + it('rejects an annotation on a number', () => { + const result = validateHeaderParams(schema({ a: { type: 'number', 'x-mcp-header': 'A' } })); + expect(result.valid).toBe(false); + expect(result.reason).toContain('number'); + }); + + it('rejects an annotation on a non-primitive', () => { + const result = validateHeaderParams(schema({ a: { type: 'object', 'x-mcp-header': 'A' } })); + expect(result.valid).toBe(false); + }); + + it('accepts integer and boolean annotations', () => { + expect( + validateHeaderParams( + schema({ a: { type: 'integer', 'x-mcp-header': 'A' }, b: { type: 'boolean', 'x-mcp-header': 'B' } }), + ), + ).toEqual({ valid: true }); + }); + + it('accepts a schema with no annotations at all', () => { + expect(validateHeaderParams(schema({ a: { type: 'string' } }))).toEqual({ valid: true }); + expect(validateHeaderParams(undefined)).toEqual({ valid: true }); + }); +}); + +describe('buildParamHeaders', () => { + const identity = (v: string) => v; + const s = schema({ region: { type: 'string', 'x-mcp-header': 'Region' } }); + + it('mirrors a supplied argument', () => { + expect(buildParamHeaders(s, { region: 'us-west1' }, identity)).toEqual({ 'Mcp-Param-Region': 'us-west1' }); + }); + + it('omits the header when the argument is absent or null', () => { + expect(buildParamHeaders(s, {}, identity)).toEqual({}); + expect(buildParamHeaders(s, { region: null }, identity)).toEqual({}); + }); + + it('stringifies booleans and integers', () => { + const bools = schema({ flag: { type: 'boolean', 'x-mcp-header': 'Flag' } }); + expect(buildParamHeaders(bools, { flag: false }, identity)).toEqual({ 'Mcp-Param-Flag': 'false' }); + + const ints = schema({ n: { type: 'integer', 'x-mcp-header': 'N' } }); + expect(buildParamHeaders(ints, { n: 42 }, identity)).toEqual({ 'Mcp-Param-N': '42' }); + }); + + it('skips numbers that cannot round-trip exactly', () => { + const ints = schema({ n: { type: 'integer', 'x-mcp-header': 'N' } }); + expect(buildParamHeaders(ints, { n: 1.5 }, identity)).toEqual({}); + expect(buildParamHeaders(ints, { n: Number.MAX_SAFE_INTEGER + 10 }, identity)).toEqual({}); + }); + + it('returns nothing for a schema with no annotations', () => { + expect(buildParamHeaders(schema({ a: { type: 'string' } }), { a: 'x' }, identity)).toEqual({}); + }); +}); diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts index ab2ffc60b..cf050be05 100644 --- a/libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts +++ b/libs/sdk/src/transport/mcp-2026/__tests__/mrtr.spec.ts @@ -241,3 +241,39 @@ describe('buildInputRequiredResult', () => { }); }); }); + +describe('MrtrExchange — elicitation modes', () => { + const URL_PENDING = { ...PENDING, mode: 'url' as const, url: 'https://example.com/approve' }; + + it('treats a bare `elicitation: {}` as implicit form support', () => { + const signal = capture(() => exchange({ clientCapabilities: { elicitation: {} } }).resolveElicitation(PENDING)); + expect(signal.inputRequests['elicitation-1']?.method).toBe('elicitation/create'); + }); + + it('refuses url mode when the client declared only form', () => { + // URL elicitation sends the user out of band, so it is never implicit — a + // form-only client cannot service it. + let error: unknown; + try { + exchange({ clientCapabilities: { elicitation: { form: {} } } }).resolveElicitation(URL_PENDING); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(MissingClientCapabilityError); + expect((error as MissingClientCapabilityError).requiredCapabilities).toEqual({ elicitation: { url: {} } }); + }); + + it('allows url mode when the client declared it', () => { + const signal = capture(() => + exchange({ clientCapabilities: { elicitation: { url: {} } } }).resolveElicitation(URL_PENDING), + ); + expect(signal.inputRequests['elicitation-1']?.params?.['mode']).toBe('url'); + }); + + it('refuses form mode when the client declared only url', () => { + expect(() => exchange({ clientCapabilities: { elicitation: { url: {} } } }).resolveElicitation(PENDING)).toThrow( + MissingClientCapabilityError, + ); + }); +}); diff --git a/libs/sdk/src/transport/mcp-2026/__tests__/request-state.spec.ts b/libs/sdk/src/transport/mcp-2026/__tests__/request-state.spec.ts index 8f11b3ba8..c2a75e19e 100644 --- a/libs/sdk/src/transport/mcp-2026/__tests__/request-state.spec.ts +++ b/libs/sdk/src/transport/mcp-2026/__tests__/request-state.spec.ts @@ -1,3 +1,6 @@ +import { hmacSha256 } from '@frontmcp/utils'; + +import { resolvePrincipal } from '../dispatcher'; import { computeRequestBinding, decodeRequestState, @@ -105,7 +108,6 @@ describe('requestState integrity', () => { it('rejects a signed blob whose payload is not JSON', () => { // Signed by us, so the signature passes — the payload check must still catch it. - const { hmacSha256 } = require('@frontmcp/utils'); const body = Buffer.from('not json', 'utf8').toString('base64url'); const mac = Buffer.from(hmacSha256(getRequestStateKey(), new TextEncoder().encode(body))).toString('base64url'); expect(decodeRequestState(`${body}.${mac}`, BINDING)).toEqual({ ok: false, reason: 'malformed' }); @@ -148,3 +150,41 @@ describe('getRequestStateKey', () => { expect(getRequestStateKey()).toBe(key); }); }); + +describe('resolvePrincipal (token collision)', () => { + it('distinguishes tokens that share a long prefix', () => { + // Every HS256 JWT begins with the same base64url-encoded header, so a + // truncating principal would map unrelated callers onto one identity and + // let them redeem each other's requestState. + const header = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; + const a = `${header}.payload-one.sig-one`; + const b = `${header}.payload-two.sig-two`; + + expect(a.slice(0, 16)).toBe(b.slice(0, 16)); + expect(resolvePrincipal({ token: a })).not.toBe(resolvePrincipal({ token: b })); + }); + + it('prefers a verified clientId over the token', () => { + expect(resolvePrincipal({ clientId: 'user-1', token: 'anything' })).toBe('user-1'); + }); + + it('falls back to anonymous with neither', () => { + expect(resolvePrincipal({})).toBe('anonymous'); + expect(resolvePrincipal(undefined)).toBe('anonymous'); + }); + + it('binds requestState to the hashed token, so a different token cannot redeem it', () => { + const header = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; + const binding = computeRequestBinding('tools/call', { name: 'confirm' }); + + const minted = encodeRequestState(RESPONSES, { + principal: resolvePrincipal({ token: `${header}.a.a` }), + binding, + }); + + expect(decodeRequestState(minted, { principal: resolvePrincipal({ token: `${header}.b.b` }), binding })).toEqual({ + ok: false, + reason: 'principal-mismatch', + }); + }); +}); diff --git a/libs/sdk/src/transport/mcp-2026/client/header-params.ts b/libs/sdk/src/transport/mcp-2026/client/header-params.ts index e6ff57a5d..60b07fdeb 100644 --- a/libs/sdk/src/transport/mcp-2026/client/header-params.ts +++ b/libs/sdk/src/transport/mcp-2026/client/header-params.ts @@ -6,8 +6,6 @@ * REJECT tool definitions whose `x-mcp-header` values break the rules, and to * exclude just those tools from `tools/list` rather than failing the whole list. */ -import { collectHeaderParams } from '../request-validation'; - /** RFC 9110 field-name token characters. */ const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; @@ -30,18 +28,24 @@ export interface HeaderParamValidation { export function validateHeaderParams(inputSchema: unknown): HeaderParamValidation { if (!inputSchema || typeof inputSchema !== 'object') return { valid: true }; - const annotated = collectHeaderParams(inputSchema); + // Walk the RAW annotations rather than `collectHeaderParams`, which lowercases + // into a Map and so silently discards both empty names and case-insensitive + // duplicates — the two things this function exists to reject. + const annotated = collectRawHeaderParams(inputSchema); const seen = new Set(); - for (const [name, path] of annotated) { + for (const { name, path } of annotated) { if (name.length === 0) return { valid: false, reason: 'x-mcp-header must not be empty' }; if (!TOKEN_RE.test(name)) { return { valid: false, reason: `x-mcp-header "${name}" is not a valid HTTP field-name token` }; } - if (seen.has(name)) { + // Uniqueness is case-INSENSITIVE: HTTP field names are, so `Region` and + // `region` would collide into one header. + const normalized = name.toLowerCase(); + if (seen.has(normalized)) { return { valid: false, reason: `x-mcp-header "${name}" is declared more than once` }; } - seen.add(name); + seen.add(normalized); const type = readTypeAtPath(inputSchema, path); if (type === 'number') { @@ -55,6 +59,32 @@ export function validateHeaderParams(inputSchema: unknown): HeaderParamValidatio return { valid: true }; } +/** + * Collect every `x-mcp-header` annotation verbatim, including duplicates and + * empty names, in `properties`-chain order. + * + * Deliberately lossless, unlike {@link collectHeaderParams}: validation has to + * see what the tool author actually wrote before anything is normalized away. + */ +function collectRawHeaderParams( + schema: unknown, + path: string[] = [], + out: Array<{ name: string; path: string[] }> = [], +): Array<{ name: string; path: string[] }> { + if (!schema || typeof schema !== 'object') return out; + const properties = (schema as { properties?: Record }).properties; + if (!properties || typeof properties !== 'object') return out; + + for (const [key, value] of Object.entries(properties)) { + if (!value || typeof value !== 'object') continue; + const annotation = (value as Record)['x-mcp-header']; + const nextPath = [...path, key]; + if (typeof annotation === 'string') out.push({ name: annotation, path: nextPath }); + collectRawHeaderParams(value, nextPath, out); + } + return out; +} + /** Read the declared `type` of a property reachable through a `properties` chain. */ function readTypeAtPath(schema: unknown, path: string[]): string | undefined { let cursor: unknown = schema; @@ -82,13 +112,17 @@ export function buildParamHeaders( const headers: Record = {}; if (!inputSchema || typeof inputSchema !== 'object') return headers; - for (const [name, path] of collectHeaderParams(inputSchema)) { + // Raw collector, so the header carries the casing the tool author declared + // (`Mcp-Param-Region`, matching the spec's example). Lookup stays + // case-insensitive on the server, so either spelling interoperates. + for (const { name, path } of collectRawHeaderParams(inputSchema)) { + if (name.length === 0) continue; const value = readValueAtPath(args, path); if (value === undefined || value === null) continue; if (typeof value === 'number' && (!Number.isInteger(value) || Math.abs(value) > MAX_SAFE)) continue; - const asString = typeof value === 'boolean' ? String(value) : String(value); + const asString = String(value); headers[`Mcp-Param-${name}`] = encode(asString); } diff --git a/libs/sdk/src/transport/mcp-2026/client/mcp-2026.client.ts b/libs/sdk/src/transport/mcp-2026/client/mcp-2026.client.ts index d5ba08e77..57e0bd347 100644 --- a/libs/sdk/src/transport/mcp-2026/client/mcp-2026.client.ts +++ b/libs/sdk/src/transport/mcp-2026/client/mcp-2026.client.ts @@ -53,6 +53,14 @@ export interface Mcp2026ClientOptions { handlers?: Mcp2026InputHandlers; /** Maximum MRTR round trips before giving up, guarding against a server that never settles. */ maxInputRounds?: number; + /** + * Per-request timeout in milliseconds. + * + * Without one an unreachable or hung remote leaves every call pending + * forever — including the `server/discover` probe used to negotiate the + * protocol, which would then never fall back. + */ + requestTimeoutMs?: number; /** Injected for tests. */ fetchImpl?: typeof fetch; } @@ -83,7 +91,13 @@ export class Mcp2026Client { private toolSchemas = new Map(); constructor(options: Mcp2026ClientOptions) { - this.options = { maxInputRounds: 8, ...options }; + // Normalize AFTER the spread: `{ maxInputRounds: undefined }` is a legal + // caller shape and spreading it would otherwise wipe out the default. + this.options = { + ...options, + maxInputRounds: options.maxInputRounds ?? 8, + requestTimeoutMs: options.requestTimeoutMs ?? 30_000, + }; } private get fetchImpl(): typeof fetch { @@ -219,8 +233,12 @@ export class Mcp2026Client { private async awaitTask(task: Record): Promise> { const taskId = String(task['taskId']); const interval = typeof task['pollIntervalMs'] === 'number' ? (task['pollIntervalMs'] as number) : 250; - const ttl = typeof task['ttlMs'] === 'number' ? (task['ttlMs'] as number) : 60_000; - const deadline = Date.now() + ttl; + // `ttlMs: null` means "unlimited" per the tasks extension; only a numeric + // TTL bounds the poll loop. Anything else falls back to a finite default so + // a malformed handle cannot spin forever. + const rawTtl = task['ttlMs']; + const ttl = rawTtl === null ? Number.POSITIVE_INFINITY : typeof rawTtl === 'number' ? rawTtl : 60_000; + const deadline = ttl === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : Date.now() + ttl; let current = task; while (Date.now() < deadline) { @@ -280,8 +298,14 @@ export class Mcp2026Client { } let resolveAck: ((value: Record) => void) | undefined; - const acknowledged = new Promise>((resolve) => { - resolveAck = resolve; + let rejectAck: ((reason: Error) => void) | undefined; + let settled = false; + const acknowledged = new Promise>((resolve, reject) => { + resolveAck = (value) => { + settled = true; + resolve(value); + }; + rejectAck = reject; }); void this.pumpSse(response.body, (message) => { @@ -291,9 +315,22 @@ export class Mcp2026Client { return; } if (message['method']) onNotification(message as { method: string; params?: Record }); - }).catch(() => { - // Stream aborted by close() — expected. - }); + }) + .then(() => { + // The stream ended without ever acknowledging. Settle rather than leave + // `await acknowledged` pending for the life of the process. + if (!settled) { + rejectAck?.(new Mcp2026Error(-32603, 'subscriptions/listen stream closed before acknowledgement')); + } + }) + .catch((error: unknown) => { + if (settled) return; // Aborted by close() after a successful ack — expected. + rejectAck?.( + error instanceof Mcp2026Error + ? error + : new Mcp2026Error(-32603, `subscriptions/listen stream failed: ${String(error)}`), + ); + }); return { acknowledged: await acknowledged, close: () => controller.abort() }; } @@ -305,13 +342,25 @@ export class Mcp2026Client { const id = this.nextId++; const { body, headers } = this.buildRequest(method, params, id); - const response = await this.fetchImpl(this.options.url, { - method: 'POST', - headers, - body: JSON.stringify(body), - }); + const controller = new AbortController(); + const timeoutMs = this.options.requestTimeoutMs ?? 30_000; + const timer = setTimeout(() => controller.abort(), timeoutMs); - const payload = await this.readResponse(response); + let payload: JsonRpcResponse; + try { + const response = await this.fetchImpl(this.options.url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + + payload = await this.readResponse(response); + } finally { + // Cleared on BOTH paths, or a rejected request would keep the timer (and + // the event loop) alive until it fires. + clearTimeout(timer); + } if (payload.error) { throw new Mcp2026Error(payload.error.code, payload.error.message, payload.error.data); @@ -393,7 +442,7 @@ export class Mcp2026Client { /** Drain an SSE body, handing each decoded JSON message to `onMessage`. */ private async pumpSse( body: ReadableStream, - onMessage: (message: Record) => void, + onMessage: (message: Record) => void, ): Promise { const reader = body.getReader(); const decoder = new TextDecoder(); diff --git a/libs/sdk/src/transport/mcp-2026/dispatcher.ts b/libs/sdk/src/transport/mcp-2026/dispatcher.ts index 054270016..2b0ff7b4e 100644 --- a/libs/sdk/src/transport/mcp-2026/dispatcher.ts +++ b/libs/sdk/src/transport/mcp-2026/dispatcher.ts @@ -8,6 +8,7 @@ * and result decoration. */ import { MCP_2026_ERROR_CODES, MCP_2026_REMOVED_METHODS, McpError, type Implementation } from '@frontmcp/protocol'; +import { sha256Hex } from '@frontmcp/utils'; import { type FrontMcpContext } from '../../context'; import { InputRequiredSignal, MissingClientCapabilityError } from '../../errors'; @@ -61,7 +62,7 @@ export type DispatchResult = * The spec enumerates these three and adds "Servers MUST NOT send * `InputRequiredResult` responses on any other client requests." */ -export const MRTR_CAPABLE_METHODS = ['tools/call', 'prompts/get', 'resources/read']; +export const MRTR_CAPABLE_METHODS = ['tools/call', 'prompts/get', 'resources/read'] as const; /** * Identify the caller for `requestState` binding. @@ -73,8 +74,13 @@ export const MRTR_CAPABLE_METHODS = ['tools/call', 'prompts/get', 'resources/rea export function resolvePrincipal(authInfo: Record | undefined): string { const clientId = authInfo?.['clientId']; if (typeof clientId === 'string' && clientId.length > 0) return clientId; + const token = authInfo?.['token']; - if (typeof token === 'string' && token.length > 0) return `tok:${token.slice(0, 16)}`; + // Hash the WHOLE token. A prefix would collide: every HS256 JWT starts with + // the same base64url-encoded header, so truncating would map all such callers + // onto one principal and let them redeem each other's `requestState`. + if (typeof token === 'string' && token.length > 0) return `tok:${sha256Hex(token)}`; + return 'anonymous'; } @@ -390,7 +396,7 @@ export async function dispatch2026(options: DispatchOptions): Promise 0x7e) return true; + } + return false; } /** diff --git a/libs/sdk/src/transport/mcp-2026/mrtr.ts b/libs/sdk/src/transport/mcp-2026/mrtr.ts index fbf5dfab3..2e31197b4 100644 --- a/libs/sdk/src/transport/mcp-2026/mrtr.ts +++ b/libs/sdk/src/transport/mcp-2026/mrtr.ts @@ -131,8 +131,16 @@ export class MrtrExchange { } /** True when the client declared support for elicitation in this request. */ - supportsElicitation(): boolean { - return this.supports('elicitation'); + supportsElicitation(mode: 'form' | 'url' = 'form'): boolean { + const declared = this.clientCapabilities['elicitation']; + if (typeof declared !== 'object' || declared === null) return false; + + // `elicitation: {}` means form support implicitly (the schema's + // "form mode only (implicit)" case). URL mode is never implicit — it sends + // the user out of band, so the client has to opt in explicitly. + const modes = declared as { form?: unknown; url?: unknown }; + if (mode === 'url') return modes.url !== undefined; + return modes.form !== undefined || modes.url === undefined; } /** @@ -148,6 +156,8 @@ export class MrtrExchange { method: string, params: Record, map: (raw: Record) => T, + /** Overrides the default capability gate (elicitation narrows it by mode). */ + gate?: { supported: boolean; required: Record }, ): T { this.counters[kind] += 1; const key = `${kind}-${this.counters[kind]}`; @@ -155,9 +165,10 @@ export class MrtrExchange { const recorded = this.responses[key]; if (recorded) return map(recorded); - if (!this.supports(kind)) { + const supported = gate ? gate.supported : this.supports(kind); + if (!supported) { throw new MissingClientCapabilityError( - CAPABILITY_FOR_KIND[kind].required, + gate ? gate.required : CAPABILITY_FOR_KIND[kind].required, `This request requires the \`${CAPABILITY_FOR_KIND[kind].capability}\` client capability`, ); } @@ -168,6 +179,10 @@ export class MrtrExchange { /** Resolve the next `elicit()` call. */ resolveElicitation(pending: PendingElicitation): { status: ElicitStatus; content?: unknown } { + // Gate on the MODE actually being asked for. A client that declared only + // `form` cannot service a `url` elicitation, and the spec forbids emitting + // an input request the client never said it supports. + const mode = pending.mode ?? 'form'; return this.resolve( 'elicitation', 'elicitation/create', @@ -178,6 +193,7 @@ export class MrtrExchange { ...(pending.url ? { url: pending.url } : {}), }, toElicitResult, + { supported: this.supportsElicitation(mode), required: { elicitation: { [mode]: {} } } }, ); } diff --git a/libs/sdk/src/transport/mcp-2026/request-state.ts b/libs/sdk/src/transport/mcp-2026/request-state.ts index d76c88cbb8a1a9595c8f3f8435a91335079ea26d..f0ad24e27b338e65a21664d0a1b541939c140348 100644 GIT binary patch delta 659 zcmZ8e%WB&|6osgyppvK|ap)@CwiGnRu{WVzBwgg;O(2xwet@r~Yio#n$C(+$@uH+( z5CX~~%lts0VERL$AJUN}n^vn~?wmR2o^yV;|8#!ccy9-2=;J}A(=&=xOf6Uqy1W?V zQ}6v&3#=SV9`$fY-Kz}K{HwP;|LI+)Biw9{Q{ErHcs|xYlIbNyFBx!jon+}*uMLIr`hzO38#fLvfX!^5*RsRw3#M| z)Odng8pUk~E0Fz~mp<4;tEmCSkTDhZASE8z#I(RD_e#|rqJ*MLNN_SCzBL8eP-^G+ z`Nsp`5;T@(j5bO@8o*Q&#xwWGiucPga_pdZY23^vE>#%NOmSS$O%t>W!AMRL($;0S zq}6myn3}>x#~)_gh^%xQubi5TLNq_w{BoqpS?79ZwG2ZPtz}hJtl>b2d@VQ#YU$_4 w!Mn!)4TJ*~&xDvi2KO(5`VOCV?nKpayF9}cW7%`{T>zT=Zs*PXb>qX@Ut9F$x&QzG delta 514 zcmZvZ%}T>S6otWvi-@#h-MG4RVKS|zf>nfCP)j#OEv2r-Nix$8CNnyjXbVc;K!ogk z3L&_1?R&V;y^r8D6{$a~Va}a7=icwWRi2#3fss5I#iU~_rLV@(+#)B}a6$&E$vxo{ zX`{4iwv6OuVQ+L0Q%a;mr3md-w|(qHN;2MKbZB4UASP%HtXR<<3pq-3vzl$WI3nBC zSOylVnYX4{DV3lhc%(2_>xzX0JP}@C$s)la5`e+QK18g?v5F;ufZ+28xB%s_P^SUo zRLBr3Cb$Eoib-ScjW3|hbiIoyXM$H7!U%?)WEK~Hc3)l$= z@2P$bShhG=zO+GX$sL{#3?(lISM+S42Gv(KCLxl)UFw=clkGEBWHq zAHojFSlh1fhY0--FYV;-XLG+t{3zUQPT6M;dI?j^r#3NJ4FVh`XS0R$c^S=o0!|&Q A6#xJL diff --git a/libs/sdk/src/transport/mcp-2026/result-decorator.ts b/libs/sdk/src/transport/mcp-2026/result-decorator.ts index bb4b0ea0c..c91a0aaa8 100644 --- a/libs/sdk/src/transport/mcp-2026/result-decorator.ts +++ b/libs/sdk/src/transport/mcp-2026/result-decorator.ts @@ -83,6 +83,10 @@ const ORDERED_LIST_FIELDS: Record = { * registered dynamically — sorting makes the guarantee explicit. * * Applied only on the 2026 path; older revisions keep their existing order. + * + * Ordering is guaranteed WITHIN a page. Concatenating paginated pages does not + * yield a globally sorted list — the cursor defines page boundaries, and this + * sorts each page as it is returned. */ export function orderListResult(method: string, result: Record): Record { const field = ORDERED_LIST_FIELDS[method]; diff --git a/libs/sdk/src/transport/mcp-2026/tasks-extension.ts b/libs/sdk/src/transport/mcp-2026/tasks-extension.ts index 295758171..90d7be4ba 100644 --- a/libs/sdk/src/transport/mcp-2026/tasks-extension.ts +++ b/libs/sdk/src/transport/mcp-2026/tasks-extension.ts @@ -185,7 +185,17 @@ export async function dispatchTasksMethod(options: TasksDispatchOptions): Promis inputRequests: undefined, }); - if (resumed) await resume(resumed); + // Do NOT await execution: `tasks/update` acknowledges immediately and the + // task resumes in the background. Awaiting would turn the whole point of the + // extension — non-blocking long-running work — back into a blocking call. + if (resumed) { + void resume(resumed).catch((error: unknown) => { + scope.logger.error('mcp-2026: resumed task failed', { + taskId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } return { kind: 'result', result: {} }; } diff --git a/libs/skills/catalog/frontmcp-deployment/SKILL.md b/libs/skills/catalog/frontmcp-deployment/SKILL.md index 55772ce84..8b4156a3e 100644 --- a/libs/skills/catalog/frontmcp-deployment/SKILL.md +++ b/libs/skills/catalog/frontmcp-deployment/SKILL.md @@ -1,6 +1,6 @@ --- name: frontmcp-deployment -description: 'Use when deploying, building for production, packaging, or shipping a FrontMCP server. Covers build targets (node, cli SEA binary, browser, embeddable SDK, mcpb archive for Claude Desktop, serverless) and deploying to Vercel (with Vercel KV), AWS Lambda (API Gateway, SAM, CDK), Cloudflare Workers (KV, D1, Durable Objects, v1.3 skills-only), and Node (multi-stage Docker, docker-compose, PM2, nginx). Also the frontmcp.deploy.yaml manifest plus GitHub Action push-resync, and MCP client integration / .mcp.json for Claude Desktop, Claude Code, Cursor, and VS Code over stdio or HTTP. Triggers: deploy, build for production, dockerize, containerize, serverless, edge runtime, go live, ship it.' +description: 'Use when deploying, building for production, packaging, or shipping a FrontMCP server. Covers build targets (node, cli SEA binary, browser, embeddable SDK, mcpb archive for Claude Desktop, serverless) and deploying to Vercel (with Vercel KV), AWS Lambda (API Gateway, SAM, CDK), Cloudflare Workers (KV, D1, Durable Objects, v1.3 skills-only), and Node (multi-stage Docker, docker-compose, PM2, nginx). Also the frontmcp.deploy.yaml manifest plus GitHub Action push-resync, MCP client integration / .mcp.json for Claude Desktop, Claude Code, Cursor, and VS Code over stdio or HTTP, and MCP protocol revisions (serving 2026-07-28 alongside 2024-11-05 through 2025-11-25). Triggers: deploy, build for production, dockerize, containerize, serverless, edge runtime, go live, ship it.' tags: [router, deployment, node, vercel, lambda, cloudflare, cli, browser, sdk, guide] category: deployment targets: [all] @@ -67,6 +67,7 @@ Entry point for deploying and building FrontMCP servers. This skill helps you ch | Write a Dockerfile for Node.js deployment | `deploy-to-node-dockerfile` | Dockerfile configuration for Node.js deployment | | Configure Vercel-specific settings (vercel.json) | `deploy-to-vercel-config` | Vercel-specific configuration (vercel.json) | | Connect MCP clients (Claude, Cursor, VS Code) | `mcp-client-integration` | Configure .mcp.json for stdio, HTTP, or Unix socket transport | +| Serve or consume MCP protocol `2026-07-28` | `protocol-versions` | Stateless requests, `server/discover`, mirrored headers, MRTR, tasks extension, and the `Mcp2026Client` | ### CLI Commands for Deployment and Operations diff --git a/libs/skills/catalog/frontmcp-deployment/references/protocol-versions.md b/libs/skills/catalog/frontmcp-deployment/references/protocol-versions.md new file mode 100644 index 000000000..b5c9792e0 --- /dev/null +++ b/libs/skills/catalog/frontmcp-deployment/references/protocol-versions.md @@ -0,0 +1,183 @@ +--- +name: protocol-versions +description: Serve MCP protocol revision 2026-07-28 alongside every earlier revision, and connect to a 2026 server as a client +--- + +# MCP Protocol Versions + +FrontMCP serves **every MCP revision from `2024-11-05` through `2026-07-28`** on +the same endpoint. The revision is selected per request — there is no +configuration switch and no server-side flag to flip. + +## When to Use This Skill + +### Must Use + +- A client reports `-32020`, `-32021`, or `-32022` against a FrontMCP server +- Building a tool that needs elicitation, sampling, or roots on a 2026 client +- Connecting FrontMCP to a remote MCP server that speaks `2026-07-28` +- Returning long-running work as a task handle under the tasks extension + +### Recommended + +- Auditing which revision a deployed server is actually serving to a client +- Adding `x-mcp-header` annotations so intermediaries can route on tool arguments +- Wiring OpenTelemetry trace context through MCP requests + +### Skip When + +- The client speaks `2025-11-25` or earlier — nothing changes for it +- You are configuring transports/ports (see `deploy-to-node`) + +## How a revision is selected + +A request is served as `2026-07-28` when ANY of these is true: + +- `params._meta` carries `io.modelcontextprotocol/protocolVersion` (a key that + exists only in this revision) +- the `MCP-Protocol-Version` header names a version the session pipeline does not + know (an unknown/future version then gets `-32022`, not a session error) +- the method is `server/discover` or `subscriptions/listen` + +Everything else — including every `initialize` and every request carrying +`Mcp-Session-Id` — takes the session pipeline unchanged. + +## What 2026-07-28 changed + +| Area | Before | 2026-07-28 | +| ------------- | ------------------------------------------ | ---------------------------------------------------- | +| Handshake | `initialize` + `notifications/initialized` | none; `_meta` on every request | +| Sessions | `Mcp-Session-Id` | removed (`GET`/`DELETE` → `405`) | +| Discovery | `initialize` result | `server/discover` | +| Notifications | standalone GET stream | `subscriptions/listen` (opt-in filter) | +| Server→client | `elicitation/create` etc. as requests | MRTR `InputRequiredResult` | +| Log level | `logging/setLevel` | per-request `_meta` `logLevel` | +| Results | bare result | `resultType` + `serverInfo` (+ `ttlMs`/`cacheScope`) | +| Tasks | core protocol | `io.modelcontextprotocol/tasks` extension | +| Not found | `-32002` | `-32602` | + +## Mirrored request headers + +The server validates that headers agree with the body and rejects a mismatch +with `400` + `-32020`. Annotate a tool argument to have it mirrored: + +```ts +const inputSchema = { + region: z.string().describe('Region to query').meta({ 'x-mcp-header': 'Region' }), + query: z.string(), +}; +``` + +A conforming client then sends `Mcp-Param-Region: us-west1` alongside +`MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name`. Non-ASCII values travel as +`=?base64?…?=`. + +## Multi Round-Trip Requests (MRTR) + +`this.elicit()`, `this.sample()`, and `this.listRoots()` no longer round-trip +inline. The server answers the ORIGINAL request with: + +```json +{ + "resultType": "input_required", + "inputRequests": { "elicitation-1": { "method": "elicitation/create", "params": { "message": "Proceed?" } } }, + "requestState": "" +} +``` + +The client gathers the input and re-issues the same request with a NEW id plus +`inputResponses` + the echoed `requestState`. + +**Write tools to be replay-safe.** The tool runs again from the top on the +retry; recorded answers resolve inline. Do not perform irreversible side effects +before the first `elicit()`/`sample()`/`listRoots()` call. + +`requestState` is HMAC-signed and bound to the caller, the originating request, +and a 10-minute expiry — a tampered or replayed blob is discarded and the +exchange restarts. + +The client MUST declare the matching capability, or the server answers `-32021`: + +```json +"io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } } +``` + +## Request-scoped logging and progress + +A client opts in per request: + +```json +"_meta": { "io.modelcontextprotocol/logLevel": "info", "progressToken": "tok-1" } +``` + +`this.notify()` and `this.progress()` then stream on that request's own SSE +response, terminated by the final result. Omit `logLevel` and the server emits +no `notifications/message` at all. + +## Tasks extension + +```json +"io.modelcontextprotocol/clientCapabilities": { + "extensions": { "io.modelcontextprotocol/tasks": {} } +} +``` + +A tool with `execution: { taskSupport: 'optional' }` then returns +`{ "resultType": "task", "task": { "taskId", "status", "ttlMs", "pollIntervalMs" } }`. +Poll `tasks/get`; answer `input_required` with `tasks/update`; `tasks/cancel` +still works. `tasks/list` and `tasks/result` were removed. + +Tasks require an **authenticated** caller — without protocol sessions an +anonymous task cannot be scoped to its creator, so a public server refuses. + +## Connecting as a client + +The upstream `@modelcontextprotocol/sdk` client cannot speak this revision: + +```ts +import { Mcp2026Client } from '@frontmcp/sdk'; + +const client = new Mcp2026Client({ + url: 'https://example.com/mcp', + capabilities: { elicitation: { form: {} } }, + handlers: { onElicit: async () => ({ action: 'accept', content: { confirmed: true } }) }, +}); + +await client.listTools(); +await client.callTool('confirm', { action: 'deploy' }); +``` + +The MRTR retry loop and task polling are handled internally, so `callTool` +resolves with the final result either way. + +For a remote app, negotiate per remote: + +```ts +transportOptions: { + protocolVersion: 'auto'; +} // 'legacy' (default) | '2026-07-28' | 'auto' +``` + +## Deprecated in this revision + +Still functional; do not adopt in new servers: + +- **Roots** → pass directories via tool parameters or server config +- **Sampling** → integrate an LLM provider API directly +- **Logging** → `stderr` or OpenTelemetry +- **HTTP+SSE transport** → Streamable HTTP +- **DCR** → Client ID Metadata Documents + +## Common Mistakes + +❌ Reusing the JSON-RPC id on an MRTR retry — it MUST be a new id +❌ Inspecting or rewriting `requestState` — it is opaque and integrity-protected +❌ Performing side effects before the first `elicit()` — the tool is replayed +❌ Expecting `Mcp-Session-Id` to be echoed — sessions are gone +❌ Calling `tasks/list` or `tasks/result` — both removed (`404` + `-32601`) +❌ Omitting `Mcp-Method`/`Mcp-Name` headers — rejected with `-32020` + +## Related + +- Docs: https://docs.agentfront.dev/frontmcp/fundamentals/protocol-versions +- Spec: https://modelcontextprotocol.io/specification/2026-07-28/changelog diff --git a/libs/skills/catalog/skills-manifest.json b/libs/skills/catalog/skills-manifest.json index e2284c016..3aecc91a7 100644 --- a/libs/skills/catalog/skills-manifest.json +++ b/libs/skills/catalog/skills-manifest.json @@ -4,7 +4,7 @@ { "name": "create-tool", "category": "development/create", - "description": "ALWAYS use this skill when the user asks to build, modify, or audit a FrontMCP tool. Covers @Tool({...}) end-to-end: class and function-style tools, Zod input/output schemas with derived execute() types, dependency injection, error handling, throttling (rate-limit / concurrency / timeout), auth providers, availability constraints, elicitation, interactive UI widgets (MCP Apps / SEP-1865 — including .tsx FileSource, CSP, window.FrontMcpBridge, host-detect resourceMode), annotations, examples metadata, registration in @App, and per-tool unit testing.", + "description": "ALWAYS use this skill when the user asks to build, modify, or audit a FrontMCP tool. Covers @Tool({...}) end-to-end: class and function-style tools, Zod input/output schemas with derived execute() types, dependency injection, error handling, throttling (rate-limit / concurrency / timeout), auth providers, availability constraints, elicitation, interactive UI widgets (MCP Apps / SEP-1865 \u2014 including .tsx FileSource, CSP, window.FrontMcpBridge, host-detect resourceMode), annotations, examples metadata, registration in @App, and per-tool unit testing.", "path": "create-tool", "targets": ["all"], "hasResources": true, @@ -30,19 +30,19 @@ "references": [ { "name": "quick-start", - "description": "60-second tour — minimal tool, schemas, registration, calling it." + "description": "60-second tour \u2014 minimal tool, schemas, registration, calling it." }, { "name": "decorator-options", - "description": "Every field on `@Tool({...})` — what it does, default, when to set it." + "description": "Every field on `@Tool({...})` \u2014 what it does, default, when to set it." }, { "name": "input-schema", - "description": "Define the tool's input contract — raw Zod shapes, refinements, defaults, optional fields." + "description": "Define the tool's input contract \u2014 raw Zod shapes, refinements, defaults, optional fields." }, { "name": "output-schema", - "description": "Define the tool's output contract — Zod shape, primitives, media, multi-content arrays." + "description": "Define the tool's output contract \u2014 Zod shape, primitives, media, multi-content arrays." }, { "name": "derived-types", @@ -50,15 +50,15 @@ }, { "name": "execution-context", - "description": "What ToolContext provides at runtime — this.get, this.fetch, this.notify, this.context." + "description": "What ToolContext provides at runtime \u2014 this.get, this.fetch, this.notify, this.context." }, { "name": "error-handling", - "description": "this.fail, MCP error classes, error flow — when to throw vs fail." + "description": "this.fail, MCP error classes, error flow \u2014 when to throw vs fail." }, { "name": "throttling", - "description": "rateLimit, concurrency, timeout — semantics, interaction, defaults." + "description": "rateLimit, concurrency, timeout \u2014 semantics, interaction, defaults." }, { "name": "auth-providers", @@ -70,23 +70,23 @@ }, { "name": "elicitation", - "description": "this.elicit — request interactive input mid-execution. Server enable + accept/decline/cancel flow." + "description": "this.elicit \u2014 request interactive input mid-execution. Server enable + accept/decline/cancel flow." }, { "name": "ui-widgets", - "description": "@Tool({ ui }) — template formats, servingMode, host-detect resourceMode, CSP, widgetAccessible, MCP Apps spec." + "description": "@Tool({ ui }) \u2014 template formats, servingMode, host-detect resourceMode, CSP, widgetAccessible, MCP Apps spec." }, { "name": "annotations", - "description": "readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title — behavioral hints for clients." + "description": "readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title \u2014 behavioral hints for clients." }, { "name": "function-style-builder", - "description": "tool({...})(handler) — when to pick over a class, register, ctx parameter." + "description": "tool({...})(handler) \u2014 when to pick over a class, register, ctx parameter." }, { "name": "remote-and-esm", - "description": "Tool.esm / Tool.remote — load tools from ESM URLs or remote MCP servers." + "description": "Tool.esm / Tool.remote \u2014 load tools from ESM URLs or remote MCP servers." }, { "name": "registration", @@ -98,7 +98,7 @@ }, { "name": "testing", - "description": "Per-tool unit tests — @frontmcp/testing, mocking DI, asserting output validation." + "description": "Per-tool unit tests \u2014 @frontmcp/testing, mocking DI, asserting output validation." } ], "examples": [ @@ -119,7 +119,7 @@ { "name": "02-basic-function-tool", "level": "basic", - "description": "Function-style `tool({...})(handler)` for a tiny pure-input tool — pick this over a class only when the tool needs no DI / lifecycle / UI.", + "description": "Function-style `tool({...})(handler)` for a tiny pure-input tool \u2014 pick this over a class only when the tool needs no DI / lifecycle / UI.", "tags": ["foundation", "function-tool", "tool-builder"], "features": [ "Using the `tool({...})(handler)` builder for a one-liner", @@ -131,10 +131,10 @@ { "name": "03-tool-with-zod-shape-output", "level": "basic", - "description": "Tool returning structured JSON declared via a Zod raw shape outputSchema — the recommended pattern for any complex output.", + "description": "Tool returning structured JSON declared via a Zod raw shape outputSchema \u2014 the recommended pattern for any complex output.", "tags": ["output-schema", "zod-shape", "structured-output"], "features": [ - "Declaring `outputSchema` as a Zod raw shape `{ field: z.string(), … }`", + "Declaring `outputSchema` as a Zod raw shape `{ field: z.string(), \u2026 }`", "Constraining values with `.int().min(0)` so invalid output is rejected at the boundary", "Letting unrelated fields returned by the implementation (e.g. an upstream API's extras) be stripped silently", "Deriving `OrderSummaryOutput` once so the type and runtime contract can't drift" @@ -143,7 +143,7 @@ { "name": "04-tool-with-zod-schema-output", "level": "advanced", - "description": "Tool returning a discriminated union via a full `z.discriminatedUnion(...)` outputSchema — for outputs that branch on a kind field.", + "description": "Tool returning a discriminated union via a full `z.discriminatedUnion(...)` outputSchema \u2014 for outputs that branch on a kind field.", "tags": ["output-schema", "zod-schema", "discriminated-union"], "features": [ "Using a full Zod schema (`z.discriminatedUnion(...)`) as `outputSchema` instead of a raw shape", @@ -155,7 +155,7 @@ { "name": "05-tool-with-primitive-output", "level": "basic", - "description": "Tool returning a single primitive — `outputSchema: 'string' | 'number' | 'boolean' | 'date'` for single-value outputs.", + "description": "Tool returning a single primitive \u2014 `outputSchema: 'string' | 'number' | 'boolean' | 'date'` for single-value outputs.", "tags": ["output-schema", "primitive-output"], "features": [ "Using a primitive literal (`'string'`, `'number'`, `'boolean'`, `'date'`) for `outputSchema`", @@ -167,19 +167,19 @@ { "name": "06-tool-with-media-output", "level": "intermediate", - "description": "Tool returning binary content (image / audio) or a multi-content array of `[text, image]` — for outputs that aren't plain JSON.", + "description": "Tool returning binary content (image / audio) or a multi-content array of `[text, image]` \u2014 for outputs that aren't plain JSON.", "tags": ["output-schema", "media-output", "image", "multi-content"], "features": [ "Returning a base64-encoded image with `outputSchema: 'image'` and `{ type: 'image', data, mimeType }`", "Returning audio with `outputSchema: 'audio'` (same `{ type: 'audio', data, mimeType }` shape, audio MIME types)", - "Returning multi-content via `outputSchema: ['string', 'image']` — text summary + annotated image in one response", + "Returning multi-content via `outputSchema: ['string', 'image']` \u2014 text summary + annotated image in one response", "When to pick a media literal vs `'resource_link'` (host-fetched URI)" ] }, { "name": "08-tool-with-provider-injection", "level": "intermediate", - "description": "Tool that resolves a DI-registered service via `this.get(TOKEN)` and uses it to power `execute()` — the standard pattern for tools that talk to a database or external API.", + "description": "Tool that resolves a DI-registered service via `this.get(TOKEN)` and uses it to power `execute()` \u2014 the standard pattern for tools that talk to a database or external API.", "tags": ["di", "provider", "this.get", "error-handling"], "features": [ "Defining a typed DI token with `Symbol('UserService')` and `Token`", @@ -191,11 +191,11 @@ { "name": "09-tool-with-multiple-providers", "level": "intermediate", - "description": "Tool composing three DI services — config (env-only), cache (optional, `tryGet`), and database (required) — the realistic shape for a production tool.", + "description": "Tool composing three DI services \u2014 config (env-only), cache (optional, `tryGet`), and database (required) \u2014 the realistic shape for a production tool.", "tags": ["di", "multiple-providers", "cache-aside", "tryGet"], "features": [ "Resolving multiple providers via `this.get(TOKEN)` and `this.tryGet(TOKEN)`", - "Cache-aside pattern — check `tryGet(CACHE)` first, fall back to the database", + "Cache-aside pattern \u2014 check `tryGet(CACHE)` first, fall back to the database", "Reading typed config from a `CONFIG` token vs `process.env` directly", "Letting the tool work in production (with cache) AND in test (without it)" ] @@ -203,12 +203,12 @@ { "name": "11-tool-with-fetch", "level": "intermediate", - "description": "Tool calling an external HTTP API with `this.fetch` — context propagation, status-code handling, and bounding the call with a tool `timeout`.", + "description": "Tool calling an external HTTP API with `this.fetch` \u2014 context propagation, status-code handling, and bounding the call with a tool `timeout`.", "tags": ["fetch", "http", "external-api", "error-handling"], "features": [ "Using `this.fetch(url, init?)` so trace context propagates to the upstream service", "Translating non-2xx HTTP responses into `PublicMcpError` so the MCP client gets a clean error", - "Bounding the call with a tool `timeout` (and `this.fetch`'s built-in per-request timeout) — without relying on a non-existent context abort signal", + "Bounding the call with a tool `timeout` (and `this.fetch`'s built-in per-request timeout) \u2014 without relying on a non-existent context abort signal", "Letting genuine network errors (DNS failure, ECONNREFUSED) propagate to the framework's error flow" ] }, @@ -227,36 +227,36 @@ { "name": "13-tool-with-single-auth-provider", "level": "intermediate", - "description": "Tool requiring a single OAuth provider via the `authProviders: ['github']` string shorthand — credentials loaded before `execute()` runs.", + "description": "Tool requiring a single OAuth provider via the `authProviders: ['github']` string shorthand \u2014 credentials loaded before `execute()` runs.", "tags": ["auth-providers", "oauth", "github", "this.authProviders"], "features": [ "Declaring a single required OAuth provider with the `authProviders: ['github']` shorthand", "Reading pre-formatted credentials via `await this.authProviders.headers('github')`", - "Letting the framework reject calls whose required credential is missing **before** `execute()` runs — a JSON-RPC `-32001` (MCP `UNAUTHORIZED`) error whose `data` carries `{ tool, providers: ['github'], authUrl }` (no auth-check boilerplate)", + "Letting the framework reject calls whose required credential is missing **before** `execute()` runs \u2014 a JSON-RPC `-32001` (MCP `UNAUTHORIZED`) error whose `data` carries `{ tool, providers: ['github'], authUrl }` (no auth-check boilerplate)", "Trusting the framework to handle token refresh, expiration, and the connect/authorize URL" ] }, { "name": "14-tool-with-multiple-auth-providers", "level": "advanced", - "description": "Tool with the full `authProviders` mapping form — one required provider with explicit scopes, one optional provider with an alias, and graceful degradation when the optional creds are missing.", + "description": "Tool with the full `authProviders` mapping form \u2014 one required provider with explicit scopes, one optional provider with an alias, and graceful degradation when the optional creds are missing.", "tags": ["auth-providers", "oauth", "scopes", "optional-auth", "this.authProviders.headers"], "features": [ "Using the object form of `authProviders` to set `required`, `scopes`, and `alias`", "Declaring required OAuth scopes that the server advertises in its Protected Resource Metadata (`scopes_supported`) so clients request them", "Resolving an optional provider via `await this.authProviders.headers('cloud')` (returns an empty object `{}` when absent)", - "Branching the tool's behavior — full deploy when both providers are present; preview-only when the cloud provider is missing", + "Branching the tool's behavior \u2014 full deploy when both providers are present; preview-only when the cloud provider is missing", "The required `github` provider gating the call: when its credential is missing the framework aborts before `execute()` with `-32001` and `data: { tool, providers: ['github'], authUrl }`; the optional `aws`/`cloud` provider never gates" ] }, { "name": "15-tool-with-credential-vault", "level": "advanced", - "description": "Tool that reads a user-supplied static credential (a Slack webhook URL) from the per-session encrypted credential vault — the pattern for credentials that aren't OAuth.", + "description": "Tool that reads a user-supplied static credential (a Slack webhook URL) from the per-session encrypted credential vault \u2014 the pattern for credentials that aren't OAuth.", "tags": ["auth-providers", "credential-vault", "slack-webhook", "encryption-at-rest"], "features": [ "Declaring a vault-backed auth provider with `authProviders: ['slack-webhook']`", - "Reading the user's pasted-in credential via `await this.authProviders.headers('slack-webhook')` — same API as OAuth", + "Reading the user's pasted-in credential via `await this.authProviders.headers('slack-webhook')` \u2014 same API as OAuth", "Letting the framework handle per-session AES-256-GCM encryption at rest (Redis or memory store)", "Knowing when to pick the vault (static secrets the user knows) vs OAuth (delegated identity)" ] @@ -264,19 +264,19 @@ { "name": "16-tool-with-rate-limit", "level": "intermediate", - "description": "Tool with `rateLimit: { maxRequests, windowMs }` capping invocations per session per minute — the protection for expensive / external-API-billed operations.", + "description": "Tool with `rateLimit: { maxRequests, windowMs }` capping invocations per session per minute \u2014 the protection for expensive / external-API-billed operations.", "tags": ["throttling", "rate-limit", "abuse-protection"], "features": [ "Capping the tool to N invocations per windowMs, partitioned per session via `partitionBy: 'session'`", "Letting the framework reject over-limit calls with `RateLimitError` (code `'RATE_LIMIT_EXCEEDED'`, HTTP status 429) carrying a retry-after hint in its message that clients can back off against", "Combining `rateLimit` with `annotations.openWorldHint: true` so clients know the tool talks to billed external services", - "Sizing the limit against upstream quota / billing — not just \"what feels reasonable\"" + "Sizing the limit against upstream quota / billing \u2014 not just \"what feels reasonable\"" ] }, { "name": "17-tool-with-concurrency-and-timeout", "level": "advanced", - "description": "Tool with `concurrency` + `timeout` for a real bottleneck (PDF rendering) — caps simultaneous in-flight work AND hard-caps per-call duration.", + "description": "Tool with `concurrency` + `timeout` for a real bottleneck (PDF rendering) \u2014 caps simultaneous in-flight work AND hard-caps per-call duration.", "tags": ["throttling", "concurrency", "timeout"], "features": [ "Capping simultaneous in-flight executions with `concurrency: { maxConcurrent }` (server-wide by default)", @@ -288,7 +288,7 @@ { "name": "18-tool-with-progress-and-notify", "level": "intermediate", - "description": "Long-running tool emitting progress updates (`this.progress`), log notifications (`this.notify`), and stage markers (`this.mark`) — the standard pattern for jobs you don't want to feel hung.", + "description": "Long-running tool emitting progress updates (`this.progress`), log notifications (`this.notify`), and stage markers (`this.mark`) \u2014 the standard pattern for jobs you don't want to feel hung.", "tags": ["progress", "notifications", "mark", "long-running"], "features": [ "Emitting per-item progress with `await this.progress(current, total, message)`", @@ -300,19 +300,19 @@ { "name": "19-tool-with-elicitation", "level": "advanced", - "description": "Tool that pauses mid-execution to ask the user for confirmation + extra input via `this.elicit(...)` — the safe pattern for destructive or expensive actions.", + "description": "Tool that pauses mid-execution to ask the user for confirmation + extra input via `this.elicit(...)` \u2014 the safe pattern for destructive or expensive actions.", "tags": ["elicitation", "this.elicit", "destructive-action", "confirmation"], "features": [ "Calling `this.elicit(message, z.object({ ... }))` to request interactive input mid-`execute()`", - "Branching on `result.status` — `accept` / `decline` / `cancel` — and matching the early returns against `outputSchema`", + "Branching on `result.status` \u2014 `accept` / `decline` / `cancel` \u2014 and matching the early returns against `outputSchema`", "Pairing elicitation with `annotations.destructiveHint: true` so clients know to render the confirmation prominently", - "Requiring `elicitation: { enabled: true }` at the `@FrontMcp({...})` server level — and what fails when it isn't" + "Requiring `elicitation: { enabled: true }` at the `@FrontMcp({...})` server level \u2014 and what fails when it isn't" ] }, { "name": "20-tool-with-annotations", "level": "basic", - "description": "Four tools showing the standard annotation combinations — read-only query, destructive delete, send-email side-effecting, external-API search — and the client behavior each combination opts into.", + "description": "Four tools showing the standard annotation combinations \u2014 read-only query, destructive delete, send-email side-effecting, external-API search \u2014 and the client behavior each combination opts into.", "tags": ["annotations", "readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"], "features": [ "Setting `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint` to opt into specific client behaviors (auto-retry, confirmation gating, parallelization)", @@ -324,43 +324,43 @@ { "name": "21-tool-with-availability-constraints", "level": "advanced", - "description": "Three tools showing the `availableWhen` axes — macOS-only OS gate, production+Node runtime gate, and a `surface` gate that allows agent + job invocation but blocks direct MCP-client calls.", + "description": "Three tools showing the `availableWhen` axes \u2014 macOS-only OS gate, production+Node runtime gate, and a `surface` gate that allows agent + job invocation but blocks direct MCP-client calls.", "tags": ["availableWhen", "os", "runtime", "surface", "EntryUnavailableError"], "features": [ "Restricting a tool to macOS with `availableWhen: { os: ['darwin'] }`", - "Composing constraints — `runtime: ['node']` AND `env: ['production']` — both must match for the tool to be available", + "Composing constraints \u2014 `runtime: ['node']` AND `env: ['production']` \u2014 both must match for the tool to be available", "Using the `surface` axis to expose an internal tool to agents and jobs while hiding it from direct user invocation", - "Knowing what happens on mismatch — `EntryUnavailableError` (`-32003` FORBIDDEN) with `data.missingAxes` so clients show the right \"not available here\" reason" + "Knowing what happens on mismatch \u2014 `EntryUnavailableError` (`-32003` FORBIDDEN) with `data.missingAxes` so clients show the right \"not available here\" reason" ] }, { "name": "22-tool-with-ui-html-template", "level": "intermediate", - "description": "Tool with an inline HTML function template — `ui: { template: (ctx) => '
' }` — for a quick widget that doesn't need a separate `.tsx` file.", + "description": "Tool with an inline HTML function template \u2014 `ui: { template: (ctx) => '
\u2026
' }` \u2014 for a quick widget that doesn't need a separate `.tsx` file.", "tags": ["ui", "ui-widgets", "html-template", "escapeHtml", "TemplateContext"], "features": [ "Adding a `ui:` block with a function template `(ctx: TemplateContext) => string`", "Annotating `ctx` explicitly to dodge the TS7006 inference gap on the union `ui.template` type", "Always escaping user-controlled output with `ctx.helpers.escapeHtml(...)` so the widget can't XSS itself", - "Reading from `ctx.output` and `ctx.helpers` — the typed runtime context the template renderer hands you" + "Reading from `ctx.output` and `ctx.helpers` \u2014 the typed runtime context the template renderer hands you" ] }, { "name": "23-tool-with-ui-filesource-tsx", "level": "advanced", - "description": "Tool with a `.tsx` widget in a separate file via the `FileSource` form — the recommended pattern for any React widget. Path anchored with `import.meta.url` so it survives any cwd.", + "description": "Tool with a `.tsx` widget in a separate file via the `FileSource` form \u2014 the recommended pattern for any React widget. Path anchored with `import.meta.url` so it survives any cwd.", "tags": ["ui", "ui-widgets", "FileSource", "tsx", "import.meta.url", "host-detect"], "features": [ "Pointing `template` at a sibling `.tsx` file via the `FileSource` form `{ file: ... }`", "Anchoring the path to the tool source with `fileURLToPath(new URL('./...widget.tsx', import.meta.url))` so `process.cwd()` doesn't matter", - "Leaving `resourceMode` unset — the framework host-detects (`'inline'` for Claude, `'cdn'` for others)", + "Leaving `resourceMode` unset \u2014 the framework host-detects (`'inline'` for Claude, `'cdn'` for others)", "Naming the widget `*.widget.tsx` so the scaffolded `tsconfig.json`'s `exclude` keeps it out of the server typecheck" ] }, { "name": "24-tool-with-ui-csp-and-bridge", "level": "advanced", - "description": "Interactive tool widget that fetches from an allow-listed CSP origin and invokes another tool via `window.FrontMcpBridge.callTool` — the full pattern for live-data widgets that need cross-tool composition.", + "description": "Interactive tool widget that fetches from an allow-listed CSP origin and invokes another tool via `window.FrontMcpBridge.callTool` \u2014 the full pattern for live-data widgets that need cross-tool composition.", "tags": ["ui", "csp", "widgetAccessible", "FrontMcpBridge", "interactive-widget"], "features": [ "Restricting the widget's outbound `fetch` via `ui.csp.connectDomains` (emitted on the resource per #455)", @@ -372,7 +372,7 @@ { "name": "25-tool-handing-off-to-job", "level": "advanced", - "description": "Thin tool that validates input and enqueues a `@Job` to do the heavy lifting — the right pattern for any operation that takes more than a few seconds.", + "description": "Thin tool that validates input and enqueues a `@Job` to do the heavy lifting \u2014 the right pattern for any operation that takes more than a few seconds.", "tags": ["composition", "jobs", "job-handoff", "hideFromDiscovery"], "features": [ "Splitting a long-running operation into a thin tool (validates, enqueues, returns a tracking handle) plus a `@Job` (does the work)", @@ -384,10 +384,10 @@ { "name": "26-tool-with-resource-link-output", "level": "advanced", - "description": "Tool returning `outputSchema: 'resource_link'` — the URI is sent to the client; the client fetches the body via `resources/read`. The right pattern for large or cacheable payloads.", + "description": "Tool returning `outputSchema: 'resource_link'` \u2014 the URI is sent to the client; the client fetches the body via `resources/read`. The right pattern for large or cacheable payloads.", "tags": ["output-schema", "resource_link", "large-payload", "caching"], "features": [ - "Returning `outputSchema: 'resource_link'` from a tool — `{ type: 'resource_link', uri }`, body fetched separately", + "Returning `outputSchema: 'resource_link'` from a tool \u2014 `{ type: 'resource_link', uri }`, body fetched separately", "Pairing the tool with a matching `@Resource({ uri: 'export://{exportId}.csv' })` URI template that resolves to the actual body", "When `'resource_link'` beats `'image'` / `'audio'` / a raw byte response (large payloads, cacheable URIs, deferred fetch)", "Cross-linking to the `create-resource` skill for the URI-template resource on the other end" @@ -396,13 +396,13 @@ { "name": "27-tool-with-examples-metadata", "level": "basic", - "description": "Tool with the `examples: [...]` field on `@Tool({...})` — concrete input (and optional expected output) examples consumed by the CodeCall `codecall:describe` tool to give agents accurate usage examples.", + "description": "Tool with the `examples: [...]` field on `@Tool({...})` \u2014 concrete input (and optional expected output) examples consumed by the CodeCall `codecall:describe` tool to give agents accurate usage examples.", "tags": ["examples-metadata", "codecall", "describe"], "features": [ "Adding `examples: [{ description, input, output? }]` to `@Tool({...})` so `codecall:describe` surfaces canned invocations", "Writing realistic example inputs so the generated describe output is concrete, not abstract", "Including `output?` for examples where showing the expected result helps an agent understand the tool", - "Why `examples` are advisory metadata — not emitted in `tools/list`, only consumed by `codecall:describe`" + "Why `examples` are advisory metadata \u2014 not emitted in `tools/list`, only consumed by `codecall:describe`" ] } ], @@ -414,7 +414,7 @@ }, { "name": "derive-execute-types", - "constraint": "`execute()` parameter and return types come from `ToolInputOf<>` / `ToolOutputOf<>` — never duplicated inline.", + "constraint": "`execute()` parameter and return types come from `ToolInputOf<>` / `ToolOutputOf<>` \u2014 never duplicated inline.", "severity": "required" }, { @@ -424,7 +424,7 @@ }, { "name": "no-toolcontext-generics", - "constraint": "`class MyTool extends ToolContext` — never `extends ToolContext`.", + "constraint": "`class MyTool extends ToolContext` \u2014 never `extends ToolContext`.", "severity": "required" }, { @@ -444,7 +444,7 @@ }, { "name": "use-this-fail-for-business-errors", - "constraint": "`this.fail(new SomeMcpError(...))` for business-logic errors — never raw `throw new Error(...)`.", + "constraint": "`this.fail(new SomeMcpError(...))` for business-logic errors \u2014 never raw `throw new Error(...)`.", "severity": "required" }, { @@ -454,7 +454,7 @@ }, { "name": "widget-resource-mode-host-detect", - "constraint": "Leave `ui.resourceMode` unset — the framework host-detects (`inline` for Claude, `cdn` for others).", + "constraint": "Leave `ui.resourceMode` unset \u2014 the framework host-detects (`inline` for Claude, `cdn` for others).", "severity": "recommended" } ] @@ -471,7 +471,7 @@ "references": [ { "name": "custom-auth-ui", - "description": "Replace FrontMCP's built-in OAuth pages with custom React components using the auth.ui slot→file map and auth.extras name→handler map (no decorator, no class) plus the @frontmcp/ui/auth hooks.", + "description": "Replace FrontMCP's built-in OAuth pages with custom React components using the auth.ui slot\u2192file map and auth.extras name\u2192handler map (no decorator, no class) plus the @frontmcp/ui/auth hooks.", "examples": [ { "name": "login-slot", @@ -480,7 +480,7 @@ "tags": ["auth", "auth-ui", "login", "custom-ui", "react", "client-rendered"], "features": [ "Mapping a slot to a `.tsx` file with `auth.ui: { login: './login.tsx' }` (the supported render path)", - "Using a RELATIVE path auto-anchored to the config file — no `fileURLToPath`, no decorator, no class", + "Using a RELATIVE path auto-anchored to the config file \u2014 no `fileURLToPath`, no decorator, no class", "Reading the injected `AuthFlowState` via `useAuthFlow()` and submitting with `
`", "Letting `` render the enclosing finish `` with the `pending_auth_id` + `csrf` hidden fields", "The SDK transpiling the `.tsx` server-side and inlining it as an ES module (deps from esm.sh via an import-map) + appending the `mountAuthPage` call automatically" @@ -488,7 +488,7 @@ }, { "name": "multi-step-auth-extra", - "description": "Add a server-validated multi-step field to a custom login page with auth.extras: { 'envs:add': fn }, useExtraField, and useAddedItems — accepted rows accumulate server-side and reflect back without a reload.", + "description": "Add a server-validated multi-step field to a custom login page with auth.extras: { 'envs:add': fn }, useExtraField, and useAddedItems \u2014 accepted rows accumulate server-side and reflect back without a reload.", "level": "advanced", "tags": ["auth", "auth-ui", "auth-extras", "useExtraField", "useAddedItems", "multi-step", "react"], "features": [ @@ -693,7 +693,7 @@ "features": [ "Selecting the secure-store backing via `auth.secureStore` (memory / sqlite / redis / custom backend) plus a namespace `scope`", "Reading/writing arbitrary user-typed secrets from a tool via `this.secureStore.set/get/list/delete` (JSON-serialized, scoped to the session/subject)", - "Backing the store with an OS keychain by supplying a `SecureStoreBackend` — no native dependency is bundled by the framework", + "Backing the store with an OS keychain by supplying a `SecureStoreBackend` \u2014 no native dependency is bundled by the framework", "Understanding scope: `user` (keyed by sub, default), `session` (keyed by sessionId), `global` (server-wide)" ] } @@ -793,7 +793,7 @@ "level": "intermediate", "tags": ["config", "http", "routes", "custom", "webhook", "download", "auth"], "features": [ - "Registering custom HTTP endpoints with `http.routes` — no tool/resource/prompt needed", + "Registering custom HTTP endpoints with `http.routes` \u2014 no tool/resource/prompt needed", "A POST endpoint that validates a user-entered secret server-side (the `/connect-env` pattern)", "Overriding the default `application/json` Content-Type for binary/HTML delivery", "Gating a route behind the MCP `session:verify` flow with `auth: true`", @@ -1058,7 +1058,7 @@ }, { "name": "configure-skills-http", - "description": "Full reference for skillsConfig — HTTP catalog endpoints, auth, caching, instructions injection, and tamper-evident audit log.", + "description": "Full reference for skillsConfig \u2014 HTTP catalog endpoints, auth, caching, instructions injection, and tamper-evident audit log.", "examples": [ { "name": "inject-instructions", @@ -1079,7 +1079,7 @@ "tags": ["config", "skills", "audit", "hs256", "development"], "features": [ "Bootstraps the audit subsystem via setSkillAuditFactory(...) before FrontMcp registers", - "MemoryAuditStore keeps records in-process — perfect for tests, lost on restart", + "MemoryAuditStore keeps records in-process \u2014 perfect for tests, lost on restart", "Hs256AuditSigner refuses to start when NODE_ENV === production with a random key", "subjectMode: 'hash' redacts user identifiers while keeping them correlatable" ] @@ -1103,11 +1103,24 @@ { "name": "frontmcp-deployment", "category": "deployment", - "description": "Use when deploying, building for production, packaging, or shipping a FrontMCP server. Covers build targets (node, cli SEA binary, browser, embeddable SDK, mcpb archive for Claude Desktop, serverless) and deploying to Vercel (with Vercel KV), AWS Lambda (API Gateway, SAM, CDK), Cloudflare Workers (KV, D1, Durable Objects, v1.3 skills-only), and Node (multi-stage Docker, docker-compose, PM2, nginx). Also the frontmcp.deploy.yaml manifest plus GitHub Action push-resync, and MCP client integration / .mcp.json for Claude Desktop, Claude Code, Cursor, and VS Code over stdio or HTTP. Triggers: deploy, build for production, dockerize, containerize, serverless, edge runtime, go live, ship it.", + "description": "Use when deploying, building for production, packaging, or shipping a FrontMCP server. Covers build targets (node, cli SEA binary, browser, embeddable SDK, mcpb archive for Claude Desktop, serverless) and deploying to Vercel (with Vercel KV), AWS Lambda (API Gateway, SAM, CDK), Cloudflare Workers (KV, D1, Durable Objects, v1.3 skills-only), and Node (multi-stage Docker, docker-compose, PM2, nginx). Also the frontmcp.deploy.yaml manifest plus GitHub Action push-resync, MCP client integration / .mcp.json for Claude Desktop, Claude Code, Cursor, and VS Code over stdio or HTTP, and MCP protocol revisions (serving 2026-07-28 alongside 2024-11-05 through 2025-11-25). Triggers: deploy, build for production, dockerize, containerize, serverless, edge runtime, go live, ship it.", "path": "frontmcp-deployment", "targets": ["all"], "hasResources": true, - "tags": ["router", "deployment", "node", "vercel", "lambda", "cloudflare", "cli", "browser", "sdk", "guide"], + "tags": [ + "router", + "deployment", + "node", + "vercel", + "lambda", + "cloudflare", + "cli", + "browser", + "sdk", + "guide", + "protocol", + "mcp-2026" + ], "bundle": ["recommended", "minimal", "full"], "references": [ { @@ -1116,7 +1129,7 @@ "examples": [ { "name": "browser-build-with-custom-entry", - "description": "Build a browser bundle using a dedicated client entry file that avoids Node.js-only imports. Re-export the real `@frontmcp/react` symbols (`useListTools`, `useListResources`, `useCallTool`) — `useTools`/`useResources` do not exist.", + "description": "Build a browser bundle using a dedicated client entry file that avoids Node.js-only imports. Re-export the real `@frontmcp/react` symbols (`useListTools`, `useListResources`, `useCallTool`) \u2014 `useTools`/`useResources` do not exist.", "level": "intermediate", "tags": ["deployment", "browser", "custom", "entry"], "features": [ @@ -1138,7 +1151,7 @@ }, { "name": "react-provider-setup", - "description": "Connect a React application to a FrontMCP server using `@frontmcp/react`. `FrontMcpProvider` takes a `DirectMcpServer` instance via the `server` prop — there is no `serverUrl` option.", + "description": "Connect a React application to a FrontMCP server using `@frontmcp/react`. `FrontMcpProvider` takes a `DirectMcpServer` instance via the `server` prop \u2014 there is no `serverUrl` option.", "level": "basic", "tags": ["deployment", "react", "browser", "provider", "setup"], "features": [ @@ -1235,11 +1248,11 @@ }, { "name": "deploy-to-cloudflare-skills-only", - "description": "Deploy a FrontMCP server to Cloudflare Workers using the v1.3 skills-only model — OpenAPI as capability inventory, AgentScript with namespaced bindings, four meta-tools, hot-reload via GitHub Action and a signed-bundle webhook." + "description": "Deploy a FrontMCP server to Cloudflare Workers using the v1.3 skills-only model \u2014 OpenAPI as capability inventory, AgentScript with namespaced bindings, four meta-tools, hot-reload via GitHub Action and a signed-bundle webhook." }, { "name": "deploy-manifest-yaml", - "description": "The frontmcp.deploy.yaml v1 schema — declarative manifest the GitHub Action consumes on every push to build, sign, and hot-reload the Cloudflare Worker." + "description": "The frontmcp.deploy.yaml v1 schema \u2014 declarative manifest the GitHub Action consumes on every push to build, sign, and hot-reload the Cloudflare Worker." }, { "name": "deploy-to-cloudflare", @@ -1297,7 +1310,7 @@ }, { "name": "lambda-handler-with-cors", - "description": "CORS for a FrontMCP Lambda is configured at the API Gateway HTTP API level, not in the handler. `frontmcp build --target lambda` writes `dist/lambda/handler.cjs` — your `@FrontMcp` server is wrapped automatically with `@codegenie/serverless-express`, so CORS belongs on the gateway.", + "description": "CORS for a FrontMCP Lambda is configured at the API Gateway HTTP API level, not in the handler. `frontmcp build --target lambda` writes `dist/lambda/handler.cjs` \u2014 your `@FrontMcp` server is wrapped automatically with `@codegenie/serverless-express`, so CORS belongs on the gateway.", "level": "intermediate", "tags": ["deployment", "lambda", "handler", "cors"], "features": [ @@ -1396,14 +1409,14 @@ "level": "basic", "tags": ["deployment", "vercel", "serverless", "config", "minimal"], "features": [ - "The exact shape of the auto-generated `vercel.json` — three keys, nothing else", + "The exact shape of the auto-generated `vercel.json` \u2014 three keys, nothing else", "That routing and function configuration live in `.vercel/output/`, not `vercel.json`", "That hand-authoring `api/frontmcp.ts` references in `vercel.json` is unnecessary and breaks deploys" ] }, { "name": "vercel-config-with-security-headers", - "description": "The Vercel adapter emits a minimal `vercel.json` (version + buildCommand + installCommand). You can layer extra Vercel-supported keys on top after the build — but never add `functions: { 'api/frontmcp.ts': ... }` or `rewrites` to `/api/frontmcp` (the build does not produce an `api/` directory).", + "description": "The Vercel adapter emits a minimal `vercel.json` (version + buildCommand + installCommand). You can layer extra Vercel-supported keys on top after the build \u2014 but never add `functions: { 'api/frontmcp.ts': ... }` or `rewrites` to `/api/frontmcp` (the build does not produce an `api/` directory).", "level": "intermediate", "tags": ["deployment", "vercel", "security", "config", "headers"], "features": [ @@ -1420,18 +1433,18 @@ "examples": [ { "name": "vercel-mcp-endpoint-test", - "description": "Verify a Vercel-deployed FrontMCP server by testing health, tool listing, and tool invocation. The CLI emits the Build Output API v3 structure — there is no `api/frontmcp.ts` to test against; the function lives at `.vercel/output/functions/index.func/handler.cjs` and is routed via `.vercel/output/config.json`.", + "description": "Verify a Vercel-deployed FrontMCP server by testing health, tool listing, and tool invocation. The CLI emits the Build Output API v3 structure \u2014 there is no `api/frontmcp.ts` to test against; the function lives at `.vercel/output/functions/index.func/handler.cjs` and is routed via `.vercel/output/config.json`.", "level": "advanced", "tags": ["deployment", "json-rpc", "vercel", "mcp", "endpoint"], "features": [ "Testing the health endpoint (`/healthz`) and MCP JSON-RPC API of a deployed Vercel function", "Using preview deployments to validate changes before promoting to production", - "Vercel plan limits for `maxDuration` (Hobby: 10s, Pro: 60s, Enterprise: 900s) — configure these in the Vercel dashboard, not via `functions: { 'api/frontmcp.ts': ... }`" + "Vercel plan limits for `maxDuration` (Hobby: 10s, Pro: 60s, Enterprise: 900s) \u2014 configure these in the Vercel dashboard, not via `functions: { 'api/frontmcp.ts': ... }`" ] }, { "name": "vercel-with-kv", - "description": "Deploy a FrontMCP server to Vercel serverless functions with Vercel KV for session persistence. The CLI emits the full Build Output API v3 structure for you — you do **not** author `api/frontmcp.ts` and you do **not** add a `rewrites` block.", + "description": "Deploy a FrontMCP server to Vercel serverless functions with Vercel KV for session persistence. The CLI emits the full Build Output API v3 structure for you \u2014 you do **not** author `api/frontmcp.ts` and you do **not** add a `rewrites` block.", "level": "basic", "tags": ["deployment", "vercel-kv", "vercel", "session", "performance", "serverless"], "features": [ @@ -1442,7 +1455,7 @@ }, { "name": "vercel-with-skills-cache", - "description": "Deploy a FrontMCP server to Vercel with skills enabled and KV-backed skill caching. The CLI handles the Build Output API v3 emission for you — your job is to configure the server and provision Vercel KV.", + "description": "Deploy a FrontMCP server to Vercel with skills enabled and KV-backed skill caching. The CLI handles the Build Output API v3 emission for you \u2014 your job is to configure the server and provision Vercel KV.", "level": "intermediate", "tags": ["deployment", "vercel-kv", "vercel", "cache", "skills"], "features": [ @@ -1494,6 +1507,10 @@ ] } ] + }, + { + "name": "protocol-versions", + "description": "Serve MCP protocol revision 2026-07-28 alongside every earlier revision, and connect to a 2026 server as a client" } ] }, @@ -1803,7 +1820,7 @@ "tags": ["development", "provider", "config", "api", "providers"], "features": [ "A configuration provider using `readonly` properties from environment variables (sync construction)", - "An API client provider that reads credentials in the constructor (no `onInit` — `@Provider` has no lifecycle hooks)", + "An API client provider that reads credentials in the constructor (no `onInit` \u2014 `@Provider` has no lifecycle hooks)", "Folder-per-provider layout (`src/apps/main/providers//`) with a barrel `index.ts` and a co-located `.provider.spec.ts`", "Top-level `src/apps/main/providers/index.ts` barrel re-exporting each provider folder", "Registering providers at `@FrontMcp` level for server-wide sharing across all apps", @@ -2024,7 +2041,7 @@ "tags": ["development", "database", "multi-app", "decorators", "multi", "app"], "features": [ "Organizing a server into multiple `@App` modules (`analytics` and `admin`)", - "Decorating a service class with `@Provider({ name, scope })` so it acts as its own DI token (the strict schema rejects `useFactory`/`useClass`/`provide` — use `AsyncProvider` for those)", + "Decorating a service class with `@Provider({ name, scope })` so it acts as its own DI token (the strict schema rejects `useFactory`/`useClass`/`provide` \u2014 use `AsyncProvider` for those)", "Accessing injected dependencies via `this.get(DatabaseClient)` in tools and resources", "Using `@ResourceTemplate` with URI parameters (`{dashboardId}`) for dynamic resources", "Registering a `@Plugin` at the server level so it applies across all apps", @@ -2148,7 +2165,7 @@ "level": "intermediate", "tags": ["development", "openapi", "adapters", "security", "ssrf", "filtering"], "features": [ - "Secure defaults: external `$ref` resolution off, spec-URL redirects not followed, internal/private targets blocked (DNS-resolved) — on `mcp-from-openapi` >= 2.5.0", + "Secure defaults: external `$ref` resolution off, spec-URL redirects not followed, internal/private targets blocked (DNS-resolved) \u2014 on `mcp-from-openapi` >= 2.5.0", "Opting back into external refs with `allowedProtocols`, and restricting the spec URL + `$ref`s with `allowedHosts`", "Using `allowInternalIPs` for trusted internal/local targets (governs the spec URL and `$ref`s)", "Filtering operations with `includeOperations`, `excludeOperations`, and `filterFn`", @@ -2218,7 +2235,7 @@ }, { "name": "skill-audit-log", - "description": "Tamper-evident, hash-chained audit log for skill action executions — pluggable signer, pluggable store, offline verification.", + "description": "Tamper-evident, hash-chained audit log for skill action executions \u2014 pluggable signer, pluggable store, offline verification.", "examples": [ { "name": "verify-chain", @@ -2228,7 +2245,7 @@ "features": [ "verifyChain returns { ok, breakAt?, reason? } and exits with the first detected break", "defaultAuditSignatureVerifier dispatches on record.signatureAlg (HS256 or RS256)", - "Trusted-keys registry maps signatureKeyId → public key PEM", + "Trusted-keys registry maps signatureKeyId \u2192 public key PEM", "iterate() reads the chain in order from any SkillAuditStore implementation" ] }, @@ -2341,7 +2358,7 @@ "features": [ "Class-as-token DI: `@Provider({ name, scope })` and inject via `this.get(TaskStoreProvider)`", "Building the singleton with `AsyncProvider({ provide, name, scope, useFactory })` for async setup", - "Cleanup: explicit `disconnect()` method (called from the host before `server.dispose()`) — `@Provider` has no `onDestroy` hook", + "Cleanup: explicit `disconnect()` method (called from the host before `server.dispose()`) \u2014 `@Provider` has no `onDestroy` hook", "Using `@frontmcp/utils` for `randomUUID()` instead of `node:crypto`", "Per-user data isolation using Redis hash keys (`tasks:${userId}`)" ] @@ -2418,7 +2435,7 @@ "Setting per-tool TTL via `@Tool({ cache: { ttl } })` metadata in seconds", "Using Redis-backed cache for multi-instance consistency", "Configuring connection pool limits and timeouts to prevent resource exhaustion", - "Providers do not implement `onInit` / `onDestroy` — initialize in the constructor and let framework shutdown handle cleanup" + "Providers do not implement `onInit` / `onDestroy` \u2014 initialize in the constructor and let framework shutdown handle cleanup" ] }, { @@ -2597,12 +2614,12 @@ "level": "intermediate", "tags": ["production", "unix-socket", "cli", "database", "daemon", "graceful"], "features": [ - "The framework already wires SIGTERM/SIGINT — daemon cleanup attaches _additional_ listeners and does not call `process.exit()`", + "The framework already wires SIGTERM/SIGINT \u2014 daemon cleanup attaches _additional_ listeners and does not call `process.exit()`", "Using `server.dispose()` (the only real method) instead of fictional `server.close()`", "Removing the Unix socket file to prevent stale `.sock` files on restart", "Cleaning up the PID file on shutdown", "Using `@frontmcp/utils` (`unlink`, `fileExists`, `ensureDir`) for file operations", - "Providers initialize in the constructor — there is no `onInit` / `onDestroy`" + "Providers initialize in the constructor \u2014 there is no `onInit` / `onDestroy`" ] }, { @@ -2651,14 +2668,14 @@ }, { "name": "wrangler-config", - "description": "Checklist for verifying the `wrangler.toml` produced by `frontmcp build --target cloudflare` is production-ready. **Note:** configuration authoring lives in `frontmcp-deployment → references/deploy-to-cloudflare.md`; this file is checklist-only.", + "description": "Checklist for verifying the `wrangler.toml` produced by `frontmcp build --target cloudflare` is production-ready. **Note:** configuration authoring lives in `frontmcp-deployment \u2192 references/deploy-to-cloudflare.md`; this file is checklist-only.", "level": "basic", "tags": ["production", "cloudflare", "cache", "session", "wrangler", "checklist"], "features": [ - "Verify `main = \"dist/cloudflare/index.js\"` (the build adapter writes this — never override)", + "Verify `main = \"dist/cloudflare/index.js\"` (the build adapter writes this \u2014 never override)", "Verify KV bindings for sessions and cache exist", "Verify staging / production environment configs are separated", - "Verify secrets are NOT in `wrangler.toml` — use `wrangler secret put`" + "Verify secrets are NOT in `wrangler.toml` \u2014 use `wrangler secret put`" ] } ] @@ -2675,13 +2692,13 @@ "features": [ "Connection reuse pattern: caching the connection promise in module scope so it survives Lambda freeze/thaw", "Lazy-loading heavy dependencies (`pg`) via dynamic `import()` on first use, not at module load", - "Not closing connections on shutdown for Lambda (they survive freeze/thaw — and providers have no `onDestroy` hook anyway)", + "Not closing connections on shutdown for Lambda (they survive freeze/thaw \u2014 and providers have no `onDestroy` hook anyway)", "Keeping module scope lightweight with no heavy initialization" ] }, { "name": "sam-template", - "description": "Checklist for verifying the SAM template pairs correctly with the bundle produced by `frontmcp build --target lambda`. **Note:** configuration authoring lives in `frontmcp-deployment → references/deploy-to-lambda.md`; this file is checklist-only.", + "description": "Checklist for verifying the SAM template pairs correctly with the bundle produced by `frontmcp build --target lambda`. **Note:** configuration authoring lives in `frontmcp-deployment \u2192 references/deploy-to-lambda.md`; this file is checklist-only.", "level": "basic", "tags": ["production", "lambda", "session", "sam", "checklist"], "features": [ @@ -2725,7 +2742,7 @@ }, { "name": "multi-instance-cleanup", - "description": "Shows how multiple SDK instances can coexist without conflicts, and how to clean up timers and listeners — given that `@Provider` classes have **no** `onInit` / `onDestroy` lifecycle hooks. The pattern is: initialize in the constructor, expose an explicit `stop()` method, and have the host app call it before `server.dispose()`.", + "description": "Shows how multiple SDK instances can coexist without conflicts, and how to clean up timers and listeners \u2014 given that `@Provider` classes have **no** `onInit` / `onDestroy` lifecycle hooks. The pattern is: initialize in the constructor, expose an explicit `stop()` method, and have the host app call it before `server.dispose()`.", "level": "advanced", "tags": ["production", "sdk", "node", "multi", "instance", "cleanup"], "features": [ @@ -2773,7 +2790,7 @@ "level": "intermediate", "tags": ["production", "redis", "database", "node", "graceful", "shutdown"], "features": [ - "The framework already handles SIGTERM/SIGINT — never call `server.close()` (no such method) or `process.exit()` on top of it", + "The framework already handles SIGTERM/SIGINT \u2014 never call `server.close()` (no such method) or `process.exit()` on top of it", "Use `server.dispose()` if you need explicit cleanup in non-server (SDK) contexts", "Add a _drain probe_ on `/healthz` so load balancers stop sending traffic during the framework's drain window", "Avoid handler conflicts: registering a second SIGTERM that calls `process.exit(0)` races the framework's own exit path" @@ -2824,12 +2841,12 @@ }, { "name": "vercel-edge-config", - "description": "Checklist for verifying the Vercel Build Output API v3 artifact and edge config produced by `frontmcp build --target vercel`. **Note:** configuration authoring lives in `frontmcp-deployment → references/deploy-to-vercel.md`; this file is checklist-only.", + "description": "Checklist for verifying the Vercel Build Output API v3 artifact and edge config produced by `frontmcp build --target vercel`. **Note:** configuration authoring lives in `frontmcp-deployment \u2192 references/deploy-to-vercel.md`; this file is checklist-only.", "level": "basic", "tags": ["production", "vercel-kv", "vercel", "session", "serverless", "checklist"], "features": [ "Verify `frontmcp build --target vercel` produced `.vercel/output/functions/index.func/handler.cjs`", - "No hand-written `vercel.json` `builds`/`routes` — the build adapter uses Build Output API v3", + "No hand-written `vercel.json` `builds`/`routes` \u2014 the build adapter uses Build Output API v3", "Verify Vercel KV (`provider: 'vercel-kv'`) is configured for session/cache state", "Verify CORS origins include `VERCEL_URL` and any custom production domain" ] @@ -3517,7 +3534,7 @@ }, { "name": "production-tracing", - "description": "Full production observability — traces to OTLP, structured logs to stdout, per-request log collection.", + "description": "Full production observability \u2014 traces to OTLP, structured logs to stdout, per-request log collection.", "level": "intermediate", "tags": ["tracing", "production", "otlp", "logging", "request-logs"], "features": [ @@ -3541,7 +3558,7 @@ "features": [ "NDJSON format for stdout (Docker/K8s log collection)", "Automatic trace context enrichment (trace_id, span_id)", - "Sensitive field redaction (token → [REDACTED])" + "Sensitive field redaction (token \u2192 [REDACTED])" ] }, { @@ -3638,7 +3655,7 @@ "tags": ["coralogix", "otlp", "vendor", "integration", "production"], "features": [ "Traces and logs both sent to Coralogix via OTLP", - "Automatic trace_id correlation — click a trace, see its logs", + "Automatic trace_id correlation \u2014 click a trace, see its logs", "Environment variable configuration for production" ] } From 2d1f88d5ebf6644d80fdf17967a9f79afeb40c5d Mon Sep 17 00:00:00 2001 From: David Antoon Date: Sun, 2 Aug 2026 03:31:00 +0300 Subject: [PATCH 4/4] feat: rename files and update references for MCP protocol revision 2026-07-28 to 20260728 --- README.md | 93 ++++++-- .../e2e/cloudflare-worker.e2e.spec.ts | 165 ++++++++++++- .../e2e/worker-isolate-safety.e2e.spec.ts | 6 +- .../e2e/backward-compat.e2e.spec.ts | 6 +- .../e2e/cacheable-results.e2e.spec.ts | 14 +- .../e2e/client.e2e.spec.ts | 35 +-- .../e2e/discover.e2e.spec.ts | 22 +- .../e2e/errors-and-removals.e2e.spec.ts | 20 +- .../e2e/helpers/mcp-stateless-client.ts} | 18 +- .../e2e/mrtr-sampling-roots.e2e.spec.ts | 38 +-- .../e2e/mrtr.e2e.spec.ts | 32 +-- .../e2e/request-headers.e2e.spec.ts | 46 ++-- .../e2e/request-notifications.e2e.spec.ts | 40 ++-- .../e2e/stateless-requests.e2e.spec.ts | 34 +-- .../e2e/subscriptions-listen.e2e.spec.ts | 71 +++++- .../e2e/tasks-anonymous.e2e.spec.ts | 10 +- .../e2e/tasks-extension.e2e.spec.ts | 42 ++-- .../jest.e2e.config.ts | 4 +- .../project.json | 18 +- .../src/apps/proto/index.ts | 0 .../src/apps/proto/prompts/greeting.prompt.ts | 0 .../apps/proto/resources/config.resource.ts | 0 .../src/apps/proto/tools/chatty.tool.ts | 0 .../src/apps/proto/tools/confirm.tool.ts | 0 .../src/apps/proto/tools/echo.tool.ts | 0 .../apps/proto/tools/list-workspaces.tool.ts | 0 .../src/apps/proto/tools/region-query.tool.ts | 0 .../src/apps/proto/tools/summarize.tool.ts | 0 .../src/apps/tasks/index.ts | 0 .../src/apps/tasks/tools/approve-job.tool.ts | 0 .../src/apps/tasks/tools/slow-job.tool.ts | 0 .../src/main-tasks.ts | 0 .../src/main.ts | 0 .../tsconfig.app.json | 0 .../tsconfig.e2e.json | 0 .../tsconfig.json | 0 .../webpack.config.js | 2 +- docs/frontmcp/adapters/openapi-adapter.mdx | 44 ++-- docs/frontmcp/adapters/openapi-polling.mdx | 1 + docs/frontmcp/authentication/authorities.mdx | 142 ++++++------ docs/frontmcp/authentication/cimd.mdx | 71 +++--- docs/frontmcp/authentication/custom-ui.mdx | 62 ++--- docs/frontmcp/authentication/demo-servers.mdx | 10 +- docs/frontmcp/authentication/local.mdx | 168 +++++++------- docs/frontmcp/authentication/modes.mdx | 76 +++--- docs/frontmcp/authentication/production.mdx | 11 +- docs/frontmcp/authentication/progressive.mdx | 46 ++-- docs/frontmcp/authentication/skills-auth.mdx | 44 ++-- docs/frontmcp/authentication/token.mdx | 12 +- .../deployment/browser-compatibility.mdx | 18 +- .../frontmcp/deployment/cloudflare-worker.mdx | 46 ++-- docs/frontmcp/deployment/deploy-manifest.mdx | 68 +++--- docs/frontmcp/deployment/frontmcp-config.mdx | 188 +++++++-------- docs/frontmcp/deployment/health-checks.mdx | 12 +- .../frontmcp/deployment/high-availability.mdx | 20 +- docs/frontmcp/deployment/local-dev-server.mdx | 10 +- docs/frontmcp/deployment/machine-id.mdx | 30 +-- docs/frontmcp/deployment/mcp-clients.mdx | 93 ++++---- docs/frontmcp/deployment/mcpb.mdx | 52 ++--- docs/frontmcp/deployment/metrics.mdx | 33 +-- docs/frontmcp/deployment/runtime-modes.mdx | 1 + docs/frontmcp/deployment/security-headers.mdx | 40 ++-- docs/frontmcp/deployment/serverless.mdx | 1 + .../deployment/transport-security.mdx | 33 +-- docs/frontmcp/features/background-tasks.mdx | 54 ++--- docs/frontmcp/features/channels.mdx | 18 +- .../features/dependency-injection.mdx | 8 +- docs/frontmcp/features/deployment-targets.mdx | 14 +- .../features/environment-awareness.mdx | 130 +++++------ .../frontmcp/features/esm-dynamic-loading.mdx | 10 +- docs/frontmcp/features/observability.mdx | 50 ++-- docs/frontmcp/features/overview.mdx | 32 +-- .../features/skills-only-deployment.mdx | 54 ++--- .../fundamentals/protocol-versions.mdx | 65 ++++-- docs/frontmcp/fundamentals/schemas.mdx | 20 +- .../getting-started/cli-reference.mdx | 94 ++++---- docs/frontmcp/guides/building-tool-ui.mdx | 14 +- docs/frontmcp/guides/observability.mdx | 57 ++--- .../guides/publishing-esm-packages.mdx | 42 ++-- .../guides/rate-limiting-and-guards.mdx | 14 ++ docs/frontmcp/guides/your-first-channel.mdx | 14 +- .../nx-plugin/executors/build-exec.mdx | 16 +- docs/frontmcp/nx-plugin/executors/deploy.mdx | 10 +- .../frontmcp/nx-plugin/executors/overview.mdx | 16 +- docs/frontmcp/nx-plugin/generators/lib.mdx | 14 +- .../nx-plugin/generators/skill-dir.mdx | 16 +- .../nx-plugin/generators/workspace.mdx | 14 +- docs/frontmcp/nx-plugin/overview.mdx | 24 +- .../plugins/codecall/configuration.mdx | 12 +- docs/frontmcp/plugins/codecall/security.mdx | 20 +- docs/frontmcp/plugins/overview.mdx | 14 +- .../plugins/skilled-openapi/api-reference.mdx | 2 +- .../plugins/skilled-openapi/bundle-format.mdx | 14 +- .../plugins/skilled-openapi/coexistence.mdx | 18 +- .../plugins/skilled-openapi/configuration.mdx | 32 +-- .../plugins/skilled-openapi/meta-tools.mdx | 8 +- .../plugins/skilled-openapi/overview.mdx | 16 +- .../plugins/skilled-openapi/quickstart.mdx | 4 + .../plugins/skilled-openapi/security.mdx | 28 +-- .../plugins/skilled-openapi/sources.mdx | 12 +- docs/frontmcp/react/agent-components.mdx | 26 +-- docs/frontmcp/react/ai-integration.mdx | 30 +-- docs/frontmcp/react/api-client.mdx | 18 +- docs/frontmcp/react/components.mdx | 62 ++--- docs/frontmcp/react/dom-resources.mdx | 22 +- docs/frontmcp/react/dynamic-tools.mdx | 51 +++-- docs/frontmcp/react/getting-started.mdx | 12 +- docs/frontmcp/react/hooks.mdx | 74 +++--- docs/frontmcp/react/overview.mdx | 26 +-- docs/frontmcp/react/provider.mdx | 40 ++-- docs/frontmcp/react/router.mdx | 42 ++-- docs/frontmcp/react/state-management.mdx | 96 ++++---- .../contexts/channel-context.mdx | 54 ++--- .../sdk-reference/contexts/skill-context.mdx | 1 + docs/frontmcp/sdk-reference/core/server.mdx | 10 +- .../frontmcp/sdk-reference/decorators/app.mdx | 34 +-- .../sdk-reference/decorators/channel.mdx | 92 ++++---- .../sdk-reference/decorators/frontmcp.mdx | 36 +-- .../sdk-reference/decorators/plugin.mdx | 20 +- .../sdk-reference/decorators/prompt.mdx | 14 +- .../sdk-reference/decorators/provider.mdx | 12 +- .../sdk-reference/decorators/resource.mdx | 2 +- .../sdk-reference/decorators/skill.mdx | 14 +- .../sdk-reference/decorators/tool.mdx | 36 +-- .../sdk-reference/errors/auth-errors.mdx | 66 +++--- .../errors/auth-internal-errors.mdx | 28 +-- .../sdk-reference/errors/esm-errors.mdx | 64 +++--- .../sdk-reference/errors/overview.mdx | 14 +- .../sdk-reference/errors/task-errors.mdx | 14 +- .../sdk-reference/errors/transport-errors.mdx | 16 +- .../sdk-reference/errors/workflow-errors.mdx | 12 +- docs/frontmcp/sdk-reference/guard.mdx | 216 +++++++++--------- .../registries/auth-registry.mdx | 42 ++-- .../sdk-reference/registries/job-registry.mdx | 14 +- .../registries/workflow-registry.mdx | 14 +- docs/frontmcp/servers/apps.mdx | 22 +- docs/frontmcp/servers/channels.mdx | 42 ++-- docs/frontmcp/servers/esm-packages.mdx | 70 +++--- docs/frontmcp/servers/guard.mdx | 152 ++++++------ docs/frontmcp/servers/jobs.mdx | 14 +- docs/frontmcp/servers/prompts.mdx | 14 +- docs/frontmcp/servers/resources.mdx | 17 +- docs/frontmcp/servers/server.mdx | 24 +- docs/frontmcp/servers/skills.mdx | 50 ++-- docs/frontmcp/servers/tools.mdx | 54 +++-- docs/frontmcp/servers/workflows.mdx | 14 +- docs/frontmcp/testing/api-reference.mdx | 6 +- docs/frontmcp/testing/authentication.mdx | 10 +- docs/frontmcp/testing/matchers.mdx | 34 +-- libs/adapters/README.md | 2 +- libs/auth/README.md | 16 +- libs/di/README.md | 3 +- libs/edge/README.md | 3 +- libs/guard/README.md | 2 +- libs/observability/README.md | 106 +++++++++ libs/plugins/README.md | 10 +- libs/protocol/README.md | 71 ++++++ libs/protocol/src/index.ts | 2 +- .../src/{types-2026.ts => types-20260728.ts} | 52 ++--- libs/react/README.md | 12 +- libs/sdk/README.md | 17 +- .../types/options/transport/interfaces.ts | 15 ++ .../common/types/options/transport/schema.ts | 18 ++ .../utils/decide-request-intent.utils.ts | 4 +- libs/sdk/src/index.ts | 10 +- libs/sdk/src/remote-mcp/mcp-client.service.ts | 4 +- ...ter.ts => mcp-stateless-client.adapter.ts} | 16 +- libs/sdk/src/scope/flows/http.request.flow.ts | 47 ++-- ...26.flow.ts => handle.mcp-20260728.flow.ts} | 99 ++++++-- .../__tests__/header-codec.spec.ts | 0 .../__tests__/header-params.spec.ts | 0 .../__tests__/mrtr.spec.ts | 0 .../__tests__/request-notifications.spec.ts | 0 .../__tests__/request-state.spec.ts | 15 ++ .../__tests__/request-validation.spec.ts | 95 ++++---- .../__tests__/result-decorator.spec.ts | 6 +- .../__tests__/tasks-extension.spec.ts | 18 +- .../client/header-params.ts | 0 .../client/index.ts | 4 +- .../client/mcp-stateless.client.ts} | 61 ++--- .../{mcp-2026 => mcp-20260728}/discover.ts | 10 +- .../{mcp-2026 => mcp-20260728}/dispatcher.ts | 19 +- .../header-codec.ts | 0 .../{mcp-2026 => mcp-20260728}/index.ts | 4 +- .../{mcp-2026 => mcp-20260728}/mrtr.ts | 0 .../protocol-20260728.constants.ts} | 4 +- .../request-notifications.ts | 0 .../request-state.ts | Bin 6116 -> 6796 bytes .../request-validation.ts | 120 +++++++--- .../result-decorator.ts | 6 +- .../subscriptions.ts | 6 +- .../tasks-extension.ts | 8 +- libs/sdk/src/transport/transport.registry.ts | 9 +- .../catalog/frontmcp-deployment/SKILL.md | 2 +- .../references/protocol-versions.md | 44 +++- libs/skills/catalog/skills-manifest.json | 2 +- libs/storage-sqlite/README.md | 91 ++++++++ libs/testing/README.md | 13 +- libs/testing/src/server/port-registry.ts | 2 +- libs/ui/README.md | 4 +- libs/uipack/README.md | 1 + libs/utils/README.md | 2 +- plugins/plugin-cache/README.md | 3 +- plugins/plugin-codecall/README.md | 2 +- plugins/plugin-feature-flags/README.md | 105 +++++++++ plugins/plugin-skilled-openapi/README.md | 51 +++-- 206 files changed, 3544 insertions(+), 2547 deletions(-) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/backward-compat.e2e.spec.ts (97%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/cacheable-results.e2e.spec.ts (80%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/client.e2e.spec.ts (86%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/discover.e2e.spec.ts (73%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/errors-and-removals.e2e.spec.ts (83%) rename apps/e2e/{demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts => demo-e2e-protocol-20260728/e2e/helpers/mcp-stateless-client.ts} (93%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/mrtr-sampling-roots.e2e.spec.ts (82%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/mrtr.e2e.spec.ts (81%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/request-headers.e2e.spec.ts (83%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/request-notifications.e2e.spec.ts (78%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/stateless-requests.e2e.spec.ts (80%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/subscriptions-listen.e2e.spec.ts (65%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/tasks-anonymous.e2e.spec.ts (82%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/e2e/tasks-extension.e2e.spec.ts (88%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/jest.e2e.config.ts (95%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/project.json (61%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/proto/index.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/proto/prompts/greeting.prompt.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/proto/resources/config.resource.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/proto/tools/chatty.tool.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/proto/tools/confirm.tool.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/proto/tools/echo.tool.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/proto/tools/list-workspaces.tool.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/proto/tools/region-query.tool.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/proto/tools/summarize.tool.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/tasks/index.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/tasks/tools/approve-job.tool.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/apps/tasks/tools/slow-job.tool.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/main-tasks.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/src/main.ts (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/tsconfig.app.json (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/tsconfig.e2e.json (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/tsconfig.json (100%) rename apps/e2e/{demo-e2e-protocol-2026 => demo-e2e-protocol-20260728}/webpack.config.js (98%) create mode 100644 libs/observability/README.md create mode 100644 libs/protocol/README.md rename libs/protocol/src/{types-2026.ts => types-20260728.ts} (88%) rename libs/sdk/src/remote-mcp/{mcp-2026-client.adapter.ts => mcp-stateless-client.adapter.ts} (88%) rename libs/sdk/src/transport/flows/{handle.mcp-2026.flow.ts => handle.mcp-20260728.flow.ts} (81%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/__tests__/header-codec.spec.ts (100%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/__tests__/header-params.spec.ts (100%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/__tests__/mrtr.spec.ts (100%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/__tests__/request-notifications.spec.ts (100%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/__tests__/request-state.spec.ts (91%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/__tests__/request-validation.spec.ts (72%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/__tests__/result-decorator.spec.ts (94%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/__tests__/tasks-extension.spec.ts (93%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/client/header-params.ts (100%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/client/index.ts (56%) rename libs/sdk/src/transport/{mcp-2026/client/mcp-2026.client.ts => mcp-20260728/client/mcp-stateless.client.ts} (88%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/discover.ts (87%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/dispatcher.ts (96%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/header-codec.ts (100%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/index.ts (88%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/mrtr.ts (100%) rename libs/sdk/src/transport/{mcp-2026/protocol-2026.constants.ts => mcp-20260728/protocol-20260728.constants.ts} (94%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/request-notifications.ts (100%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/request-state.ts (89%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/request-validation.ts (69%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/result-decorator.ts (96%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/subscriptions.ts (96%) rename libs/sdk/src/transport/{mcp-2026 => mcp-20260728}/tasks-extension.ts (96%) create mode 100644 libs/storage-sqlite/README.md create mode 100644 plugins/plugin-feature-flags/README.md diff --git a/README.md b/README.md index 02f45feed..d6e422822 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,12 @@ scoped [Providers / DI][docs-providers]. stateful / stateless [sessions][docs-server] (JWT or UUID transport IDs). **Connect & operate** — [Streamable HTTP + SSE transport][docs-transport], -capability [discovery][docs-discovery], [elicitation][docs-elicitation], -[hooks][docs-hooks], HTTP-discoverable [skills][docs-skills], -[external MCP sub-apps][docs-ext-apps], an in-process [Direct Client][docs-direct] -(`connectOpenAI` / `connectClaude`), and first-class [deployment][docs-deploy]. +every [MCP protocol revision][docs-protocol] from `2024-11-05` through +`2026-07-28` on one endpoint, capability [discovery][docs-discovery], +[elicitation][docs-elicitation], [hooks][docs-hooks], HTTP-discoverable +[skills][docs-skills], [tool UI / MCP Apps][docs-ext-apps], an in-process +[Direct Client][docs-direct] (`connectOpenAI` / `connectClaude`), and +first-class [deployment][docs-deploy]. **Extend & tooling** — official [plugins][docs-plugins] (Cache, Remember, CodeCall, Dashboard), the [OpenAPI adapter][docs-adapters], a [UI library][docs-ui] (HTML/React @@ -90,18 +92,66 @@ widgets, SSR, MCP Bridge), an [E2E testing framework][docs-testing], and a ## Packages -| Package | Description | -| ------------------------------------- | ------------------------------------------------------ | -| [`@frontmcp/sdk`](libs/sdk) | Core framework — decorators, DI, flows, transport | -| [`@frontmcp/cli`](libs/cli) | CLI tooling (`frontmcp create`, `dev`, `build`) | -| [`@frontmcp/auth`](libs/auth) | Authentication, OAuth, JWKS, credential vault | -| [`@frontmcp/adapters`](libs/adapters) | OpenAPI adapter for auto-generating tools | -| [`@frontmcp/plugins`](libs/plugins) | Official plugins: Cache, Remember, CodeCall, Dashboard | -| [`@frontmcp/testing`](libs/testing) | E2E test framework with fixtures and matchers | -| [`@frontmcp/ui`](libs/ui) | React components, hooks, SSR renderers | -| [`@frontmcp/uipack`](libs/uipack) | React-free themes, build tools, platform adapters | -| [`@frontmcp/di`](libs/di) | Dependency injection container (internal) | -| [`@frontmcp/utils`](libs/utils) | Shared utilities — naming, URI, crypto, FS (internal) | +You install `frontmcp` (the CLI) and `@frontmcp/sdk`. Everything else is either +pulled in for you or opt-in. + +### Core + +| Package | Description | +| ----------------------------------- | --------------------------------------------------------------- | +| [`frontmcp`](libs/cli) | The CLI — `create`, `init`, `dev`, `build`, `inspect`, `doctor` | +| [`@frontmcp/sdk`](libs/sdk) | Core framework — decorators, DI, flows, transport, MCP protocol | +| [`@frontmcp/auth`](libs/auth) | Authentication, OAuth, JWKS, DCR/CIMD, credential vault | +| [`@frontmcp/testing`](libs/testing) | E2E test framework with fixtures and matchers | + +### Extend + +| Package | Description | +| ----------------------------------------------- | ------------------------------------------------------------- | +| [`@frontmcp/plugins`](libs/plugins) | Plugin authoring toolkit + official plugin re-exports | +| [`@frontmcp/adapters`](libs/adapters) | OpenAPI adapter — generate tools from an OpenAPI spec | +| [`@frontmcp/skills`](libs/skills) | Curated SKILL.md catalog for scaffolding and `skills install` | +| [`@frontmcp/guard`](libs/guard) | Policy/guard rules for tool inputs and outputs | +| [`@frontmcp/observability`](libs/observability) | Structured logging, metrics, and tracing helpers | + +### UI + +| Package | Description | +| --------------------------------- | ----------------------------------------------------- | +| [`@frontmcp/react`](libs/react) | React hooks + client for talking to a FrontMCP server | +| [`@frontmcp/ui`](libs/ui) | React components, SSR renderers, MCP Bridge | +| [`@frontmcp/uipack`](libs/uipack) | React-free themes, build tools, platform adapters | + +### Runtime & storage + +| Package | Description | +| ------------------------------------------------- | -------------------------------------------------------------- | +| [`@frontmcp/edge`](libs/edge) | Run a server on Cloudflare Workers / V8 isolates from a config | +| [`@frontmcp/storage-sqlite`](libs/storage-sqlite) | SQLite-backed session, task, and elicitation stores | +| [`@frontmcp/nx`](libs/nx-plugin) | Nx generators and executors for FrontMCP workspaces | + +### Internal + +Published so the packages above resolve, but not intended for direct use: + +| Package | Description | +| ------------------------------------- | ------------------------------------------------------------ | +| [`@frontmcp/protocol`](libs/protocol) | The single boundary to the upstream MCP SDK — protocol types | +| [`@frontmcp/di`](libs/di) | Dependency injection container | +| [`@frontmcp/utils`](libs/utils) | Shared utilities — naming, URI, crypto, FS | +| [`@frontmcp/lazy-zod`](libs/lazy-zod) | Lazily-loaded Zod wrapper that keeps cold starts small | + +### Official plugins + +| Package | Description | +| -------------------------------------------------------------------- | -------------------------------------------- | +| [`@frontmcp/plugin-cache`](plugins/plugin-cache) | Cache tool results with a TTL | +| [`@frontmcp/plugin-remember`](plugins/plugin-remember) | Per-session memory (`this.remember`) | +| [`@frontmcp/plugin-approval`](plugins/plugin-approval) | Human approval gates before a tool runs | +| [`@frontmcp/plugin-codecall`](plugins/plugin-codecall) | Let the model compose tool calls as code | +| [`@frontmcp/plugin-dashboard`](plugins/plugin-dashboard) | Built-in web dashboard | +| [`@frontmcp/plugin-feature-flags`](plugins/plugin-feature-flags) | Toggle tools and apps at runtime | +| [`@frontmcp/plugin-skilled-openapi`](plugins/plugin-skilled-openapi) | OpenAPI → skills + meta-tools for large APIs | ## Version Alignment @@ -120,7 +170,7 @@ PRs welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for workflow, coding stand [docs-home]: https://docs.agentfront.dev/frontmcp 'FrontMCP Docs' [docs-install]: https://docs.agentfront.dev/frontmcp/getting-started/installation 'Installation' [docs-quickstart]: https://docs.agentfront.dev/frontmcp/getting-started/quickstart 'Quickstart' -[docs-sdk-ref]: https://docs.agentfront.dev/frontmcp/sdk-reference/overview 'SDK Reference' +[docs-sdk-ref]: https://docs.agentfront.dev/frontmcp/sdk-reference/decorators/overview 'SDK Reference' [docs-server]: https://docs.agentfront.dev/frontmcp/servers/server 'The FrontMCP Server' [docs-apps]: https://docs.agentfront.dev/frontmcp/servers/apps 'Apps' [docs-tools]: https://docs.agentfront.dev/frontmcp/servers/tools 'Tools' @@ -130,15 +180,16 @@ PRs welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for workflow, coding stand [docs-elicitation]: https://docs.agentfront.dev/frontmcp/servers/elicitation 'Elicitation' [docs-skills]: https://docs.agentfront.dev/frontmcp/servers/skills 'Skills' [docs-discovery]: https://docs.agentfront.dev/frontmcp/servers/discovery 'Discovery' +[docs-protocol]: https://docs.agentfront.dev/frontmcp/fundamentals/protocol-versions 'Protocol Versions' [docs-auth]: https://docs.agentfront.dev/frontmcp/authentication/overview 'Authentication' [docs-direct]: https://docs.agentfront.dev/frontmcp/deployment/direct-client 'Direct Client' -[docs-transport]: https://docs.agentfront.dev/frontmcp/deployment/transport 'Transport' -[docs-ext-apps]: https://docs.agentfront.dev/frontmcp/servers/ext-apps 'Ext-Apps' -[docs-hooks]: https://docs.agentfront.dev/frontmcp/extensibility/hooks 'Hooks' +[docs-transport]: https://docs.agentfront.dev/frontmcp/deployment/transport-security 'Transport' +[docs-ext-apps]: https://docs.agentfront.dev/frontmcp/guides/building-tool-ui 'Tool UI / MCP Apps' +[docs-hooks]: https://docs.agentfront.dev/frontmcp/sdk-reference/decorators/hooks 'Hooks' [docs-providers]: https://docs.agentfront.dev/frontmcp/extensibility/providers 'Providers' [docs-plugins]: https://docs.agentfront.dev/frontmcp/plugins/overview 'Plugins' [docs-adapters]: https://docs.agentfront.dev/frontmcp/adapters/overview 'Adapters' [docs-testing]: https://docs.agentfront.dev/frontmcp/testing/overview 'Testing' -[docs-ui]: https://docs.agentfront.dev/frontmcp/ui/overview 'UI Library' +[docs-ui]: https://docs.agentfront.dev/frontmcp/react/overview 'React SDK' [docs-deploy]: https://docs.agentfront.dev/frontmcp/deployment/local-dev-server 'Deployment' [docs-production]: https://docs.agentfront.dev/frontmcp/deployment/production-build 'Production Build' diff --git a/apps/e2e/demo-e2e-cloudflare/e2e/cloudflare-worker.e2e.spec.ts b/apps/e2e/demo-e2e-cloudflare/e2e/cloudflare-worker.e2e.spec.ts index d45303c1f..1835e9401 100644 --- a/apps/e2e/demo-e2e-cloudflare/e2e/cloudflare-worker.e2e.spec.ts +++ b/apps/e2e/demo-e2e-cloudflare/e2e/cloudflare-worker.e2e.spec.ts @@ -10,8 +10,8 @@ * `nodejs_compat` flag, or a Node `req`/`res` shim), this fails. */ import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; -import * as path from 'node:path'; import * as fs from 'node:fs'; +import * as path from 'node:path'; const ROOT_DIR = path.resolve(__dirname, '../../../..'); const FIXTURE_DIR = path.resolve(__dirname, '..', 'fixture'); @@ -48,21 +48,59 @@ type JsonRpcResponse = { id?: number | string | null; result?: { serverInfo?: { name?: string; version?: string }; - capabilities?: { tools?: unknown }; + capabilities?: { tools?: unknown; extensions?: Record }; tools?: Array<{ name: string }>; content?: Array<{ text?: string }>; + // Protocol 2026-07-28 envelope. + resultType?: string; + supportedVersions?: string[]; + ttlMs?: number; + cacheScope?: string; + _meta?: Record; }; error?: { code: number; message: string }; }; -async function mcp(body: unknown): Promise<{ status: number; json: JsonRpcResponse }> { +async function mcp( + body: unknown, + extraHeaders: Record = {}, +): Promise<{ status: number; json: JsonRpcResponse; headers: Headers }> { const res = await fetch(`${BASE_URL}/mcp`, { method: 'POST', - headers: MCP_HEADERS, + headers: { ...MCP_HEADERS, ...extraHeaders }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000), }); - return { status: res.status, json: await readMcp(res) }; + return { status: res.status, json: await readMcp(res), headers: res.headers }; +} + +const PROTOCOL_20260728 = '2026-07-28'; +const META_VERSION = 'io.modelcontextprotocol/protocolVersion'; +const META_CAPS = 'io.modelcontextprotocol/clientCapabilities'; +const META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo'; + +/** Issue a fully-conforming 2026-07-28 request, mirrored headers and all. */ +async function mcpStateless20260728( + method: string, + params: Record = {}, + id = 1, +): Promise<{ status: number; json: JsonRpcResponse; headers: Headers }> { + const headers: Record = { + 'mcp-protocol-version': PROTOCOL_20260728, + 'mcp-method': method, + }; + const name = method === 'tools/call' ? params['name'] : undefined; + if (typeof name === 'string') headers['mcp-name'] = name; + + return mcp( + { + jsonrpc: '2.0', + id, + method, + params: { ...params, _meta: { [META_VERSION]: PROTOCOL_20260728, [META_CAPS]: {} } }, + }, + headers, + ); } /** Read an MCP response, handling both buffered JSON and the SSE stream the worker emits by default. */ @@ -164,4 +202,121 @@ describe('FrontMCP on Cloudflare Workers (workerd)', () => { expect(status).toBe(200); expect(json.result?.content?.[0]?.text).toBe('Echo: hi'); }); + + /** + * Protocol 2026-07-28 on the Worker. + * + * A V8-isolate deployment defaults to the stateless revision: it needs no + * session storage, so an MCP call that names no revision is answered directly + * instead of minting a session in a Durable Object. Clients that DO name a + * revision still get exactly that one. + */ + describe('FrontMCP on Cloudflare Workers — protocol 2026-07-28', () => { + it('serves server/discover natively', async () => { + const { status, json } = await mcpStateless20260728('server/discover', {}, 10); + + expect(status).toBe(200); + expect(json.error).toBeUndefined(); + expect(json.result?.supportedVersions).toContain('2026-07-28'); + expect(json.result?.capabilities).toBeDefined(); + }); + + it('answers a fully-conforming 2026 tools/call', async () => { + const { status, json } = await mcpStateless20260728( + 'tools/call', + { name: 'echo', arguments: { message: 'cf' } }, + 11, + ); + + expect(status).toBe(200); + expect(json.error).toBeUndefined(); + expect(json.result?.resultType).toBe('complete'); + expect(json.result?.content?.[0]?.text).toBe('Echo: cf'); + }); + + it('defaults an unversioned call to the stateless pipeline', async () => { + // No `initialize`, no session id, no version header — on the Worker this is + // served by the 2026 pipeline. `resultType` + `serverInfo` are the proof: + // the session-era transport never emits them. + const { status, json } = await mcp({ jsonrpc: '2.0', id: 12, method: 'tools/list', params: {} }); + + expect(status).toBe(200); + expect(json.result?.resultType).toBe('complete'); + expect(json.result?._meta?.[META_SERVER_INFO]).toBeDefined(); + }); + + it('mints no session for a stateless call', async () => { + const { headers } = await mcp({ jsonrpc: '2.0', id: 13, method: 'tools/list', params: {} }); + + // The whole point on a Worker: no session means no Durable Object. + expect(headers.get('mcp-session-id')).toBeNull(); + }); + + it('marks list results cacheable', async () => { + const { json } = await mcp({ jsonrpc: '2.0', id: 14, method: 'tools/list', params: {} }); + + expect(typeof json.result?.ttlMs).toBe('number'); + expect(['public', 'private']).toContain(json.result?.cacheScope); + }); + + it('does NOT require mirrored headers from a client that never opted in', async () => { + // A pre-2026 client sends no `Mcp-Method` / `Mcp-Name`. Defaulting it to the + // stateless revision must not turn its working call into a -32020. + const { status, json } = await mcp({ + jsonrpc: '2.0', + id: 15, + method: 'tools/call', + params: { name: 'echo', arguments: { message: 'lenient' } }, + }); + + expect(status).toBe(200); + expect(json.error).toBeUndefined(); + expect(json.result?.content?.[0]?.text).toBe('Echo: lenient'); + }); + + it('still enforces mirrored headers once the client declares 2026', async () => { + const { status, json } = await mcp( + { + jsonrpc: '2.0', + id: 16, + method: 'tools/list', + params: { _meta: { [META_VERSION]: PROTOCOL_20260728, [META_CAPS]: {} } }, + }, + { 'mcp-protocol-version': PROTOCOL_20260728, 'mcp-method': 'resources/list' }, + ); + + expect(status).toBe(400); + expect(json.error?.code).toBe(-32020); + }); + + it('keeps serving the legacy initialize handshake', async () => { + // The stateless default must not strand a session-based client: an + // explicit `initialize` still routes to the session-era pipeline. + const { status, json } = await mcp({ + jsonrpc: '2.0', + id: 17, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy', version: '1.0.0' } }, + }); + + expect(status).toBe(200); + expect(json.result?.serverInfo?.name).toBe('cf-worker-fixture'); + // A legacy negotiation must not grow the 2026 envelope. + expect(json.result?.resultType).toBeUndefined(); + // No `Mcp-Session-Id` assertion here: this fixture binds no Durable + // Object, so the Worker's legacy path already ran session-less before + // this change. Sessions on the Worker come from the DO session host. + }); + + it('rejects GET and DELETE on the MCP endpoint', async () => { + for (const method of ['GET', 'DELETE']) { + const res = await fetch(`${BASE_URL}/mcp`, { + method, + headers: { 'mcp-protocol-version': PROTOCOL_20260728 }, + signal: AbortSignal.timeout(10000), + }); + expect(res.status).toBe(405); + } + }); + }); }); diff --git a/apps/e2e/demo-e2e-cloudflare/e2e/worker-isolate-safety.e2e.spec.ts b/apps/e2e/demo-e2e-cloudflare/e2e/worker-isolate-safety.e2e.spec.ts index 943d8ae0f..b21295748 100644 --- a/apps/e2e/demo-e2e-cloudflare/e2e/worker-isolate-safety.e2e.spec.ts +++ b/apps/e2e/demo-e2e-cloudflare/e2e/worker-isolate-safety.e2e.spec.ts @@ -8,8 +8,8 @@ * regression in a cold path is caught here before it can break a real worker. */ import { execFileSync } from 'node:child_process'; -import * as path from 'node:path'; import * as fs from 'node:fs'; +import * as path from 'node:path'; const ROOT_DIR = path.resolve(__dirname, '../../../..'); const CHECK = path.join(ROOT_DIR, 'scripts', 'check-worker-isolate-safety.mjs'); @@ -21,7 +21,9 @@ describe('worker isolate-safety (no module-eval side effects)', () => { it('the worker-graph libs have no module-eval random/timer/network calls', () => { let exitCode = 0; - let output = ''; + // No initializer: both branches below assign it, so an initial '' would be + // dead (and `no-useless-assignment` rightly flags it). + let output: string; try { output = execFileSync('node', [CHECK], { cwd: ROOT_DIR, encoding: 'utf-8' }); } catch (err: unknown) { diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/backward-compat.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/backward-compat.e2e.spec.ts similarity index 97% rename from apps/e2e/demo-e2e-protocol-2026/e2e/backward-compat.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/backward-compat.e2e.spec.ts index 8dd33196b..134752eb9 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/backward-compat.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/backward-compat.e2e.spec.ts @@ -7,7 +7,7 @@ */ import { expect, test } from '@frontmcp/testing'; -import { parseSseEvents } from './helpers/mcp-2026-client'; +import { parseSseEvents } from './helpers/mcp-stateless-client'; const LEGACY_VERSIONS = ['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']; @@ -42,8 +42,8 @@ async function legacyInitialize(baseUrl: string, protocolVersion: string) { test.describe('protocol backward compatibility', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/cacheable-results.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/cacheable-results.e2e.spec.ts similarity index 80% rename from apps/e2e/demo-e2e-protocol-2026/e2e/cacheable-results.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/cacheable-results.e2e.spec.ts index 0f8151143..c7c176a82 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/cacheable-results.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/cacheable-results.e2e.spec.ts @@ -7,7 +7,7 @@ */ import { expect, test } from '@frontmcp/testing'; -import { mcp2026Fetch } from './helpers/mcp-2026-client'; +import { mcpStatelessFetch } from './helpers/mcp-stateless-client'; const CACHEABLE: { method: string; params?: Record }[] = [ { method: 'tools/list' }, @@ -20,14 +20,14 @@ const CACHEABLE: { method: string; params?: Record }[] = [ test.describe('protocol 2026-07-28 — cacheable results', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); for (const [index, call] of CACHEABLE.entries()) { test(`${call.method} returns a numeric ttlMs >= 0`, async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { ...call, id: 300 + index }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...call, id: 300 + index }); const { result, error } = res.json(); expect(error).toBeUndefined(); @@ -37,7 +37,7 @@ test.describe('protocol 2026-07-28 — cacheable results', () => { }); test(`${call.method} returns a valid cacheScope`, async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { ...call, id: 400 + index }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...call, id: 400 + index }); const { result } = res.json(); expect(['public', 'private']).toContain(result.cacheScope); @@ -47,7 +47,7 @@ test.describe('protocol 2026-07-28 — cacheable results', () => { test('non-cacheable results do NOT gain ttlMs/cacheScope', async ({ server }) => { // `tools/call` is not a CacheableResult — inventing the fields there would // mislead intermediaries into caching a side-effecting call. - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 500, params: { name: 'echo', arguments: { message: 'no-cache' } }, @@ -61,7 +61,7 @@ test.describe('protocol 2026-07-28 — cacheable results', () => { test('scopes an authenticated-context list as private', async ({ server }) => { // This server runs in public mode, so `public` is legitimate; the assertion // is that the server makes a deliberate choice rather than omitting it. - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 501 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 501 }); const { result } = res.json(); expect(result.cacheScope).toBeDefined(); }); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/client.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/client.e2e.spec.ts similarity index 86% rename from apps/e2e/demo-e2e-protocol-2026/e2e/client.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/client.e2e.spec.ts index e7e4dbc2a..f6964f3b4 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/client.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/client.e2e.spec.ts @@ -1,25 +1,30 @@ /** - * `Mcp2026Client` — the FrontMCP client speaking 2026-07-28 end to end. + * `McpStatelessClient` — the FrontMCP client speaking 2026-07-28 end to end. * * The other suites assert the server's wire bytes with raw fetch; this one * proves the shipped client actually interoperates with it, including the parts * a client MUST implement (mirrored headers, the MRTR retry loop, task polling, * and rejecting malformed `x-mcp-header` annotations). */ -import { Mcp2026Client, Mcp2026ClientAdapter, Mcp2026Error, negotiateRemoteProtocol } from '@frontmcp/sdk'; +import { + McpStatelessClient, + McpStatelessClientAdapter, + McpStatelessError, + negotiateRemoteProtocol, +} from '@frontmcp/sdk'; import { expect, test } from '@frontmcp/testing'; -import type { ListedTool } from './helpers/mcp-2026-client'; +import type { ListedTool } from './helpers/mcp-stateless-client'; -test.describe('protocol 2026-07-28 — Mcp2026Client', () => { +test.describe('protocol 2026-07-28 — McpStatelessClient', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); const client = (server: { info: { baseUrl: string } }, overrides = {}) => - new Mcp2026Client({ url: server.info.baseUrl, ...overrides }); + new McpStatelessClient({ url: server.info.baseUrl, ...overrides }); test('discovers the server', async ({ server }) => { const result = await client(server).discover(); @@ -104,11 +109,11 @@ test.describe('protocol 2026-07-28 — Mcp2026Client', () => { await expect(mcp.callTool('confirm', { action: 'x' })).rejects.toThrow(/no handler is configured/); }); - test('surfaces a server error as Mcp2026Error with its JSON-RPC code', async ({ server }) => { + test('surfaces a server error as McpStatelessError with its JSON-RPC code', async ({ server }) => { const mcp = client(server); await expect(mcp.readResource('proto://missing')).rejects.toMatchObject({ - name: 'Mcp2026Error', + name: 'McpStatelessError', code: -32602, }); }); @@ -147,19 +152,19 @@ test.describe('protocol 2026-07-28 — Mcp2026Client', () => { } }); - test('exposes Mcp2026Error for unsupported protocol versions', () => { + test('exposes McpStatelessError for unsupported protocol versions', () => { // Constructed directly: the class is part of the public surface, so callers // can branch on `code` without string-matching messages. - const error = new Mcp2026Error(-32022, 'Unsupported protocol version: 2099-01-01', { supported: [] }); + const error = new McpStatelessError(-32022, 'Unsupported protocol version: 2099-01-01', { supported: [] }); expect(error.code).toBe(-32022); - expect(error.name).toBe('Mcp2026Error'); + expect(error.name).toBe('McpStatelessError'); }); }); test.describe('protocol 2026-07-28 — remote-proxy adapter', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); @@ -182,7 +187,7 @@ test.describe('protocol 2026-07-28 — remote-proxy adapter', () => { }); test('presents the remote through the Client-shaped surface', async ({ server }) => { - const adapter = new Mcp2026ClientAdapter({ url: server.info.baseUrl }); + const adapter = new McpStatelessClientAdapter({ url: server.info.baseUrl }); await adapter.connect(); expect(adapter.getServerCapabilities()).toBeDefined(); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/discover.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/discover.e2e.spec.ts similarity index 73% rename from apps/e2e/demo-e2e-protocol-2026/e2e/discover.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/discover.e2e.spec.ts index 51e9a4dab..9d41a6a5d 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/discover.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/discover.e2e.spec.ts @@ -7,17 +7,17 @@ */ import { expect, test } from '@frontmcp/testing'; -import { mcp2026Fetch, META_SERVER_INFO, PROTOCOL_2026 } from './helpers/mcp-2026-client'; +import { mcpStatelessFetch, META_SERVER_INFO, PROTOCOL_20260728 } from './helpers/mcp-stateless-client'; test.describe('protocol 2026-07-28 — server/discover', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); test('responds to server/discover without any prior handshake', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 1 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'server/discover', id: 1 }); expect(res.status).toBe(200); const body = res.json(); @@ -27,15 +27,15 @@ test.describe('protocol 2026-07-28 — server/discover', () => { }); test('advertises 2026-07-28 among supportedVersions', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 2 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'server/discover', id: 2 }); const { result } = res.json(); expect(Array.isArray(result.supportedVersions)).toBe(true); - expect(result.supportedVersions).toContain(PROTOCOL_2026); + expect(result.supportedVersions).toContain(PROTOCOL_20260728); }); test('still advertises the legacy versions it supports', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 3 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'server/discover', id: 3 }); const { result } = res.json(); // Backwards compatibility is a hard requirement: dropping the older @@ -45,7 +45,7 @@ test.describe('protocol 2026-07-28 — server/discover', () => { }); test('returns server capabilities and instructions', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 4 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'server/discover', id: 4 }); const { result } = res.json(); expect(result.capabilities).toBeDefined(); @@ -55,7 +55,7 @@ test.describe('protocol 2026-07-28 — server/discover', () => { }); test('carries resultType "complete" and serverInfo in _meta', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 5 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'server/discover', id: 5 }); const { result } = res.json(); expect(result.resultType).toBe('complete'); @@ -66,7 +66,7 @@ test.describe('protocol 2026-07-28 — server/discover', () => { }); test('is cacheable — carries ttlMs and cacheScope', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 6 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'server/discover', id: 6 }); const { result } = res.json(); expect(typeof result.ttlMs).toBe('number'); @@ -75,7 +75,7 @@ test.describe('protocol 2026-07-28 — server/discover', () => { }); test('declares the extensions field on capabilities', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'server/discover', id: 7 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'server/discover', id: 7 }); const { result } = res.json(); // `extensions` was added to ServerCapabilities in 2026-07-28; keys must be diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/errors-and-removals.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/errors-and-removals.e2e.spec.ts similarity index 83% rename from apps/e2e/demo-e2e-protocol-2026/e2e/errors-and-removals.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/errors-and-removals.e2e.spec.ts index 1f984f361..d335e89b6 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/errors-and-removals.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/errors-and-removals.e2e.spec.ts @@ -7,7 +7,7 @@ */ import { expect, test } from '@frontmcp/testing'; -import { INVALID_PARAMS, mcp2026Fetch, METHOD_NOT_FOUND, PROTOCOL_2026 } from './helpers/mcp-2026-client'; +import { INVALID_PARAMS, mcpStatelessFetch, METHOD_NOT_FOUND, PROTOCOL_20260728 } from './helpers/mcp-stateless-client'; const REMOVED_METHODS = [ 'ping', @@ -21,14 +21,14 @@ const REMOVED_METHODS = [ test.describe('protocol 2026-07-28 — removals and error codes', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); for (const [index, method] of REMOVED_METHODS.entries()) { test(`${method} is gone — 404 + -32601`, async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method, id: 600 + index, params: method === 'resources/subscribe' ? { uri: 'proto://config' } : {}, @@ -42,7 +42,7 @@ test.describe('protocol 2026-07-28 — removals and error codes', () => { } test('resource not found now returns -32602, not -32002', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'resources/read', id: 700, params: { uri: 'proto://does-not-exist' }, @@ -59,7 +59,7 @@ test.describe('protocol 2026-07-28 — removals and error codes', () => { method: 'GET', headers: { accept: 'text/event-stream', - 'mcp-protocol-version': PROTOCOL_2026, + 'mcp-protocol-version': PROTOCOL_20260728, }, }); @@ -70,7 +70,7 @@ test.describe('protocol 2026-07-28 — removals and error codes', () => { const res = await fetch(server.info.baseUrl, { method: 'DELETE', headers: { - 'mcp-protocol-version': PROTOCOL_2026, + 'mcp-protocol-version': PROTOCOL_20260728, 'mcp-session-id': 'anything', }, }); @@ -79,7 +79,7 @@ test.describe('protocol 2026-07-28 — removals and error codes', () => { }); test('an unknown method still returns 404 + -32601', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'totally/unknown', id: 701 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'totally/unknown', id: 701 }); expect(res.status).toBe(404); expect(res.json().error.code).toBe(METHOD_NOT_FOUND); @@ -91,7 +91,7 @@ test.describe('protocol 2026-07-28 — removals and error codes', () => { headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', - 'mcp-protocol-version': PROTOCOL_2026, + 'mcp-protocol-version': PROTOCOL_20260728, 'mcp-method': 'notifications/cancelled', }, body: JSON.stringify({ @@ -106,7 +106,7 @@ test.describe('protocol 2026-07-28 — removals and error codes', () => { }); test('does not emit notifications/message when no logLevel was requested', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 702, params: { name: 'echo', arguments: { message: 'quiet' } }, diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/helpers/mcp-stateless-client.ts similarity index 93% rename from apps/e2e/demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/helpers/mcp-stateless-client.ts index 29d9269cc..93987c16e 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/helpers/mcp-2026-client.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/helpers/mcp-stateless-client.ts @@ -8,7 +8,7 @@ * would normalize away. */ -export const PROTOCOL_2026 = '2026-07-28'; +export const PROTOCOL_20260728 = '2026-07-28'; export const META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion'; export const META_CLIENT_INFO = 'io.modelcontextprotocol/clientInfo'; @@ -72,7 +72,7 @@ export interface JsonRpcRequestBody { params?: Record; } -export interface Mcp2026CallOptions { +export interface McpStatelessCallOptions { /** JSON-RPC method, e.g. `tools/call`. */ method: string; /** JSON-RPC params, excluding `_meta` (added automatically). */ @@ -93,7 +93,7 @@ export interface Mcp2026CallOptions { accept?: string; } -export interface Mcp2026Response { +export interface McpStatelessResponse { status: number; headers: Headers; text: string; @@ -112,11 +112,11 @@ export function deriveMcpName(method: string, params: Record | return undefined; } -export function buildMcp2026Request(opts: Mcp2026CallOptions): { +export function buildMcpStatelessRequest(opts: McpStatelessCallOptions): { body: JsonRpcRequestBody; headers: Record; } { - const protocolVersion = opts.protocolVersion ?? PROTOCOL_2026; + const protocolVersion = opts.protocolVersion ?? PROTOCOL_20260728; const meta: Record = { [META_PROTOCOL_VERSION]: protocolVersion, @@ -154,8 +154,8 @@ export function buildMcp2026Request(opts: Mcp2026CallOptions): { } /** Issue a single 2026-07-28 POST and buffer the whole response. */ -export async function mcp2026Fetch(baseUrl: string, opts: Mcp2026CallOptions): Promise { - const { body, headers } = buildMcp2026Request(opts); +export async function mcpStatelessFetch(baseUrl: string, opts: McpStatelessCallOptions): Promise { + const { body, headers } = buildMcpStatelessRequest(opts); const res = await fetch(baseUrl, { method: 'POST', @@ -212,8 +212,8 @@ export interface SseStreamHandle { * Open a long-lived POST/SSE stream (used by `subscriptions/listen`) and expose * a small await-based API over the messages that arrive on it. */ -export async function openMcp2026Stream(baseUrl: string, opts: Mcp2026CallOptions): Promise { - const { body, headers } = buildMcp2026Request(opts); +export async function openMcpStatelessStream(baseUrl: string, opts: McpStatelessCallOptions): Promise { + const { body, headers } = buildMcpStatelessRequest(opts); const controller = new AbortController(); const res = await fetch(baseUrl, { diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr-sampling-roots.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/mrtr-sampling-roots.e2e.spec.ts similarity index 82% rename from apps/e2e/demo-e2e-protocol-2026/e2e/mrtr-sampling-roots.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/mrtr-sampling-roots.e2e.spec.ts index 0093ec3ba..e891953f8 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr-sampling-roots.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/mrtr-sampling-roots.e2e.spec.ts @@ -7,7 +7,11 @@ */ import { expect, test } from '@frontmcp/testing'; -import { mcp2026Fetch, MISSING_REQUIRED_CLIENT_CAPABILITY, type InputRequest } from './helpers/mcp-2026-client'; +import { + mcpStatelessFetch, + MISSING_REQUIRED_CLIENT_CAPABILITY, + type InputRequest, +} from './helpers/mcp-stateless-client'; const SAMPLING_CALL = { method: 'tools/call' as const, @@ -23,14 +27,14 @@ const ROOTS_CALL = { test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); test.describe('sampling', () => { test('answers with a sampling/createMessage input request', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 1 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 1 }); const { result, error } = res.json(); expect(error).toBeUndefined(); @@ -44,11 +48,11 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { }); test('completes the call when the client supplies the completion', async ({ server }) => { - const first = await mcp2026Fetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 2 }); + const first = await mcpStatelessFetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 2 }); const { result: interim } = first.json(); const [key] = Object.keys(interim.inputRequests); - const second = await mcp2026Fetch(server.info.baseUrl, { + const second = await mcpStatelessFetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 3, params: { @@ -74,7 +78,7 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { }); test('rejects sampling when the client declared no sampling capability', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 4, clientCapabilities: {}, @@ -89,7 +93,7 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { test.describe('roots', () => { test('answers with a roots/list input request', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { ...ROOTS_CALL, id: 5 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...ROOTS_CALL, id: 5 }); const { result } = res.json(); expect(result.resultType).toBe('input_required'); @@ -99,11 +103,11 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { }); test('completes the call when the client supplies its roots', async ({ server }) => { - const first = await mcp2026Fetch(server.info.baseUrl, { ...ROOTS_CALL, id: 6 }); + const first = await mcpStatelessFetch(server.info.baseUrl, { ...ROOTS_CALL, id: 6 }); const { result: interim } = first.json(); const [key] = Object.keys(interim.inputRequests); - const second = await mcp2026Fetch(server.info.baseUrl, { + const second = await mcpStatelessFetch(server.info.baseUrl, { ...ROOTS_CALL, id: 7, params: { @@ -124,7 +128,7 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { }); test('rejects roots when the client declared no roots capability', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { ...ROOTS_CALL, id: 8, clientCapabilities: {} }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...ROOTS_CALL, id: 8, clientCapabilities: {} }); expect(res.status).toBe(400); expect(res.json().error.code).toBe(MISSING_REQUIRED_CLIENT_CAPABILITY); @@ -133,7 +137,7 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { test.describe('requestState integrity', () => { test('ignores a tampered requestState and re-asks', async ({ server }) => { - const first = await mcp2026Fetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 9 }); + const first = await mcpStatelessFetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 9 }); const { result: interim } = first.json(); const [key] = Object.keys(interim.inputRequests); @@ -143,7 +147,7 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { 'utf8', ).toString('base64url'); - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 10, params: { ...SAMPLING_CALL.params, requestState: `${forged}.notavalidsignature` }, @@ -157,7 +161,7 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { }); test('rejects a requestState replayed onto a different tool call', async ({ server }) => { - const first = await mcp2026Fetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 11 }); + const first = await mcpStatelessFetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 11 }); const { result: interim } = first.json(); expect(interim.resultType).toBe('input_required'); @@ -165,7 +169,7 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { // no `inputResponses` — the carried state is then the only thing that could // complete the call. If the server honoured the mismatched binding it would // answer `complete`; rejecting it means asking again. - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 12, params: { @@ -180,11 +184,11 @@ test.describe('protocol 2026-07-28 — MRTR for sampling and roots', () => { }); test('accepts a legitimately signed requestState', async ({ server }) => { - const first = await mcp2026Fetch(server.info.baseUrl, { ...ROOTS_CALL, id: 13 }); + const first = await mcpStatelessFetch(server.info.baseUrl, { ...ROOTS_CALL, id: 13 }); const { result: interim } = first.json(); const [key] = Object.keys(interim.inputRequests); - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { ...ROOTS_CALL, id: 14, params: { diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/mrtr.e2e.spec.ts similarity index 81% rename from apps/e2e/demo-e2e-protocol-2026/e2e/mrtr.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/mrtr.e2e.spec.ts index 41bc5e723..dcd06942f 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/mrtr.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/mrtr.e2e.spec.ts @@ -8,7 +8,11 @@ */ import { expect, test } from '@frontmcp/testing'; -import { mcp2026Fetch, MISSING_REQUIRED_CLIENT_CAPABILITY, type InputRequest } from './helpers/mcp-2026-client'; +import { + mcpStatelessFetch, + MISSING_REQUIRED_CLIENT_CAPABILITY, + type InputRequest, +} from './helpers/mcp-stateless-client'; const ELICITING_CALL = { method: 'tools/call' as const, @@ -18,13 +22,13 @@ const ELICITING_CALL = { test.describe('protocol 2026-07-28 — MRTR', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); test('answers an eliciting tool with resultType "input_required"', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 1 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 1 }); expect(res.status).toBe(200); const { result, error } = res.json(); @@ -33,7 +37,7 @@ test.describe('protocol 2026-07-28 — MRTR', () => { }); test('carries an elicitation/create entry in inputRequests', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 2 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 2 }); const { result } = res.json(); const entries = Object.entries(result.inputRequests ?? {}); @@ -47,7 +51,7 @@ test.describe('protocol 2026-07-28 — MRTR', () => { }); test('carries an opaque requestState the client echoes back', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 3 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 3 }); const { result } = res.json(); expect(typeof result.requestState).toBe('string'); @@ -55,7 +59,7 @@ test.describe('protocol 2026-07-28 — MRTR', () => { }); test('never sends a server-initiated JSON-RPC request on the response stream', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 4, accept: 'text/event-stream', @@ -80,12 +84,12 @@ test.describe('protocol 2026-07-28 — MRTR', () => { }); test('completes the call when the client retries with inputResponses', async ({ server }) => { - const first = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 5 }); + const first = await mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 5 }); const { result: interim } = first.json(); const [key] = Object.keys(interim.inputRequests); - const second = await mcp2026Fetch(server.info.baseUrl, { + const second = await mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 6, params: { @@ -105,11 +109,11 @@ test.describe('protocol 2026-07-28 — MRTR', () => { }); test('honours a declined elicitation on retry', async ({ server }) => { - const first = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 7 }); + const first = await mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 7 }); const { result: interim } = first.json(); const [key] = Object.keys(interim.inputRequests); - const second = await mcp2026Fetch(server.info.baseUrl, { + const second = await mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 8, params: { @@ -125,7 +129,7 @@ test.describe('protocol 2026-07-28 — MRTR', () => { }); test('rejects an eliciting call when the client declared no elicitation capability', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 9, params: { name: 'confirm', arguments: { action: 'deploy' } }, @@ -139,7 +143,7 @@ test.describe('protocol 2026-07-28 — MRTR', () => { }); test('does not emit notifications/elicitation/complete (removed in 2026-07-28)', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 10, accept: 'text/event-stream', @@ -149,7 +153,7 @@ test.describe('protocol 2026-07-28 — MRTR', () => { }); test('does not leak an elicitationId field', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { ...ELICITING_CALL, id: 11 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 11 }); const { result } = res.json(); const [, request] = Object.entries(result.inputRequests)[0] as [string, InputRequest]; diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/request-headers.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/request-headers.e2e.spec.ts similarity index 83% rename from apps/e2e/demo-e2e-protocol-2026/e2e/request-headers.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/request-headers.e2e.spec.ts index 49f4efeaf..40de41266 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/request-headers.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/request-headers.e2e.spec.ts @@ -10,22 +10,22 @@ import { expect, test } from '@frontmcp/testing'; import { encodeHeaderValue, HEADER_MISMATCH, - mcp2026Fetch, + mcpStatelessFetch, META_PROTOCOL_VERSION, - PROTOCOL_2026, + PROTOCOL_20260728, UNSUPPORTED_PROTOCOL_VERSION, type ListedTool, -} from './helpers/mcp-2026-client'; +} from './helpers/mcp-stateless-client'; test.describe('protocol 2026-07-28 — request metadata headers', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); test('accepts a request whose headers match the body', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 1, params: { name: 'echo', arguments: { message: 'ok' } }, @@ -36,7 +36,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { }); test('rejects a missing Mcp-Method header with -32020', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 2, headers: { 'mcp-method': null }, @@ -47,7 +47,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { }); test('rejects an Mcp-Method that disagrees with the body', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 3, headers: { 'mcp-method': 'resources/list' }, @@ -58,7 +58,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { }); test('rejects a missing Mcp-Name on tools/call', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 4, params: { name: 'echo', arguments: {} }, @@ -70,7 +70,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { }); test('rejects an Mcp-Name that disagrees with params.name', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 5, params: { name: 'echo', arguments: {} }, @@ -82,7 +82,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { }); test('validates Mcp-Name against params.uri for resources/read', async ({ server }) => { - const ok = await mcp2026Fetch(server.info.baseUrl, { + const ok = await mcpStatelessFetch(server.info.baseUrl, { method: 'resources/read', id: 6, params: { uri: 'proto://config' }, @@ -90,7 +90,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { expect(ok.status).toBe(200); expect(ok.json().error).toBeUndefined(); - const bad = await mcp2026Fetch(server.info.baseUrl, { + const bad = await mcpStatelessFetch(server.info.baseUrl, { method: 'resources/read', id: 7, params: { uri: 'proto://config' }, @@ -101,7 +101,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { }); test('decodes the =?base64?…?= sentinel before comparing Mcp-Name', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'resources/read', id: 8, params: { uri: 'proto://config' }, @@ -113,7 +113,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { expect(res.status).toBe(400); expect(res.json().error.code).toBe(HEADER_MISMATCH); - const good = await mcp2026Fetch(server.info.baseUrl, { + const good = await mcpStatelessFetch(server.info.baseUrl, { method: 'resources/read', id: 9, params: { uri: 'proto://config' }, @@ -124,7 +124,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { }); test('rejects a missing MCP-Protocol-Version header', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 10, headers: { 'mcp-protocol-version': null }, @@ -137,7 +137,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { }); test('rejects a MCP-Protocol-Version header that disagrees with _meta', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 11, headers: { 'mcp-protocol-version': '2025-06-18' }, @@ -148,7 +148,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { }); test('rejects an unknown protocol version with -32022 and lists supported', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 12, protocolVersion: '2099-01-01', @@ -159,11 +159,11 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { expect(error.code).toBe(UNSUPPORTED_PROTOCOL_VERSION); expect(error.data.requested).toBe('2099-01-01'); expect(Array.isArray(error.data.supported)).toBe(true); - expect(error.data.supported).toContain(PROTOCOL_2026); + expect(error.data.supported).toContain(PROTOCOL_20260728); }); test('accepts a matching Mcp-Param-* header from x-mcp-header', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 13, params: { name: 'region-query', arguments: { region: 'us-west1', query: 'SELECT 1' } }, @@ -176,7 +176,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { }); test('rejects an Mcp-Param-* header that disagrees with the argument', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 14, params: { name: 'region-query', arguments: { region: 'us-west1', query: 'SELECT 1' } }, @@ -188,14 +188,14 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { }); test('advertises x-mcp-header in the tool inputSchema', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 15 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 15 }); const tool = (res.json().result.tools as ListedTool[]).find((t) => t.name === 'region-query'); expect(tool?.inputSchema?.['properties']?.region?.['x-mcp-header']).toBe('Region'); }); test('treats header names case-insensitively', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 16, headers: { 'mcp-method': null, 'MCP-METHOD': 'tools/list' }, @@ -212,7 +212,7 @@ test.describe('protocol 2026-07-28 — request metadata headers', () => { headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', - 'mcp-protocol-version': PROTOCOL_2026, + 'mcp-protocol-version': PROTOCOL_20260728, 'mcp-method': 'tools/list', }, body: JSON.stringify({ diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/request-notifications.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/request-notifications.e2e.spec.ts similarity index 78% rename from apps/e2e/demo-e2e-protocol-2026/e2e/request-notifications.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/request-notifications.e2e.spec.ts index 2dfbb78d3..9b9cf330b 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/request-notifications.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/request-notifications.e2e.spec.ts @@ -8,7 +8,7 @@ */ import { expect, test } from '@frontmcp/testing'; -import { mcp2026Fetch, META_SERVER_INFO, parseSseEvents, type ListedTool } from './helpers/mcp-2026-client'; +import { mcpStatelessFetch, META_SERVER_INFO, parseSseEvents, type ListedTool } from './helpers/mcp-stateless-client'; const CHATTY = { method: 'tools/call' as const, @@ -22,13 +22,13 @@ function messagesOf(text: string): any[] { test.describe('protocol 2026-07-28 — request-scoped notifications', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); test('emits no notifications/message when logLevel is absent', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { ...CHATTY, id: 1, accept: 'text/event-stream' }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...CHATTY, id: 1, accept: 'text/event-stream' }); expect(res.status).toBe(200); expect(res.text).not.toContain('notifications/message'); @@ -36,7 +36,7 @@ test.describe('protocol 2026-07-28 — request-scoped notifications', () => { }); test('streams notifications/message when logLevel is set', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { ...CHATTY, id: 2, logLevel: 'debug', @@ -53,7 +53,7 @@ test.describe('protocol 2026-07-28 — request-scoped notifications', () => { }); test('honours the requested minimum severity', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { ...CHATTY, id: 3, logLevel: 'warning', @@ -68,7 +68,7 @@ test.describe('protocol 2026-07-28 — request-scoped notifications', () => { }); test('terminates the stream with the final response', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { ...CHATTY, id: 4, logLevel: 'debug', @@ -83,7 +83,7 @@ test.describe('protocol 2026-07-28 — request-scoped notifications', () => { }); test('streams notifications/progress when a progressToken is supplied', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { ...CHATTY, id: 5, meta: { progressToken: 'tok-1' }, @@ -98,12 +98,12 @@ test.describe('protocol 2026-07-28 — request-scoped notifications', () => { }); test('emits no progress when no progressToken was supplied', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { ...CHATTY, id: 6, accept: 'text/event-stream' }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...CHATTY, id: 6, accept: 'text/event-stream' }); expect(res.text).not.toContain('notifications/progress'); }); test('falls back to a buffered JSON response when the client will not take SSE', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { ...CHATTY, id: 7, logLevel: 'debug', @@ -117,15 +117,15 @@ test.describe('protocol 2026-07-28 — request-scoped notifications', () => { test.describe('protocol 2026-07-28 — OpenTelemetry context', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); const TRACEPARENT = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; test('echoes traceparent back on the result _meta', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 8, meta: { traceparent: TRACEPARENT }, @@ -138,7 +138,7 @@ test.describe('protocol 2026-07-28 — OpenTelemetry context', () => { }); test('echoes tracestate and baggage', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 9, meta: { traceparent: TRACEPARENT, tracestate: 'vendor=abc', baggage: 'tenant=acme' }, @@ -150,7 +150,7 @@ test.describe('protocol 2026-07-28 — OpenTelemetry context', () => { }); test('omits the trace keys entirely when the client sent none', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 10 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 10 }); const { result } = res.json(); expect(result._meta.traceparent).toBeUndefined(); @@ -161,13 +161,13 @@ test.describe('protocol 2026-07-28 — OpenTelemetry context', () => { test.describe('protocol 2026-07-28 — deterministic list ordering', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); test('returns tools sorted by name', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 11 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 11 }); const names = (res.json().result.tools as ListedTool[]).map((t) => t.name); expect(names).toEqual([...names].sort()); @@ -175,8 +175,8 @@ test.describe('protocol 2026-07-28 — deterministic list ordering', () => { test('returns prompts and resources in a stable order too', async ({ server }) => { for (const [index, method] of ['prompts/list', 'resources/list'].entries()) { - const first = await mcp2026Fetch(server.info.baseUrl, { method, id: 20 + index }); - const second = await mcp2026Fetch(server.info.baseUrl, { method, id: 30 + index }); + const first = await mcpStatelessFetch(server.info.baseUrl, { method, id: 20 + index }); + const second = await mcpStatelessFetch(server.info.baseUrl, { method, id: 30 + index }); expect(JSON.stringify(first.json().result)).toBe(JSON.stringify(second.json().result)); } }); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/stateless-requests.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/stateless-requests.e2e.spec.ts similarity index 80% rename from apps/e2e/demo-e2e-protocol-2026/e2e/stateless-requests.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/stateless-requests.e2e.spec.ts index 4184b5916..33b0e93af 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/stateless-requests.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/stateless-requests.e2e.spec.ts @@ -8,22 +8,22 @@ import { expect, test } from '@frontmcp/testing'; import { - mcp2026Fetch, + mcpStatelessFetch, META_SERVER_INFO, MISSING_REQUIRED_CLIENT_CAPABILITY, type ListedTool, - type Mcp2026Response, -} from './helpers/mcp-2026-client'; + type McpStatelessResponse, +} from './helpers/mcp-stateless-client'; test.describe('protocol 2026-07-28 — stateless requests', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); test('tools/list works with no initialize and no session', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 1 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 1 }); expect(res.status).toBe(200); const { result, error } = res.json(); @@ -34,7 +34,7 @@ test.describe('protocol 2026-07-28 — stateless requests', () => { }); test('never mints an Mcp-Session-Id', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 2 }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 2 }); // Sessions were removed from the transport — the server must not mint or // echo one, even if a client sends it. @@ -42,7 +42,7 @@ test.describe('protocol 2026-07-28 — stateless requests', () => { }); test('ignores an Mcp-Session-Id sent by a confused client', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 3, headers: { 'mcp-session-id': 'bogus-session-value' }, @@ -54,7 +54,7 @@ test.describe('protocol 2026-07-28 — stateless requests', () => { }); test('ignores Last-Event-ID — streams are no longer resumable', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 4, headers: { 'last-event-id': '42' }, @@ -65,7 +65,7 @@ test.describe('protocol 2026-07-28 — stateless requests', () => { }); test('tools/call succeeds cold, with no prior request of any kind', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 5, params: { name: 'echo', arguments: { message: 'stateless' } }, @@ -90,7 +90,7 @@ test.describe('protocol 2026-07-28 — stateless requests', () => { const seen: Record = {}; for (const [i, call] of calls.entries()) { - const res = await mcp2026Fetch(server.info.baseUrl, { ...call, id: 100 + i }); + const res = await mcpStatelessFetch(server.info.baseUrl, { ...call, id: 100 + i }); const body = res.json(); seen[call.method] = body.error ?? body.result?.resultType; } @@ -112,7 +112,7 @@ test.describe('protocol 2026-07-28 — stateless requests', () => { const methods = ['tools/list', 'resources/list', 'prompts/list']; const seen: Record = {}; for (const [i, method] of methods.entries()) { - const res = await mcp2026Fetch(server.info.baseUrl, { method, id: 200 + i }); + const res = await mcpStatelessFetch(server.info.baseUrl, { method, id: 200 + i }); seen[method] = res.json().result?._meta?.[META_SERVER_INFO]; } @@ -124,13 +124,13 @@ test.describe('protocol 2026-07-28 — stateless requests', () => { test('does not infer capabilities from a previous request', async ({ server }) => { // Request 1 declares elicitation support; request 2 declares none. The // server MUST NOT carry the first declaration over to the second. - await mcp2026Fetch(server.info.baseUrl, { + await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 6, clientCapabilities: { elicitation: { form: {} } }, }); - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 7, params: { name: 'confirm', arguments: { action: 'deploy' } }, @@ -146,10 +146,10 @@ test.describe('protocol 2026-07-28 — stateless requests', () => { }); test('returns tools/list in a deterministic order across calls', async ({ server }) => { - const first = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 8 }); - const second = await mcp2026Fetch(server.info.baseUrl, { method: 'tools/list', id: 9 }); + const first = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 8 }); + const second = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 9 }); - const names = (r: Mcp2026Response) => (r.json().result.tools as ListedTool[]).map((t) => t.name); + const names = (r: McpStatelessResponse) => (r.json().result.tools as ListedTool[]).map((t) => t.name); expect(names(first)).toEqual(names(second)); }); }); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/subscriptions-listen.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/subscriptions-listen.e2e.spec.ts similarity index 65% rename from apps/e2e/demo-e2e-protocol-2026/e2e/subscriptions-listen.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/subscriptions-listen.e2e.spec.ts index 10a9535c3..a032cd643 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/subscriptions-listen.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/subscriptions-listen.e2e.spec.ts @@ -7,17 +7,22 @@ */ import { expect, test } from '@frontmcp/testing'; -import { META_SUBSCRIPTION_ID, openMcp2026Stream } from './helpers/mcp-2026-client'; +import { + INVALID_PARAMS, + mcpStatelessFetch, + META_SUBSCRIPTION_ID, + openMcpStatelessStream, +} from './helpers/mcp-stateless-client'; test.describe('protocol 2026-07-28 — subscriptions/listen', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); test('opens an SSE response stream', async ({ server }) => { - const stream = await openMcp2026Stream(server.info.baseUrl, { + const stream = await openMcpStatelessStream(server.info.baseUrl, { method: 'subscriptions/listen', id: 'sub-1', params: { notifications: { toolsListChanged: true } }, @@ -32,7 +37,7 @@ test.describe('protocol 2026-07-28 — subscriptions/listen', () => { }); test('sets X-Accel-Buffering: no on the stream', async ({ server }) => { - const stream = await openMcp2026Stream(server.info.baseUrl, { + const stream = await openMcpStatelessStream(server.info.baseUrl, { method: 'subscriptions/listen', id: 'sub-2', params: { notifications: { toolsListChanged: true } }, @@ -46,7 +51,7 @@ test.describe('protocol 2026-07-28 — subscriptions/listen', () => { }); test('acknowledges the subscription as its first message', async ({ server }) => { - const stream = await openMcp2026Stream(server.info.baseUrl, { + const stream = await openMcpStatelessStream(server.info.baseUrl, { method: 'subscriptions/listen', id: 'sub-3', params: { notifications: { toolsListChanged: true, resourcesListChanged: true } }, @@ -65,7 +70,7 @@ test.describe('protocol 2026-07-28 — subscriptions/listen', () => { }); test('tags every subscription message with the subscriptionId', async ({ server }) => { - const stream = await openMcp2026Stream(server.info.baseUrl, { + const stream = await openMcpStatelessStream(server.info.baseUrl, { method: 'subscriptions/listen', id: 'sub-4', params: { notifications: { toolsListChanged: true } }, @@ -80,7 +85,7 @@ test.describe('protocol 2026-07-28 — subscriptions/listen', () => { }); test('omits notification types the server cannot honor', async ({ server }) => { - const stream = await openMcp2026Stream(server.info.baseUrl, { + const stream = await openMcpStatelessStream(server.info.baseUrl, { method: 'subscriptions/listen', id: 'sub-5', params: { notifications: { toolsListChanged: true, promptsListChanged: true } }, @@ -101,7 +106,7 @@ test.describe('protocol 2026-07-28 — subscriptions/listen', () => { }); test('does not send unrequested notification types', async ({ server }) => { - const stream = await openMcp2026Stream(server.info.baseUrl, { + const stream = await openMcpStatelessStream(server.info.baseUrl, { method: 'subscriptions/listen', id: 'sub-6', params: { notifications: { resourcesListChanged: true } }, @@ -116,7 +121,7 @@ test.describe('protocol 2026-07-28 — subscriptions/listen', () => { }); test('accepts resourceSubscriptions in place of resources/subscribe', async ({ server }) => { - const stream = await openMcp2026Stream(server.info.baseUrl, { + const stream = await openMcpStatelessStream(server.info.baseUrl, { method: 'subscriptions/listen', id: 'sub-7', params: { notifications: { resourceSubscriptions: ['proto://config'] } }, @@ -131,7 +136,7 @@ test.describe('protocol 2026-07-28 — subscriptions/listen', () => { }); test('does not deliver request-scoped notifications on the listen stream', async ({ server }) => { - const stream = await openMcp2026Stream(server.info.baseUrl, { + const stream = await openMcpStatelessStream(server.info.baseUrl, { method: 'subscriptions/listen', id: 'sub-8', params: { notifications: { toolsListChanged: true } }, @@ -149,3 +154,47 @@ test.describe('protocol 2026-07-28 — subscriptions/listen', () => { } }); }); + +test.describe('protocol 2026-07-28 — subscriptions/listen validation', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', + publicMode: true, + }); + + test('rejects a non-array resourceSubscriptions with -32602', async ({ server }) => { + // Must fail BEFORE the SSE headers are committed — once the stream is open + // there is no way to send a JSON-RPC error instead. + const res = await mcpStatelessFetch(server.info.baseUrl, { + method: 'subscriptions/listen', + id: 'bad-1', + params: { notifications: { resourceSubscriptions: 42 } }, + }); + + expect(res.status).toBe(400); + expect(res.headers.get('content-type')).not.toContain('text/event-stream'); + expect(res.json().error.code).toBe(INVALID_PARAMS); + }); + + test('rejects a non-object notifications filter', async ({ server }) => { + const res = await mcpStatelessFetch(server.info.baseUrl, { + method: 'subscriptions/listen', + id: 'bad-2', + params: { notifications: 'everything' }, + }); + + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(INVALID_PARAMS); + }); + + test('rejects an id that is not a string or number', async ({ server }) => { + const res = await mcpStatelessFetch(server.info.baseUrl, { + method: 'subscriptions/listen', + id: { nested: true } as unknown as string, + params: { notifications: { toolsListChanged: true } }, + }); + + expect(res.status).toBe(400); + expect(res.json().error.code).toBe(INVALID_PARAMS); + }); +}); diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-anonymous.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/tasks-anonymous.e2e.spec.ts similarity index 82% rename from apps/e2e/demo-e2e-protocol-2026/e2e/tasks-anonymous.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/tasks-anonymous.e2e.spec.ts index cfc5e7255..ad519f9a2 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-anonymous.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/tasks-anonymous.e2e.spec.ts @@ -12,19 +12,19 @@ */ import { expect, test } from '@frontmcp/testing'; -import { INVALID_PARAMS, mcp2026Fetch } from './helpers/mcp-2026-client'; +import { INVALID_PARAMS, mcpStatelessFetch } from './helpers/mcp-stateless-client'; const TASKS_EXT = { extensions: { 'io.modelcontextprotocol/tasks': {} } }; test.describe('protocol 2026-07-28 — tasks require an identified caller', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', publicMode: true, }); test('refuses tasks/get for an anonymous caller', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tasks/get', id: 1, params: { taskId: 'anything' }, @@ -36,7 +36,7 @@ test.describe('protocol 2026-07-28 — tasks require an identified caller', () = }); test('refuses tasks/update for an anonymous caller', async ({ server }) => { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tasks/update', id: 2, params: { taskId: 'anything', inputResponses: {} }, diff --git a/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-extension.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/tasks-extension.e2e.spec.ts similarity index 88% rename from apps/e2e/demo-e2e-protocol-2026/e2e/tasks-extension.e2e.spec.ts rename to apps/e2e/demo-e2e-protocol-20260728/e2e/tasks-extension.e2e.spec.ts index 447ec4e17..da75faf2e 100644 --- a/apps/e2e/demo-e2e-protocol-2026/e2e/tasks-extension.e2e.spec.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/tasks-extension.e2e.spec.ts @@ -9,12 +9,12 @@ import { expect, test } from '@frontmcp/testing'; import { - mcp2026Fetch, + mcpStatelessFetch, METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, type InputRequest, type TaskWire, -} from './helpers/mcp-2026-client'; +} from './helpers/mcp-stateless-client'; const JWT_SECRET = 'protocol-2026-tasks-e2e-secret-0123456789'; const TASKS_EXT = { extensions: { 'io.modelcontextprotocol/tasks': {} } }; @@ -31,7 +31,7 @@ async function pollUntil( let last: TaskWire | undefined; let id = 9000; while (Date.now() < deadline) { - const res = await mcp2026Fetch(baseUrl, { + const res = await mcpStatelessFetch(baseUrl, { method: 'tasks/get', id: id++, params: { taskId }, @@ -47,14 +47,14 @@ async function pollUntil( test.describe('protocol 2026-07-28 — tasks extension', () => { test.use({ - server: 'apps/e2e/demo-e2e-protocol-2026/src/main-tasks.ts', - project: 'demo-e2e-protocol-2026', + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main-tasks.ts', + project: 'demo-e2e-protocol-20260728', env: { JWT_SECRET }, }); test('advertises the extension in server/discover', async ({ server, auth }) => { const token = await auth.createToken({ sub: 'user-discover', scopes: ['anonymous'] }); - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'server/discover', id: 1, headers: { authorization: `Bearer ${token}` }, @@ -65,7 +65,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { test('returns resultType "task" when the client declares the extension', async ({ server, auth }) => { const token = await auth.createToken({ sub: 'user-create', scopes: ['anonymous'] }); - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 2, params: { name: 'slow-job', arguments: { label: 'build' } }, @@ -83,7 +83,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { test('uses the 2026 field names on the task handle', async ({ server, auth }) => { const token = await auth.createToken({ sub: 'user-fields', scopes: ['anonymous'] }); - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 3, params: { name: 'slow-job', arguments: {} }, @@ -101,7 +101,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { test('runs inline when the client did NOT declare the extension', async ({ server, auth }) => { const token = await auth.createToken({ sub: 'user-inline', scopes: ['anonymous'] }); - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 4, params: { name: 'slow-job', arguments: { label: 'inline' } }, @@ -117,7 +117,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { test('polls to completion via tasks/get and carries the result', async ({ server, auth }) => { const token = await auth.createToken({ sub: 'user-poll', scopes: ['anonymous'] }); - const created = await mcp2026Fetch(server.info.baseUrl, { + const created = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 5, params: { name: 'slow-job', arguments: { label: 'polled' } }, @@ -134,7 +134,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { test('requires the extension to call tasks/get', async ({ server, auth }) => { const token = await auth.createToken({ sub: 'user-nocap', scopes: ['anonymous'] }); - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tasks/get', id: 6, params: { taskId: 'whatever' }, @@ -150,7 +150,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { const owner = await auth.createToken({ sub: 'user-owner', scopes: ['anonymous'] }); const stranger = await auth.createToken({ sub: 'user-stranger', scopes: ['anonymous'] }); - const created = await mcp2026Fetch(server.info.baseUrl, { + const created = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 7, params: { name: 'slow-job', arguments: {} }, @@ -159,7 +159,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { }); const { taskId } = created.json().result.task; - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tasks/get', id: 8, params: { taskId }, @@ -174,7 +174,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { test('tasks/list and tasks/result are gone', async ({ server, auth }) => { const token = await auth.createToken({ sub: 'user-removed', scopes: ['anonymous'] }); for (const [index, method] of ['tasks/list', 'tasks/result'].entries()) { - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method, id: 10 + index, params: { taskId: 'x' }, @@ -188,7 +188,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { test('cancels a task', async ({ server, auth }) => { const token = await auth.createToken({ sub: 'user-cancel', scopes: ['anonymous'] }); - const created = await mcp2026Fetch(server.info.baseUrl, { + const created = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 12, params: { name: 'slow-job', arguments: { delayMs: 3000 } }, @@ -197,7 +197,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { }); const { taskId } = created.json().result.task; - const cancelled = await mcp2026Fetch(server.info.baseUrl, { + const cancelled = await mcpStatelessFetch(server.info.baseUrl, { method: 'tasks/cancel', id: 13, params: { taskId }, @@ -213,7 +213,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { test.describe('mid-flight input', () => { test('parks the task in input_required with pending inputRequests', async ({ server, auth }) => { const token = await auth.createToken({ sub: 'user-input', scopes: ['anonymous'] }); - const created = await mcp2026Fetch(server.info.baseUrl, { + const created = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 14, params: { name: 'approve-job', arguments: { change: 'deploy v2' } }, @@ -233,7 +233,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { test('resumes and completes after tasks/update', async ({ server, auth }) => { const token = await auth.createToken({ sub: 'user-resume', scopes: ['anonymous'] }); - const created = await mcp2026Fetch(server.info.baseUrl, { + const created = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 15, params: { name: 'approve-job', arguments: { change: 'deploy v3' } }, @@ -245,7 +245,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { const paused = await pollUntil(server.info.baseUrl, token, taskId, (t) => t.status === 'input_required'); const [key] = Object.keys(paused.inputRequests); - const updated = await mcp2026Fetch(server.info.baseUrl, { + const updated = await mcpStatelessFetch(server.info.baseUrl, { method: 'tasks/update', id: 16, params: { taskId, inputResponses: { [key]: { action: 'accept', content: { approved: true } } } }, @@ -260,7 +260,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { test('rejects tasks/update for a task that is not awaiting input', async ({ server, auth }) => { const token = await auth.createToken({ sub: 'user-badupdate', scopes: ['anonymous'] }); - const created = await mcp2026Fetch(server.info.baseUrl, { + const created = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/call', id: 17, params: { name: 'slow-job', arguments: {} }, @@ -270,7 +270,7 @@ test.describe('protocol 2026-07-28 — tasks extension', () => { const { taskId } = created.json().result.task; await pollUntil(server.info.baseUrl, token, taskId, (t) => t.status === 'completed'); - const res = await mcp2026Fetch(server.info.baseUrl, { + const res = await mcpStatelessFetch(server.info.baseUrl, { method: 'tasks/update', id: 18, params: { taskId, inputResponses: { 'elicitation-1': { action: 'accept' } } }, diff --git a/apps/e2e/demo-e2e-protocol-2026/jest.e2e.config.ts b/apps/e2e/demo-e2e-protocol-20260728/jest.e2e.config.ts similarity index 95% rename from apps/e2e/demo-e2e-protocol-2026/jest.e2e.config.ts rename to apps/e2e/demo-e2e-protocol-20260728/jest.e2e.config.ts index 3e326e130..f9d1106dd 100644 --- a/apps/e2e/demo-e2e-protocol-2026/jest.e2e.config.ts +++ b/apps/e2e/demo-e2e-protocol-20260728/jest.e2e.config.ts @@ -6,7 +6,7 @@ const require = createRequire(import.meta.url); const e2eCoveragePreset = require('../../../jest.e2e.coverage.preset.js'); const config: Config.InitialOptions = { - displayName: 'demo-e2e-protocol-2026', + displayName: 'demo-e2e-protocol-20260728', preset: '../../../jest.preset.js', testEnvironment: 'node', testMatch: ['/e2e/**/*.e2e.spec.ts'], @@ -36,7 +36,7 @@ const config: Config.InitialOptions = { '^@frontmcp/sdk$': '/../../../libs/sdk/src/index.ts', '^@frontmcp/adapters$': '/../../../libs/adapters/src/index.ts', }, - coverageDirectory: '../../../coverage/e2e/demo-e2e-protocol-2026', + coverageDirectory: '../../../coverage/e2e/demo-e2e-protocol-20260728', ...e2eCoveragePreset, }; diff --git a/apps/e2e/demo-e2e-protocol-2026/project.json b/apps/e2e/demo-e2e-protocol-20260728/project.json similarity index 61% rename from apps/e2e/demo-e2e-protocol-2026/project.json rename to apps/e2e/demo-e2e-protocol-20260728/project.json index a28a2f9eb..da4e8f6ae 100644 --- a/apps/e2e/demo-e2e-protocol-2026/project.json +++ b/apps/e2e/demo-e2e-protocol-20260728/project.json @@ -1,7 +1,7 @@ { - "name": "demo-e2e-protocol-2026", + "name": "demo-e2e-protocol-20260728", "$schema": "../../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "apps/e2e/demo-e2e-protocol-2026/src", + "sourceRoot": "apps/e2e/demo-e2e-protocol-20260728/src", "projectType": "application", "tags": ["scope:demo", "type:e2e", "feature:protocol"], "targets": { @@ -12,10 +12,10 @@ "options": { "target": "node", "compiler": "tsc", - "outputPath": "dist/apps/e2e/demo-e2e-protocol-2026", - "main": "apps/e2e/demo-e2e-protocol-2026/src/main.ts", - "tsConfig": "apps/e2e/demo-e2e-protocol-2026/tsconfig.app.json", - "webpackConfig": "apps/e2e/demo-e2e-protocol-2026/webpack.config.js", + "outputPath": "dist/apps/e2e/demo-e2e-protocol-20260728", + "main": "apps/e2e/demo-e2e-protocol-20260728/src/main.ts", + "tsConfig": "apps/e2e/demo-e2e-protocol-20260728/tsconfig.app.json", + "webpackConfig": "apps/e2e/demo-e2e-protocol-20260728/webpack.config.js", "generatePackageJson": true }, "configurations": { @@ -29,15 +29,15 @@ "executor": "nx:run-commands", "dependsOn": ["build"], "options": { - "command": "node dist/apps/e2e/demo-e2e-protocol-2026/main.js", + "command": "node dist/apps/e2e/demo-e2e-protocol-20260728/main.js", "cwd": "{workspaceRoot}" } }, "test": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-protocol-2026"], + "outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-protocol-20260728"], "options": { - "jestConfig": "apps/e2e/demo-e2e-protocol-2026/jest.e2e.config.ts", + "jestConfig": "apps/e2e/demo-e2e-protocol-20260728/jest.e2e.config.ts", "passWithNoTests": true } } diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/index.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/index.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/proto/index.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/index.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/prompts/greeting.prompt.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/prompts/greeting.prompt.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/proto/prompts/greeting.prompt.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/prompts/greeting.prompt.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/resources/config.resource.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/resources/config.resource.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/proto/resources/config.resource.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/resources/config.resource.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/chatty.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/chatty.tool.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/chatty.tool.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/chatty.tool.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/confirm.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/confirm.tool.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/confirm.tool.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/confirm.tool.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/echo.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/echo.tool.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/echo.tool.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/echo.tool.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/list-workspaces.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/list-workspaces.tool.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/list-workspaces.tool.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/list-workspaces.tool.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/region-query.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/region-query.tool.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/region-query.tool.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/region-query.tool.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/summarize.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/summarize.tool.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/proto/tools/summarize.tool.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/summarize.tool.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/index.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/tasks/index.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/index.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/tasks/index.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/approve-job.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/tasks/tools/approve-job.tool.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/approve-job.tool.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/tasks/tools/approve-job.tool.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/slow-job.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/tasks/tools/slow-job.tool.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/apps/tasks/tools/slow-job.tool.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/apps/tasks/tools/slow-job.tool.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/main-tasks.ts b/apps/e2e/demo-e2e-protocol-20260728/src/main-tasks.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/main-tasks.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/main-tasks.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/src/main.ts b/apps/e2e/demo-e2e-protocol-20260728/src/main.ts similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/src/main.ts rename to apps/e2e/demo-e2e-protocol-20260728/src/main.ts diff --git a/apps/e2e/demo-e2e-protocol-2026/tsconfig.app.json b/apps/e2e/demo-e2e-protocol-20260728/tsconfig.app.json similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/tsconfig.app.json rename to apps/e2e/demo-e2e-protocol-20260728/tsconfig.app.json diff --git a/apps/e2e/demo-e2e-protocol-2026/tsconfig.e2e.json b/apps/e2e/demo-e2e-protocol-20260728/tsconfig.e2e.json similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/tsconfig.e2e.json rename to apps/e2e/demo-e2e-protocol-20260728/tsconfig.e2e.json diff --git a/apps/e2e/demo-e2e-protocol-2026/tsconfig.json b/apps/e2e/demo-e2e-protocol-20260728/tsconfig.json similarity index 100% rename from apps/e2e/demo-e2e-protocol-2026/tsconfig.json rename to apps/e2e/demo-e2e-protocol-20260728/tsconfig.json diff --git a/apps/e2e/demo-e2e-protocol-2026/webpack.config.js b/apps/e2e/demo-e2e-protocol-20260728/webpack.config.js similarity index 98% rename from apps/e2e/demo-e2e-protocol-2026/webpack.config.js rename to apps/e2e/demo-e2e-protocol-20260728/webpack.config.js index 39e3696d9..7c2b400b5 100644 --- a/apps/e2e/demo-e2e-protocol-2026/webpack.config.js +++ b/apps/e2e/demo-e2e-protocol-20260728/webpack.config.js @@ -3,7 +3,7 @@ const { join } = require('path'); module.exports = { output: { - path: join(__dirname, '../../../dist/apps/e2e/demo-e2e-protocol-2026'), + path: join(__dirname, '../../../dist/apps/e2e/demo-e2e-protocol-20260728'), ...(process.env.NODE_ENV !== 'production' && { devtoolModuleFilenameTemplate: '[absolute-resource-path]', }), diff --git a/docs/frontmcp/adapters/openapi-adapter.mdx b/docs/frontmcp/adapters/openapi-adapter.mdx index 400f54da1..76ec31aaa 100644 --- a/docs/frontmcp/adapters/openapi-adapter.mdx +++ b/docs/frontmcp/adapters/openapi-adapter.mdx @@ -550,11 +550,11 @@ OpenapiAdapter.init({ }); ``` -| Field | Type | Description | -| --- | --- | --- | -| `mode` | `'definition' \| 'description' \| 'both'` | Where to expose the schema. Default: `'definition'`. | -| `descriptionFormat` | `'jsonSchema' \| 'summary'` | Format used when schema appears in description. Default: `'summary'`. | -| `descriptionFormatter` | `(schema, ctx) => string \| Promise` | Custom formatter (sync or async, e.g., LLM-generated). | +| Field | Type | Description | +| ---------------------- | -------------------------------------------- | --------------------------------------------------------------------- | +| `mode` | `'definition' \| 'description' \| 'both'` | Where to expose the schema. Default: `'definition'`. | +| `descriptionFormat` | `'jsonSchema' \| 'summary'` | Format used when schema appears in description. Default: `'summary'`. | +| `descriptionFormatter` | `(schema, ctx) => string \| Promise` | Custom formatter (sync or async, e.g., LLM-generated). | #### Custom Schema Formatter @@ -847,11 +847,12 @@ OpenapiAdapter.init({ }, }); ``` + -| Option | Type | Default | Description | -| --- | --- | --- | --- | -| `resolveFormats` | `boolean` | `false` | Enable built-in format resolvers (uuid, date-time, email, int32, etc.) | +| Option | Type | Default | Description | +| ----------------- | -------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `resolveFormats` | `boolean` | `false` | Enable built-in format resolvers (uuid, date-time, email, int32, etc.) | | `formatResolvers` | `Record` | `undefined` | Custom format resolvers. When used with `resolveFormats: true`, custom resolvers are merged with built-ins (custom takes precedence). | @@ -980,6 +981,7 @@ OpenapiAdapter.init({ }, }); ``` + @@ -990,12 +992,12 @@ OpenapiAdapter.init({ These apply to **both** the spec-URL fetch and external `$ref` resolution (`mcp-from-openapi` ≥ 2.5.0). -| Option | Type | Default (FrontMCP) | Description | -| --- | --- | --- | --- | -| `allowedProtocols` | `string[]` | `[]` | Protocols allowed for external `$ref` resolution (http, https, file, …). **FrontMCP defaults this to `[]`** (external refs disabled); set `['http','https']` to enable. | -| `allowedHosts` | `string[]` | `undefined` | When set, only the spec URL / `$ref` URLs pointing to these hostnames are allowed. All other hosts are blocked. | -| `blockedHosts` | `string[]` | `undefined` | Additional hostnames/IPs to block, on top of the built-in internal-address block list. | -| `allowInternalIPs` | `boolean` | `false` | Set to `true` to allow loopback / private / internal targets for the spec URL **and** `$ref`s (skips the internal-address block list and DNS re-check). **Warning:** re-exposes SSRF; use only in trusted/local environments. | +| Option | Type | Default (FrontMCP) | Description | +| ------------------ | ---------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `allowedProtocols` | `string[]` | `[]` | Protocols allowed for external `$ref` resolution (http, https, file, …). **FrontMCP defaults this to `[]`** (external refs disabled); set `['http','https']` to enable. | +| `allowedHosts` | `string[]` | `undefined` | When set, only the spec URL / `$ref` URLs pointing to these hostnames are allowed. All other hosts are blocked. | +| `blockedHosts` | `string[]` | `undefined` | Additional hostnames/IPs to block, on top of the built-in internal-address block list. | +| `allowInternalIPs` | `boolean` | `false` | Set to `true` to allow loopback / private / internal targets for the spec URL **and** `$ref`s (skips the internal-address block list and DNS re-check). **Warning:** re-exposes SSRF; use only in trusted/local environments. | ## Polling & Live Updates @@ -1199,14 +1201,14 @@ The adapter automatically validates your security configuration and assigns a ri Beyond authentication, the adapter includes defense-in-depth protections: -| Protection | Description | -| ------------------------- | --------------------------------------------------------------------------------------- | +| Protection | Description | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | **$ref SSRF Prevention** | Blocks `file://` protocol and internal/private IPs during `$ref` dereferencing. Configurable via [`refResolution`](#ref-resolution-security). | -| **SSRF Prevention** | Validates server URLs, blocks dangerous protocols (`file://`, `javascript://`, `data:`) | -| **Header Injection** | Rejects control characters (`\r`, `\n`, `\x00`, `\f`, `\v`) in header values | -| **Prototype Pollution** | Blocks reserved JS keys (`__proto__`, `constructor`, `prototype`) in input transforms | -| **Request Size Limits** | Content-Length validation with integer overflow protection | -| **Query Param Collision** | Detects conflicts between security and user input parameters | +| **SSRF Prevention** | Validates server URLs, blocks dangerous protocols (`file://`, `javascript://`, `data:`) | +| **Header Injection** | Rejects control characters (`\r`, `\n`, `\x00`, `\f`, `\v`) in header values | +| **Prototype Pollution** | Blocks reserved JS keys (`__proto__`, `constructor`, `prototype`) in input transforms | +| **Request Size Limits** | Content-Length validation with integer overflow protection | +| **Query Param Collision** | Detects conflicts between security and user input parameters | **Auth Type Routing:** Tokens are automatically routed to the correct context field based on security scheme type (Bearer → `jwt`, API Key → `apiKey`, Basic → `basic`, OAuth2 → `oauth2Token`). diff --git a/docs/frontmcp/adapters/openapi-polling.mdx b/docs/frontmcp/adapters/openapi-polling.mdx index 5959b8949..2bb6fd00c 100644 --- a/docs/frontmcp/adapters/openapi-polling.mdx +++ b/docs/frontmcp/adapters/openapi-polling.mdx @@ -336,6 +336,7 @@ export default class MultiApiApp {} Use short intervals (100ms) and real HTTP servers in integration tests to verify the full pipeline. See the [Testing OpenAPI Adapter](/frontmcp/guides/testing-openapi-adapter) guide for patterns. Test servers bind to `localhost`, which the SSRF guard blocks by default. Add `loadOptions: { refResolution: { allowInternalIPs: true } }` to the adapter under test so the poller can reach the local spec server. + diff --git a/docs/frontmcp/authentication/authorities.mdx b/docs/frontmcp/authentication/authorities.mdx index d0171af88..16e79fa22 100644 --- a/docs/frontmcp/authentication/authorities.mdx +++ b/docs/frontmcp/authentication/authorities.mdx @@ -13,12 +13,12 @@ The Authorities system provides declarative, built-in authorization on tools, re Authorities supports four authorization paradigms: -| Paradigm | Description | Use Case | -| -------- | ----------- | -------- | -| **RBAC** | Role-based and permission-based checks | "Only admins can delete users" | -| **ABAC** | Attribute-based conditions with operators | "Only users in the `engineering` department" | -| **ReBAC** | Relationship-based checks via an external resolver | "Only the owner of this document" | -| **Custom** | Extend with your own evaluator functions | "Only requests from allowed IP ranges" | +| Paradigm | Description | Use Case | +| ---------- | -------------------------------------------------- | -------------------------------------------- | +| **RBAC** | Role-based and permission-based checks | "Only admins can delete users" | +| **ABAC** | Attribute-based conditions with operators | "Only users in the `engineering` department" | +| **ReBAC** | Relationship-based checks via an external resolver | "Only the owner of this document" | +| **Custom** | Extend with your own evaluator functions | "Only requests from allowed IP ranges" | These paradigms can be composed using `allOf`, `anyOf`, and `not` combinators to express complex policies. @@ -309,12 +309,12 @@ When both `roles` and `permissions` are specified in the same policy, they are c Attribute-based access control evaluates conditions against a context envelope with four namespaces: -| Prefix | Source | -| -------- | ------ | -| `user.*` | Resolved user object (`sub`, `roles`, `permissions`, `claims`) | -| `claims.*` | Raw JWT claims | -| `input.*` | Tool/prompt input arguments | -| `env.*` | Runtime environment variables | +| Prefix | Source | +| ---------- | -------------------------------------------------------------- | +| `user.*` | Resolved user object (`sub`, `roles`, `permissions`, `claims`) | +| `claims.*` | Raw JWT claims | +| `input.*` | Tool/prompt input arguments | +| `env.*` | Runtime environment variables | ### Simple Match @@ -355,21 +355,21 @@ The `conditions` field supports a rich set of operators for more complex checks. ### Operator Reference -| Operator | Description | Value Type | -| ------------ | ---------------------------------------- | ------------- | -| `eq` | Strict equality (`===`) | any | -| `neq` | Strict inequality (`!==`) | any | -| `in` | Value is in the array | array | -| `notIn` | Value is not in the array | array | -| `gt` | Greater than | number | -| `gte` | Greater than or equal | number | -| `lt` | Less than | number | -| `lte` | Less than or equal | number | -| `contains` | String includes substring, or array contains value | string/array | -| `startsWith` | String starts with prefix | string | -| `endsWith` | String ends with suffix | string | -| `exists` | Value is defined (`true`) or undefined (`false`) | boolean | -| `matches` | Regular expression match | string (regex) | +| Operator | Description | Value Type | +| ------------ | -------------------------------------------------- | -------------- | +| `eq` | Strict equality (`===`) | any | +| `neq` | Strict inequality (`!==`) | any | +| `in` | Value is in the array | array | +| `notIn` | Value is not in the array | array | +| `gt` | Greater than | number | +| `gte` | Greater than or equal | number | +| `lt` | Less than | number | +| `lte` | Less than or equal | number | +| `contains` | String includes substring, or array contains value | string/array | +| `startsWith` | String starts with prefix | string | +| `endsWith` | String ends with suffix | string | +| `exists` | Value is defined (`true`) or undefined (`false`) | boolean | +| `matches` | Regular expression match | string (regex) | ### Dynamic Value References @@ -399,9 +399,9 @@ Condition values can reference runtime data instead of using static literals. }) ``` -| Ref | Description | -| ---- | ----------- | -| `{ fromInput: 'fieldName' }` | Resolves to the value of the named tool input argument | +| Ref | Description | +| ---------------------------- | ----------------------------------------------------------- | +| `{ fromInput: 'fieldName' }` | Resolves to the value of the named tool input argument | | `{ fromClaims: 'dot.path' }` | Resolves to a value from the user's JWT claims via dot-path | @@ -480,11 +480,11 @@ Pass an array of relationship checks. All must pass. ### Resource ID Sources -| Source | Example | Description | -| ------ | ------- | ----------- | -| Static string | `resourceId: 'site-123'` | Hardcoded resource ID | -| From input | `resourceId: { fromInput: 'siteId' }` | Resolved from tool input arguments | -| From claims | `resourceId: { fromClaims: 'user.orgId' }` | Resolved from JWT claims via dot-path | +| Source | Example | Description | +| ------------- | ------------------------------------------ | ------------------------------------- | +| Static string | `resourceId: 'site-123'` | Hardcoded resource ID | +| From input | `resourceId: { fromInput: 'siteId' }` | Resolved from tool input arguments | +| From claims | `resourceId: { fromClaims: 'user.orgId' }` | Resolved from JWT claims via dot-path | ## Combinators @@ -659,11 +659,11 @@ If a referenced custom evaluator is not registered, the policy is denied with th List and discovery flows automatically filter entries based on the caller's authorities via the built-in `filterByAuthorities` stage. When a client calls `tools/list`, `resources/list`, or `prompts/list`, entries the user is not authorized to access are silently removed from the results. -| Flow | Stage | Runs After | -| ---- | ----- | ---------- | -| `tools:list-tools` | `filterByAuthorities` | `findTools` | +| Flow | Stage | Runs After | +| -------------------------- | --------------------- | --------------- | +| `tools:list-tools` | `filterByAuthorities` | `findTools` | | `resources:list-resources` | `filterByAuthorities` | `findResources` | -| `prompts:list-prompts` | `filterByAuthorities` | `findPrompts` | +| `prompts:list-prompts` | `filterByAuthorities` | `findPrompts` | ```typescript // A viewer calling tools/list will only see tools they are authorized to use. @@ -684,11 +684,11 @@ This ensures AI agents only see tools they can actually call, preventing wasted Skills are filtered on **every** discovery surface so a caller never sees a skill they are not authorized to load: -| Surface | What is filtered | -| ------- | ---------------- | -| `skills/search`, `skills/list` (MCP) | Authority-gated skills the caller can't access are removed from results | -| `skill://index.json` + skill-path completions (SEP-2640) | Gated skills are excluded from the discovery index and autocomplete | -| `GET /skills`, `GET /skills?query=…` (HTTP) | Gated skills are hidden (see HTTP note below) | +| Surface | What is filtered | +| -------------------------------------------------------- | ----------------------------------------------------------------------- | +| `skills/search`, `skills/list` (MCP) | Authority-gated skills the caller can't access are removed from results | +| `skill://index.json` + skill-path completions (SEP-2640) | Gated skills are excluded from the discovery index and autocomplete | +| `GET /skills`, `GET /skills?query=…` (HTTP) | Gated skills are hidden (see HTTP note below) | ```typescript @Skill({ name: 'review-pr' }) // Visible to everyone @@ -709,14 +709,14 @@ Authority enforcement runs as **native flow stages**, not plugin hooks. This mea ### Flow Stages Reference -| Flow | Stage | Purpose | -| ---- | ----- | ------- | -| `tools:call-tool` | `checkEntryAuthorities` | Enforce before tool execution | -| `tools:list-tools` | `filterByAuthorities` | Filter unauthorized tools from discovery | -| `resources:read-resource` | `checkEntryAuthorities` | Enforce before resource read | -| `resources:list-resources` | `filterByAuthorities` | Filter unauthorized resources from discovery | -| `prompts:get-prompt` | `checkEntryAuthorities` | Enforce before prompt execution | -| `prompts:list-prompts` | `filterByAuthorities` | Filter unauthorized prompts from discovery | +| Flow | Stage | Purpose | +| -------------------------- | ----------------------- | -------------------------------------------- | +| `tools:call-tool` | `checkEntryAuthorities` | Enforce before tool execution | +| `tools:list-tools` | `filterByAuthorities` | Filter unauthorized tools from discovery | +| `resources:read-resource` | `checkEntryAuthorities` | Enforce before resource read | +| `resources:list-resources` | `filterByAuthorities` | Filter unauthorized resources from discovery | +| `prompts:get-prompt` | `checkEntryAuthorities` | Enforce before prompt execution | +| `prompts:list-prompts` | `filterByAuthorities` | Filter unauthorized prompts from discovery | Skills are enforced across multiple serving surfaces rather than a single flow stage (MCP custom-method handlers + SEP-2640 `skill://` resources + the HTTP Skills API), but the behaviour mirrors the table above: **deny** on direct load/read (`AuthorityDeniedError`, code `-32003`) and **filter** on discovery. See [Skill Discovery Filtering](#skill-discovery-filtering). @@ -821,14 +821,14 @@ When an authorities check fails at execution time (as opposed to list filtering) ### AuthorityDeniedError -| Property | Type | Value | -| ------------- | -------- | ----- | -| `mcpErrorCode` | `number` | `-32003` (FORBIDDEN) | -| `statusCode` | `number` | `403` | -| `code` | `string` | `AUTHORITY_DENIED` | -| `entryType` | `string` | `'Tool'`, `'Resource'`, `'Prompt'`, `'Skill'` | -| `entryName` | `string` | Name of the denied entry | -| `deniedBy` | `string` | Human-readable reason (e.g., `"roles.all: missing 'admin'"`) | +| Property | Type | Value | +| -------------- | -------- | ------------------------------------------------------------ | +| `mcpErrorCode` | `number` | `-32003` (FORBIDDEN) | +| `statusCode` | `number` | `403` | +| `code` | `string` | `AUTHORITY_DENIED` | +| `entryType` | `string` | `'Tool'`, `'Resource'`, `'Prompt'`, `'Skill'` | +| `entryName` | `string` | Name of the denied entry | +| `deniedBy` | `string` | Human-readable reason (e.g., `"roles.all: missing 'admin'"`) | ```typescript import { AuthorityDeniedError } from '@frontmcp/auth'; @@ -862,17 +862,17 @@ The error serializes to a standard JSON-RPC error for MCP transport: The `deniedBy` field provides actionable feedback: -| Pattern | Example | -| ------- | ------- | -| `roles.all: missing ''` | `roles.all: missing 'admin'` | -| `roles.any: user has none of '', ...` | `roles.any: user has none of 'admin', 'superadmin'` | -| `permissions.all: missing ''` | `permissions.all: missing 'users:delete'` | -| `permissions.any: user has none of '', ...` | `permissions.any: user has none of 'data:export'` | -| `attributes.match: '' expected ...` | `attributes.match: 'claims.dept' expected 'eng' but got 'sales'` | -| `attributes.conditions: '' failed '' check` | `attributes.conditions: 'claims.credits' failed 'gt' check against '0'` | -| `relationships: user '' is not '' of :` | `relationships: user 'u-123' is not 'owner' of document:doc-456` | -| `profile '' is not registered` | `profile 'admin' is not registered` | -| `custom evaluator '' is not registered` | `custom evaluator 'ipAllowList' is not registered` | +| Pattern | Example | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `roles.all: missing ''` | `roles.all: missing 'admin'` | +| `roles.any: user has none of '', ...` | `roles.any: user has none of 'admin', 'superadmin'` | +| `permissions.all: missing ''` | `permissions.all: missing 'users:delete'` | +| `permissions.any: user has none of '', ...` | `permissions.any: user has none of 'data:export'` | +| `attributes.match: '' expected ...` | `attributes.match: 'claims.dept' expected 'eng' but got 'sales'` | +| `attributes.conditions: '' failed '' check` | `attributes.conditions: 'claims.credits' failed 'gt' check against '0'` | +| `relationships: user '' is not '' of :` | `relationships: user 'u-123' is not 'owner' of document:doc-456` | +| `profile '' is not registered` | `profile 'admin' is not registered` | +| `custom evaluator '' is not registered` | `custom evaluator 'ipAllowList' is not registered` | ## Type-Safe Profiles diff --git a/docs/frontmcp/authentication/cimd.mdx b/docs/frontmcp/authentication/cimd.mdx index 930757066..e1fd9c18a 100644 --- a/docs/frontmcp/authentication/cimd.mdx +++ b/docs/frontmcp/authentication/cimd.mdx @@ -158,17 +158,17 @@ CIMD is configured through the `auth.cimd` option when creating a FrontMCP serve import { FrontMcp, App } from '@frontmcp/sdk'; @FrontMcp({ - info: { name: 'my-mcp-server', version: '1.0.0' }, - auth: { - mode: 'local', - cimd: { - enabled: true, - security: { - blockPrivateIPs: true, - allowedDomains: ['*.verified-clients.mcp'], - }, - }, - }, +info: { name: 'my-mcp-server', version: '1.0.0' }, +auth: { +mode: 'local', +cimd: { +enabled: true, +security: { +blockPrivateIPs: true, +allowedDomains: ['*.verified-clients.mcp'], +}, +}, +}, }) class MyServer {} @@ -207,12 +207,12 @@ const client = await connect({ import { FrontMcp } from '@frontmcp/sdk'; @FrontMcp({ - info: { name: 'my-mcp-server', version: '1.0.0' }, - auth: { - mode: 'local', - cimd: { - // Enable/disable CIMD support - enabled: true, +info: { name: 'my-mcp-server', version: '1.0.0' }, +auth: { +mode: 'local', +cimd: { +// Enable/disable CIMD support +enabled: true, // Cache settings cache: { @@ -240,7 +240,8 @@ import { FrontMcp } from '@frontmcp/sdk'; maxRedirects: 5, }, }, - }, + +}, }) class MyServer {} @@ -272,32 +273,32 @@ CIMD is **enabled by default** when using orchestrated auth mode. The framework ### Security Settings -| Option | Default | Description | -| -------------------------- | ----------- | ----------------------------------------------------- | -| `blockPrivateIPs` | `true` | Block private/internal IP addresses (SSRF protection) | -| `allowedDomains` | `undefined` | If set, only these domains can host CIMD documents | -| `blockedDomains` | `undefined` | These domains cannot host CIMD documents | -| `warnOnLocalhostRedirects` | `true` | Log warning for localhost-only redirect URIs | +| Option | Default | Description | +| -------------------------- | ----------- | -------------------------------------------------------------------------------- | +| `blockPrivateIPs` | `true` | Block private/internal IP addresses (SSRF protection) | +| `allowedDomains` | `undefined` | If set, only these domains can host CIMD documents | +| `blockedDomains` | `undefined` | These domains cannot host CIMD documents | +| `warnOnLocalhostRedirects` | `true` | Log warning for localhost-only redirect URIs | | `allowInsecureForTesting` | `false` | **Test only.** Permit `http://` (non-TLS) CIMD URLs. Never enable in production. | ### Cache Settings -| Option | Default | Description | -| -------------- | --------------------- | --------------------------------------------------------------------- | -| `type` | `'memory'` | Cache backend: `'memory'` (dev/single-instance) or `'redis'` (distributed) | +| Option | Default | Description | +| -------------- | --------------------- | ---------------------------------------------------------------------------------------------- | +| `type` | `'memory'` | Cache backend: `'memory'` (dev/single-instance) or `'redis'` (distributed) | | `redis` | `undefined` | Redis connection (e.g. `{ provider: 'redis', host, port }`). **Required** when `type: 'redis'` | -| `defaultTtlMs` | `3600000` (1 hour) | Default cache TTL when no headers present | -| `maxTtlMs` | `86400000` (24 hours) | Maximum TTL even if server suggests longer | -| `minTtlMs` | `60000` (1 minute) | Minimum TTL even if server suggests shorter | +| `defaultTtlMs` | `3600000` (1 hour) | Default cache TTL when no headers present | +| `maxTtlMs` | `86400000` (24 hours) | Maximum TTL even if server suggests longer | +| `minTtlMs` | `60000` (1 minute) | Minimum TTL even if server suggests shorter | ### Network Settings -| Option | Default | Description | -| ---------------------- | -------------- | --------------------------------------------------------------------------- | -| `timeoutMs` | `5000` (5s) | Request timeout | -| `maxResponseSizeBytes` | `65536` (64KB) | Maximum response body size | +| Option | Default | Description | +| ---------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `timeoutMs` | `5000` (5s) | Request timeout | +| `maxResponseSizeBytes` | `65536` (64KB) | Maximum response body size | | `redirectPolicy` | `'deny'` | How to handle redirects when fetching the CIMD document: `'deny'` (reject any redirect), `'same-origin'` (only same-origin), or `'allow'` (follow up to `maxRedirects`) | -| `maxRedirects` | `5` | Maximum redirects to follow (only relevant when `redirectPolicy` allows redirects) | +| `maxRedirects` | `5` | Maximum redirects to follow (only relevant when `redirectPolicy` allows redirects) | ## Client Metadata Document Format diff --git a/docs/frontmcp/authentication/custom-ui.mdx b/docs/frontmcp/authentication/custom-ui.mdx index d82014bd6..b9b6f4016 100644 --- a/docs/frontmcp/authentication/custom-ui.mdx +++ b/docs/frontmcp/authentication/custom-ui.mdx @@ -17,10 +17,10 @@ FrontMCP's `local` and `remote` OAuth modes serve built-in HTML pages for the lo There are two halves: -| Half | Package | Responsibility | -| ---------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------ | -| **Server** | `@frontmcp/sdk` (`auth.ui` / `auth.extras`) | Transpiles your component's `.tsx` once (server-side, single-file transform — deps stay external), inlines it as an ES module + an import-map, injects the flow state, mints/verifies CSRF, sets CSP. It never bundles or renders your component server-side. | -| **Client** | `@frontmcp/ui/auth` | The framework-free contract (`AuthFlowState`, wire constants) **plus** the React hooks (`useAuthFlow`, …), ``, and `mountAuthPage` that read the injected state, render the component in the browser, and drive submits. Loaded in the browser **from esm.sh** via the import-map. | +| Half | Package | Responsibility | +| ---------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Server** | `@frontmcp/sdk` (`auth.ui` / `auth.extras`) | Transpiles your component's `.tsx` once (server-side, single-file transform — deps stay external), inlines it as an ES module + an import-map, injects the flow state, mints/verifies CSRF, sets CSP. It never bundles or renders your component server-side. | +| **Client** | `@frontmcp/ui/auth` | The framework-free contract (`AuthFlowState`, wire constants) **plus** the React hooks (`useAuthFlow`, …), ``, and `mountAuthPage` that read the injected state, render the component in the browser, and drive submits. Loaded in the browser **from esm.sh** via the import-map. | At request time the server serializes an `AuthFlowState` into `window.__FRONTMCP_AUTH__`, serves a page with an **empty** `#frontmcp-auth-root` mount node, an **``) | +| Helper | Description | +| ------------------------------ | ---------------------------------------------------------------- | +| `escapeHtml(str)` | Escape HTML entities to prevent XSS (handles `null`/`undefined`) | +| `formatDate(date, format?)` | Format a date (accepts `Date` or ISO string) | +| `formatCurrency(amount, ccy?)` | ISO-4217 currency formatting (defaults to `'USD'`) | +| `uniqueId(prefix?)` | Generate a unique ID for DOM elements | +| `jsonEmbed(data)` | Safely embed JSON in an inline ``) | Always use `helpers.escapeHtml()` when rendering user-provided data to prevent XSS vulnerabilities. diff --git a/docs/frontmcp/guides/observability.mdx b/docs/frontmcp/guides/observability.mdx index 1f9c162e0..abb127e69 100644 --- a/docs/frontmcp/guides/observability.mdx +++ b/docs/frontmcp/guides/observability.mdx @@ -66,6 +66,7 @@ observability: { requestLogs: true, } ``` + --- @@ -88,6 +89,7 @@ The `@FrontMcp({ ... })` snippets below show only the `observability` block for exporter: 'console', }); ``` + @@ -110,6 +112,7 @@ The `@FrontMcp({ ... })` snippets below show only the `observability` block for OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ node server.js ``` + @@ -182,6 +185,7 @@ The `@FrontMcp({ ... })` snippets below show only the `observability` block for }, }) ``` + @@ -201,6 +205,7 @@ The `@FrontMcp({ ... })` snippets below show only the `observability` block for }, }) ``` + @@ -375,38 +380,38 @@ HTTP Server Span: "POST /mcp" ### MCP Protocol Attributes (interoperable) -| Attribute | Example | Description | -| --- | --- | --- | -| `mcp.method.name` | `tools/call` | MCP protocol method | -| `mcp.session.id` | `a3f8b2c1d4e5f6a7` | Hashed session ID | -| `mcp.resource.uri` | `file:///data.txt` | Resource URI | -| `mcp.component.type` | `tool` | Component type: tool, resource, prompt, agent | -| `mcp.component.key` | `tool:get_weather` | Fully qualified component key | +| Attribute | Example | Description | +| -------------------- | ------------------ | --------------------------------------------- | +| `mcp.method.name` | `tools/call` | MCP protocol method | +| `mcp.session.id` | `a3f8b2c1d4e5f6a7` | Hashed session ID | +| `mcp.resource.uri` | `file:///data.txt` | Resource URI | +| `mcp.component.type` | `tool` | Component type: tool, resource, prompt, agent | +| `mcp.component.key` | `tool:get_weather` | Fully qualified component key | ### Standard OTel Attributes -| Attribute | Example | Description | -| --- | --- | --- | -| `rpc.system` | `mcp` | RPC system identifier | -| `rpc.service` | `my-server` | Server name | -| `rpc.method` | `tools/call` | RPC method | -| `http.request.method` | `POST` | HTTP method | -| `http.response.status_code` | `200` | HTTP status | -| `enduser.id` | `client-42` | Client ID from auth token | -| `enduser.scope` | `read write admin` | OAuth scopes | +| Attribute | Example | Description | +| --------------------------- | ------------------ | ------------------------- | +| `rpc.system` | `mcp` | RPC system identifier | +| `rpc.service` | `my-server` | Server name | +| `rpc.method` | `tools/call` | RPC method | +| `http.request.method` | `POST` | HTTP method | +| `http.response.status_code` | `200` | HTTP status | +| `enduser.id` | `client-42` | Client ID from auth token | +| `enduser.scope` | `read write admin` | OAuth scopes | ### FrontMCP Vendor Attributes -| Attribute | Description | -| --- | --- | -| `frontmcp.scope.id` | Scope identifier | -| `frontmcp.request.id` | Unique request ID | -| `frontmcp.tool.name` | Tool name | -| `frontmcp.tool.owner` | Tool owner class | -| `frontmcp.flow.name` | Flow name (e.g., `tools:call-tool`) | -| `frontmcp.transport.type` | Transport: `legacy-sse`, `streamable-http` | -| `frontmcp.auth.mode` | Auth mode: `public`, `transparent`, `orchestrated` | -| `frontmcp.session.id_hash` | Privacy-safe session hash | +| Attribute | Description | +| -------------------------- | -------------------------------------------------- | +| `frontmcp.scope.id` | Scope identifier | +| `frontmcp.request.id` | Unique request ID | +| `frontmcp.tool.name` | Tool name | +| `frontmcp.tool.owner` | Tool owner class | +| `frontmcp.flow.name` | Flow name (e.g., `tools:call-tool`) | +| `frontmcp.transport.type` | Transport: `legacy-sse`, `streamable-http` | +| `frontmcp.auth.mode` | Auth mode: `public`, `transparent`, `orchestrated` | +| `frontmcp.session.id_hash` | Privacy-safe session hash | --- diff --git a/docs/frontmcp/guides/publishing-esm-packages.mdx b/docs/frontmcp/guides/publishing-esm-packages.mdx index 992e3614a..63863679c 100644 --- a/docs/frontmcp/guides/publishing-esm-packages.mdx +++ b/docs/frontmcp/guides/publishing-esm-packages.mdx @@ -99,18 +99,19 @@ export class EchoTool extends ToolContext { } } ``` + ### Plain Object Contract When using plain objects, each tool must have: -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | `string` | Yes | Tool name | -| `description` | `string` | No | Human-readable description | -| `inputSchema` | `object` | No | JSON Schema for input validation | -| `execute` | `function` | Yes | `(input) => Promise` | +| Field | Type | Required | Description | +| ------------- | ---------- | -------- | ------------------------------------ | +| `name` | `string` | Yes | Tool name | +| `description` | `string` | No | Human-readable description | +| `inputSchema` | `object` | No | JSON Schema for input validation | +| `execute` | `function` | Yes | `(input) => Promise` | The `execute` function receives the parsed input and must return a `CallToolResult` with a `content` array. @@ -152,26 +153,27 @@ export const greetingPrompt = { }), }; ``` + ### Resource Contract -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | `string` | Yes | Resource name | -| `description` | `string` | No | Human-readable description | -| `uri` | `string` | Yes | Resource URI (e.g., `my-tools://status`) | -| `mimeType` | `string` | No | MIME type of the resource content | -| `read` | `function` | Yes | `() => Promise` | +| Field | Type | Required | Description | +| ------------- | ---------- | -------- | ---------------------------------------- | +| `name` | `string` | Yes | Resource name | +| `description` | `string` | No | Human-readable description | +| `uri` | `string` | Yes | Resource URI (e.g., `my-tools://status`) | +| `mimeType` | `string` | No | MIME type of the resource content | +| `read` | `function` | Yes | `() => Promise` | ### Prompt Contract -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | `string` | Yes | Prompt name | -| `description` | `string` | No | Human-readable description | -| `arguments` | `array` | No | Array of `{ name, description?, required? }` | -| `execute` | `function` | Yes | `(args) => Promise` | +| Field | Type | Required | Description | +| ------------- | ---------- | -------- | -------------------------------------------- | +| `name` | `string` | Yes | Prompt name | +| `description` | `string` | No | Human-readable description | +| `arguments` | `array` | No | Array of `{ name, description?, required? }` | +| `execute` | `function` | Yes | `(args) => Promise` | --- @@ -214,6 +216,7 @@ export const version = '1.0.0'; export { echoTool, addTool } from './tools'; export const tools = [echoTool, addTool]; ``` + @@ -308,6 +311,7 @@ FrontMCP's `normalizeEsmExport()` function tries three paths in order: 3. **Named exports** — Scans the module for named exports matching manifest primitive keys (`tools`, `prompts`, `resources`, etc.) and assembles them into a manifest. If none of these paths produce a valid manifest, an `EsmManifestInvalidError` is thrown. + --- diff --git a/docs/frontmcp/guides/rate-limiting-and-guards.mdx b/docs/frontmcp/guides/rate-limiting-and-guards.mdx index 058e56727..0cbc05023 100644 --- a/docs/frontmcp/guides/rate-limiting-and-guards.mdx +++ b/docs/frontmcp/guides/rate-limiting-and-guards.mdx @@ -15,6 +15,7 @@ This guide walks through adding production-grade traffic controls to your FrontM ## What You'll Build By the end of this guide, your server will have: + - Per-user rate limiting on tools - Concurrency control to prevent resource exhaustion - Execution timeouts to catch hanging requests @@ -51,6 +52,7 @@ By the end of this guide, your server will have: ``` This limits each user to 30 search requests per minute. + @@ -70,6 +72,7 @@ By the end of this guide, your server will have: Setting `throttle.enabled: true` is required. Without it, rate limit decorators on tools are ignored. + @@ -81,6 +84,7 @@ By the end of this guide, your server will have: "message": "Rate limit exceeded. Retry after 12 seconds." } ``` + @@ -110,6 +114,7 @@ Prevent expensive tools from running too many instances simultaneously. ``` This allows at most 2 report generations at once. Additional requests wait up to 15 seconds for a slot. + @@ -123,6 +128,7 @@ Prevent expensive tools from running too many instances simultaneously. ```typescript concurrency: { maxConcurrent: 1 } ``` + @@ -149,6 +155,7 @@ Protect against hanging requests by setting a maximum execution time. ``` If execution takes longer than 30 seconds, it throws `ExecutionTimeoutError` (408). + @@ -167,6 +174,7 @@ Protect against hanging requests by setting a maximum execution time. ``` Tools with their own `timeout` override the default. Tools without `timeout` use the app default. + @@ -198,6 +206,7 @@ Add a server-wide rate limit that applies to all requests, regardless of which t ``` Global limits are checked **before** per-tool limits. Both must pass for a request to proceed. + @@ -213,6 +222,7 @@ Add a server-wide rate limit that applies to all requests, regardless of which t ``` Even if the global limit allows 500 requests/min per IP, this tool is limited to 5 requests/min per user. + @@ -258,6 +268,7 @@ Block malicious IPs and restrict access to known networks. 3. IP on neither list → `defaultAction` applies (`'allow'` or `'deny'`) With `defaultAction: 'deny'`, only IPs explicitly on the allow list can access your server. + @@ -270,6 +281,7 @@ Block malicious IPs and restrict access to known networks. // ... } ``` + @@ -305,6 +317,7 @@ In-memory storage works for development but does not persist across restarts or ``` All rate limit counters and semaphore tickets are stored in Redis, shared across all server instances. + @@ -322,6 +335,7 @@ In-memory storage works for development but does not persist across restarts or token: process.env.KV_REST_API_TOKEN, }, ``` + diff --git a/docs/frontmcp/guides/your-first-channel.mdx b/docs/frontmcp/guides/your-first-channel.mdx index 760f4f417..674fc1487 100644 --- a/docs/frontmcp/guides/your-first-channel.mdx +++ b/docs/frontmcp/guides/your-first-channel.mdx @@ -48,13 +48,13 @@ export class DeployChannel extends ChannelContext { Key concepts: -| Part | Purpose | -| --- | --- | -| `@Channel()` | Declares metadata — name, source, static meta | -| `ChannelContext` | Base class providing logger, DI, and lifecycle hooks | -| `onEvent()` | **Inbound handler** — receives external events, returns a notification for Claude | -| `source: { type: 'webhook', path: '...' }` | Registers an HTTP POST endpoint | -| `meta` | Static key-value pairs added to every notification | +| Part | Purpose | +| ------------------------------------------ | --------------------------------------------------------------------------------- | +| `@Channel()` | Declares metadata — name, source, static meta | +| `ChannelContext` | Base class providing logger, DI, and lifecycle hooks | +| `onEvent()` | **Inbound handler** — receives external events, returns a notification for Claude | +| `source: { type: 'webhook', path: '...' }` | Registers an HTTP POST endpoint | +| `meta` | Static key-value pairs added to every notification | --- diff --git a/docs/frontmcp/nx-plugin/executors/build-exec.mdx b/docs/frontmcp/nx-plugin/executors/build-exec.mdx index 9788a49cb..7ca5204ab 100644 --- a/docs/frontmcp/nx-plugin/executors/build-exec.mdx +++ b/docs/frontmcp/nx-plugin/executors/build-exec.mdx @@ -33,17 +33,17 @@ nx build-exec my-app ## Options -| Option | Type | Description | -| ------------ | --------- | -------------------------- | -| `entry` | `string` | Entry file path | -| `outputPath` | `string` | Output directory path | +| Option | Type | Description | +| ------------ | -------- | --------------------- | +| `entry` | `string` | Entry file path | +| `outputPath` | `string` | Output directory path | ## Output Files -| Output | Description | -| ----------------------- | -------------------------------- | -| `dist/{name}-server.cjs`| Single-file MCP server bundle | -| `dist/{name}-runner.sh` | Shell runner with env/port setup | +| Output | Description | +| ------------------------ | -------------------------------- | +| `dist/{name}-server.cjs` | Single-file MCP server bundle | +| `dist/{name}-runner.sh` | Shell runner with env/port setup | ## Caching diff --git a/docs/frontmcp/nx-plugin/executors/deploy.mdx b/docs/frontmcp/nx-plugin/executors/deploy.mdx index 8756f2aeb..da73c29a7 100644 --- a/docs/frontmcp/nx-plugin/executors/deploy.mdx +++ b/docs/frontmcp/nx-plugin/executors/deploy.mdx @@ -38,12 +38,12 @@ nx deploy production ## Platform Details -| Target | What Happens | -| ------------ | ---------------------------------------------------- | -| `node` | Builds the Docker image and runs `docker compose up --build -d` | -| `vercel` | Runs `npx vercel --prod` using `vercel.json` config | +| Target | What Happens | +| ------------ | ----------------------------------------------------------------- | +| `node` | Builds the Docker image and runs `docker compose up --build -d` | +| `vercel` | Runs `npx vercel --prod` using `vercel.json` config | | `lambda` | Runs `sam build && sam deploy` using `template.yaml` SAM template | -| `cloudflare` | Runs `npx wrangler deploy` using `wrangler.toml` config | +| `cloudflare` | Runs `npx wrangler deploy` using `wrangler.toml` config | ## Workflow diff --git a/docs/frontmcp/nx-plugin/executors/overview.mdx b/docs/frontmcp/nx-plugin/executors/overview.mdx index c72049ac4..202d9178d 100644 --- a/docs/frontmcp/nx-plugin/executors/overview.mdx +++ b/docs/frontmcp/nx-plugin/executors/overview.mdx @@ -10,15 +10,15 @@ Executors wrap FrontMCP CLI commands as Nx targets, enabling caching, dependency ## Summary -| Executor | CLI Command | Cacheable | Long-Running | -| -------------------------------------------------------- | ----------------------- | --------- | ------------ | -| [`build`](/frontmcp/nx-plugin/executors/build) | `frontmcp build` | Yes | No | +| Executor | CLI Command | Cacheable | Long-Running | +| -------------------------------------------------------- | ------------------------------ | --------- | ------------ | +| [`build`](/frontmcp/nx-plugin/executors/build) | `frontmcp build` | Yes | No | | [`build-exec`](/frontmcp/nx-plugin/executors/build-exec) | `frontmcp build --target node` | Yes | No | -| [`dev`](/frontmcp/nx-plugin/executors/dev) | `frontmcp dev` | No | Yes | -| [`serve`](/frontmcp/nx-plugin/executors/serve) | `frontmcp start` | No | Yes | -| [`test`](/frontmcp/nx-plugin/executors/test) | `frontmcp test` | Yes | No | -| [`inspector`](/frontmcp/nx-plugin/executors/inspector) | `frontmcp inspector` | No | Yes | -| [`deploy`](/frontmcp/nx-plugin/executors/deploy) | Platform-specific | No | No | +| [`dev`](/frontmcp/nx-plugin/executors/dev) | `frontmcp dev` | No | Yes | +| [`serve`](/frontmcp/nx-plugin/executors/serve) | `frontmcp start` | No | Yes | +| [`test`](/frontmcp/nx-plugin/executors/test) | `frontmcp test` | Yes | No | +| [`inspector`](/frontmcp/nx-plugin/executors/inspector) | `frontmcp inspector` | No | Yes | +| [`deploy`](/frontmcp/nx-plugin/executors/deploy) | Platform-specific | No | No | ## Usage in project.json diff --git a/docs/frontmcp/nx-plugin/generators/lib.mdx b/docs/frontmcp/nx-plugin/generators/lib.mdx index cfbaf80b5..de8d89ebd 100644 --- a/docs/frontmcp/nx-plugin/generators/lib.mdx +++ b/docs/frontmcp/nx-plugin/generators/lib.mdx @@ -16,14 +16,14 @@ nx g @frontmcp/nx:lib my-lib ## Options -| Option | Type | Default | Description | -| ------------- | ----------------------------------------------------- | ------------- | ---------------------------------------------------- | -| `name` | `string` | — | **Required.** The name of the library | -| `directory` | `string` | `libs/` | The directory of the library | -| `libType` | `generic` \| `plugin` \| `adapter` \| `tool-register` | `generic` | The type of library to generate | -| `publishable` | `boolean` | `false` | Generate a publishable library with package.json | +| Option | Type | Default | Description | +| ------------- | ----------------------------------------------------- | ------------------ | ---------------------------------------------------- | +| `name` | `string` | — | **Required.** The name of the library | +| `directory` | `string` | `libs/` | The directory of the library | +| `libType` | `generic` \| `plugin` \| `adapter` \| `tool-register` | `generic` | The type of library to generate | +| `publishable` | `boolean` | `false` | Generate a publishable library with package.json | | `importPath` | `string` | `@frontmcp/` | The npm scope/import path (required for publishable) | -| `tags` | `string` | — | Comma-separated tags for the project | +| `tags` | `string` | — | Comma-separated tags for the project | ## Library Types diff --git a/docs/frontmcp/nx-plugin/generators/skill-dir.mdx b/docs/frontmcp/nx-plugin/generators/skill-dir.mdx index 60ed827c1..f8d53d309 100644 --- a/docs/frontmcp/nx-plugin/generators/skill-dir.mdx +++ b/docs/frontmcp/nx-plugin/generators/skill-dir.mdx @@ -19,14 +19,14 @@ nx g @frontmcp/nx:skill-dir code-review --project crm --description "Walk a revi ## Options -| Option | Type | Default | Description | -| ---------------- | --------- | ------------ | ---------------------------------------------- | -| `name` | `string` | — | **Required.** Skill name (kebab-case) | -| `project` | `string` | — | **Required.** The project to add the skill to | -| `description` | `string` | — | **Required.** Short description of the skill | -| `directory` | `string` | `skills` | Custom directory relative to the project root | -| `tags` | `string` | — | Comma-separated tags for categorization | -| `withReferences` | `boolean` | `false` | Include a `references/` directory | +| Option | Type | Default | Description | +| ---------------- | --------- | -------- | --------------------------------------------- | +| `name` | `string` | — | **Required.** Skill name (kebab-case) | +| `project` | `string` | — | **Required.** The project to add the skill to | +| `description` | `string` | — | **Required.** Short description of the skill | +| `directory` | `string` | `skills` | Custom directory relative to the project root | +| `tags` | `string` | — | Comma-separated tags for categorization | +| `withReferences` | `boolean` | `false` | Include a `references/` directory | ## Generated Files diff --git a/docs/frontmcp/nx-plugin/generators/workspace.mdx b/docs/frontmcp/nx-plugin/generators/workspace.mdx index 139088b05..ce94b7b03 100644 --- a/docs/frontmcp/nx-plugin/generators/workspace.mdx +++ b/docs/frontmcp/nx-plugin/generators/workspace.mdx @@ -22,13 +22,13 @@ npx frontmcp create my-platform --nx ## Options -| Option | Type | Default | Description | -| ----------------- | ------------------------- | ------- | --------------------------------------- | -| `name` | `string` | — | **Required.** The name of the workspace | -| `packageManager` | `npm` \| `yarn` \| `pnpm` \| `bun` | `npm` | Package manager to use | -| `skipInstall` | `boolean` | `false` | Skip package installation | -| `skipGit` | `boolean` | `false` | Skip git initialization. When `false`, runs `git init` and creates an initial commit. | -| `createSampleApp` | `boolean` | `true` | Create a sample demo application | +| Option | Type | Default | Description | +| ----------------- | ---------------------------------- | ------- | ------------------------------------------------------------------------------------- | +| `name` | `string` | — | **Required.** The name of the workspace | +| `packageManager` | `npm` \| `yarn` \| `pnpm` \| `bun` | `npm` | Package manager to use | +| `skipInstall` | `boolean` | `false` | Skip package installation | +| `skipGit` | `boolean` | `false` | Skip git initialization. When `false`, runs `git init` and creates an initial commit. | +| `createSampleApp` | `boolean` | `true` | Create a sample demo application | ## Generated Files diff --git a/docs/frontmcp/nx-plugin/overview.mdx b/docs/frontmcp/nx-plugin/overview.mdx index 68df2fd9a..763f2e3db 100644 --- a/docs/frontmcp/nx-plugin/overview.mdx +++ b/docs/frontmcp/nx-plugin/overview.mdx @@ -57,23 +57,23 @@ graph TD ### Generators (20) -| Category | Generators | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| **Structural** | `workspace`, `app`, `lib`, `server` | +| Category | Generators | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **Structural** | `workspace`, `app`, `lib`, `server` | | **Component** | `tool`, `resource`, `prompt`, `skill`, `skill-dir`, `agent`, `provider`, `plugin`, `adapter`, `auth-provider`, `flow`, `job`, `workflow` | -| **UI** | `ui-component`, `ui-page`, `ui-shell` | +| **UI** | `ui-component`, `ui-page`, `ui-shell` | ### Executors (7) -| Executor | Wraps | Cacheable | -| ------------ | ----------------------- | ----------------- | -| `build` | `frontmcp build` | Yes | +| Executor | Wraps | Cacheable | +| ------------ | ------------------------------ | ----------------- | +| `build` | `frontmcp build` | Yes | | `build-exec` | `frontmcp build --target node` | Yes | -| `dev` | `frontmcp dev` | No (long-running) | -| `serve` | `frontmcp start` | No (long-running) | -| `test` | `frontmcp test` | Yes | -| `inspector` | `frontmcp inspector` | No (long-running) | -| `deploy` | Platform-specific | No | +| `dev` | `frontmcp dev` | No (long-running) | +| `serve` | `frontmcp start` | No (long-running) | +| `test` | `frontmcp test` | Yes | +| `inspector` | `frontmcp inspector` | No (long-running) | +| `deploy` | Platform-specific | No | ## Next Steps diff --git a/docs/frontmcp/plugins/codecall/configuration.mdx b/docs/frontmcp/plugins/codecall/configuration.mdx index 35952ea17..a6357bb31 100644 --- a/docs/frontmcp/plugins/codecall/configuration.mdx +++ b/docs/frontmcp/plugins/codecall/configuration.mdx @@ -252,12 +252,12 @@ CodeCallPlugin.init({ ### VM Presets -| Preset | Timeout | Max Steps | Console | Use Case | -| -------------- | ------- | -------------- | ------- | ---------------------------- | -| `locked_down` | 2s | 2,000 | No | Ultra-sensitive environments | -| `secure` | 3.5s | 5,000 | Yes | **Production default** | -| `balanced` | 5s | 10,000 | Yes | Complex workflows | -| `experimental` | 10s | 20,000 | Yes | Development only | +| Preset | Timeout | Max Steps | Console | Use Case | +| -------------- | ------- | --------- | ------- | ---------------------------- | +| `locked_down` | 2s | 2,000 | No | Ultra-sensitive environments | +| `secure` | 3.5s | 5,000 | Yes | **Production default** | +| `balanced` | 5s | 10,000 | Yes | Complex workflows | +| `experimental` | 10s | 20,000 | Yes | Development only | ### Preset Details diff --git a/docs/frontmcp/plugins/codecall/security.mdx b/docs/frontmcp/plugins/codecall/security.mdx index 0f09b7657..0976ea8df 100644 --- a/docs/frontmcp/plugins/codecall/security.mdx +++ b/docs/frontmcp/plugins/codecall/security.mdx @@ -231,16 +231,16 @@ CodeCall uses the **AgentScript preset** - the most restrictive preset designed The AgentScript preset enforces these rules: -| Rule | Setting | Rationale | -| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | -| **Allowed globals** | `callTool`, `getTool`, `codecallContext`, `Math`, `JSON`, `Array`, `Object`, `String`, `Number`, `Date`, `console` (optional) | Whitelist-only access to safe built-ins | +| Rule | Setting | Rationale | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| **Allowed globals** | `callTool`, `getTool`, `codecallContext`, `Math`, `JSON`, `Array`, `Object`, `String`, `Number`, `Date`, `console` (optional) | Whitelist-only access to safe built-ins | | **`for` loops** | Allowed | Bounded iteration with `maxSteps` enforcement | | **`for-of` loops** | Allowed | Bounded by array length + `maxSteps` | -| **`while` loops** | Blocked | Unbounded — risk of infinite loops | -| **`do-while` loops** | Blocked | Unbounded — risk of infinite loops | -| **`for-in` loops** | Blocked | Walks prototype chain — security risk | -| **Arrow functions** | Allowed | No recursion risk (anonymous) | -| **Function declarations** | Blocked | Enables recursion and hoisting tricks | +| **`while` loops** | Blocked | Unbounded — risk of infinite loops | +| **`do-while` loops** | Blocked | Unbounded — risk of infinite loops | +| **`for-in` loops** | Blocked | Walks prototype chain — security risk | +| **Arrow functions** | Allowed | No recursion risk (anonymous) | +| **Function declarations** | Blocked | Enables recursion and hoisting tricks | ### What's Allowed @@ -284,7 +284,7 @@ After AST validation passes, code is transformed for safe execution: | ------------------ | ------------------------------------ | ---------------------- | | Top-level code | `async function __ag_main() { ... }` | Enable top-level await | | `callTool(...)` | `__safe_callTool(...)` | Proxy through Enclave | -| `for (...)` | Iteration-limited version | Enforce maxSteps | +| `for (...)` | Iteration-limited version | Enforce maxSteps | | `console.log(...)` | `__safe_console.log(...)` | Capture for logging | ### Example @@ -348,7 +348,7 @@ let __safe_bypass = 123; | Limit | Default | Purpose | | ----------------------- | ------- | --------------------------------------------- | | `timeoutMs` | 3,500ms | Maximum execution time | -| `maxSteps` | 5,000 | Maximum loop iterations | +| `maxSteps` | 5,000 | Maximum loop iterations | | `maxToolCalls` | 100 | Maximum tool invocations | | `maxConsoleOutputBytes` | 64KB | Maximum console output (I/O flood protection) | | `maxConsoleCalls` | 100 | Maximum console calls (I/O flood protection) | diff --git a/docs/frontmcp/plugins/overview.mdx b/docs/frontmcp/plugins/overview.mdx index ca42189ba..3e8c9c8d1 100644 --- a/docs/frontmcp/plugins/overview.mdx +++ b/docs/frontmcp/plugins/overview.mdx @@ -147,13 +147,13 @@ Custom plugins are documented in two pages: Choose the right plugin for your use case: -| Plugin | Best For | Overhead | Scope | Complexity | -| -------------------- | ------------------------------------------ | -------- | ------------ | ----------- | -| **Remember** | Session memory, approvals | Low | Per-session | Low | -| **CodeCall** | Large toolsets, workflows | Low | Cross-app | Medium | -| **Cache** | Expensive operations | Minimal | Per-tool | Low | -| **Skilled OpenAPI** | Customer REST APIs as curated MCP skills | Medium | Per-bundle | Medium | -| **Custom** | Any cross-cutting feature | Varies | Configurable | Medium-High | +| Plugin | Best For | Overhead | Scope | Complexity | +| ------------------- | ---------------------------------------- | -------- | ------------ | ----------- | +| **Remember** | Session memory, approvals | Low | Per-session | Low | +| **CodeCall** | Large toolsets, workflows | Low | Cross-app | Medium | +| **Cache** | Expensive operations | Minimal | Per-tool | Low | +| **Skilled OpenAPI** | Customer REST APIs as curated MCP skills | Medium | Per-bundle | Medium | +| **Custom** | Any cross-cutting feature | Varies | Configurable | Medium-High | ## Next Steps diff --git a/docs/frontmcp/plugins/skilled-openapi/api-reference.mdx b/docs/frontmcp/plugins/skilled-openapi/api-reference.mdx index d7d9b9602..bad07d965 100644 --- a/docs/frontmcp/plugins/skilled-openapi/api-reference.mdx +++ b/docs/frontmcp/plugins/skilled-openapi/api-reference.mdx @@ -151,4 +151,4 @@ await handle.unregister(); Added to `McpNotificationMethod`. The `NotificationService` subscribes to `scope.skills` and broadcasts on every global change event. -These changes are documented in the [skills feature page](/frontmcp/features/skills) and are usable independently of this plugin. +These changes are documented in the [skills feature page](/frontmcp/features/skill-based-workflows) and are usable independently of this plugin. diff --git a/docs/frontmcp/plugins/skilled-openapi/bundle-format.mdx b/docs/frontmcp/plugins/skilled-openapi/bundle-format.mdx index d8f361445..89481c0e5 100644 --- a/docs/frontmcp/plugins/skilled-openapi/bundle-format.mdx +++ b/docs/frontmcp/plugins/skilled-openapi/bundle-format.mdx @@ -154,13 +154,13 @@ One bundle can wrap several microservices natively — declare each service in ` ## What's not supported -| Feature | Status | -| --- | --- | -| Multipart / form-data request bodies | Not supported | -| Server-sent events / streaming responses | Not supported | -| WebSocket operations | Not supported | -| OpenAPI `callbacks` and `links` | Not supported | +| Feature | Status | +| ------------------------------------------------------------ | ----------------------------------------- | +| Multipart / form-data request bodies | Not supported | +| Server-sent events / streaming responses | Not supported | +| WebSocket operations | Not supported | +| OpenAPI `callbacks` and `links` | Not supported | | `oauth2` interactive flows (authorization_code, device_code) | Not supported (only `client_credentials`) | -| HTTP/2 push | Not supported | +| HTTP/2 push | Not supported | These are the same caveats the underlying `mcp-from-openapi` parser carries; bundles that include them will fail validation. diff --git a/docs/frontmcp/plugins/skilled-openapi/coexistence.mdx b/docs/frontmcp/plugins/skilled-openapi/coexistence.mdx index 55467f6e8..826386910 100644 --- a/docs/frontmcp/plugins/skilled-openapi/coexistence.mdx +++ b/docs/frontmcp/plugins/skilled-openapi/coexistence.mdx @@ -10,15 +10,15 @@ The [OpenAPI adapter](/frontmcp/adapters/openapi-adapter) and the Skilled OpenAP ## At a glance -| | OpenAPI Adapter | Skilled OpenAPI Plugin | -| --- | --- | --- | -| **Discovery unit** | One MCP tool per `operationId` | One MCP **skill** bundling many ops | -| **Visible to MCP client** | Every operation (in `tools/list`) | Three meta-tools only; per-op tools hidden | -| **When the spec changes** | Server redeploy | Bundle hot-swap (signed, atomic) | -| **Bundle origin trust** | None — spec is local | Signed bundles from CI / SaaS | -| **ABAC per operation** | Via `@FrontMcp({ authorities })` on the adapter-mounted tools | Native; `requiredAuthorities` on the bundle's operations | -| **Runtime cost** | `mcp-from-openapi` parser at boot | Pre-parsed bundle (parsing happens in your CI) | -| **Sweet spot** | 5–20 hand-curated endpoints | 50+ endpoints, multi-service, customer-facing | +| | OpenAPI Adapter | Skilled OpenAPI Plugin | +| ------------------------- | ------------------------------------------------------------- | -------------------------------------------------------- | +| **Discovery unit** | One MCP tool per `operationId` | One MCP **skill** bundling many ops | +| **Visible to MCP client** | Every operation (in `tools/list`) | Three meta-tools only; per-op tools hidden | +| **When the spec changes** | Server redeploy | Bundle hot-swap (signed, atomic) | +| **Bundle origin trust** | None — spec is local | Signed bundles from CI / SaaS | +| **ABAC per operation** | Via `@FrontMcp({ authorities })` on the adapter-mounted tools | Native; `requiredAuthorities` on the bundle's operations | +| **Runtime cost** | `mcp-from-openapi` parser at boot | Pre-parsed bundle (parsing happens in your CI) | +| **Sweet spot** | 5–20 hand-curated endpoints | 50+ endpoints, multi-service, customer-facing | ## When to use each diff --git a/docs/frontmcp/plugins/skilled-openapi/configuration.mdx b/docs/frontmcp/plugins/skilled-openapi/configuration.mdx index 0a6227dbc..4d011ee1d 100644 --- a/docs/frontmcp/plugins/skilled-openapi/configuration.mdx +++ b/docs/frontmcp/plugins/skilled-openapi/configuration.mdx @@ -26,22 +26,22 @@ SkilledOpenApiPlugin.init({ ## All options -| Option | Type | Default | Notes | -| --- | --- | --- | --- | -| `source` | `BundleSourceOptions` | (required) | One of `static`, `npm`, `saas` — see [Sources](/frontmcp/plugins/skilled-openapi/sources) | -| `requireSignature` | `boolean` | `true` | Bundle must carry a verifiable `integrity` envelope. Default safe; never set false in production | -| `trustedKeys` | `SignatureKey[]` | `[]` | Public keys for signature verification. At least one is mandatory when `requireSignature: true` | -| `dev` | `boolean` | `false` | Bypasses signature verification and allows http://. **Loud startup warning when true.** Never in production | -| `unprotectedOps` | `'allow'\|'deny'` | `'allow'` | How to treat an op with **no** `requiredAuthorities` (neither skill- nor op-level) on the execution surface (`run_workflow` + internal per-op tools). `'allow'` (default, back-compat): callable — origin trust comes from the signed bundle. **`'deny'` (recommended for production):** policy-less ops are blocked unless explicitly marked `public: true`, so a single missing policy line can't silently expose a protected op | -| `outbound.allowPrivateNetworks` | `boolean` | `false` | Bypass the **private-network** IP blocklist (RFC 1918 / loopback / CGNAT) for self-hosted on a private network. The **cloud-metadata / link-local denylist** (169.254.169.254, `fd00:ec2::254`, fe80::/10, …) is **always enforced** regardless of this flag | -| `outbound.allowHttp` | `boolean` | `false` | Allow `http://` upstreams (for local dev) | -| `outbound.maxConcurrencyPerHost` | `number` | `10` | Per-host concurrent outbound-request cap, **enforced** by an async semaphore (per runtime isolate). Excess calls queue until a slot frees | -| `outbound.defaultTimeoutMs` | `number` | `30_000` | Default per-op timeout. Override per-op via `OperationDescriptor.timeoutMs` | -| `outbound.defaultMaxResponseBytes` | `number` | `262_144` (256KB) | Default response size cap. Override per-op via `OperationDescriptor.maxResponseBytes` | -| `sourceConflictPolicy` | `'static-wins'\|'last-wins'\|'reject'` | `'static-wins'` | Policy for when multiple sources register the same `bundleId` | -| `bundleCacheDir` | `string` | `.frontmcp/skilled-openapi` | Where the SaaS source caches the last-good bundle | -| `credentials` | `Record` | `undefined` | In-memory credential map keyed by `vaultRef`. For dev / single-tenant only — production should override the `SkilledOpenApiCredentialResolver` provider with a libs/auth-vault-backed resolver | -| `exposeOperationsAsInternalTools` | `boolean` | `true` | Register each bundle operation as an internal SDK tool (visibility: `internal`) so other tools / agents / CodeCall scripts / jobs can compose with it via `this.callTool('.', args)`. Internal tools are excluded from `tools/list` and rejected for external `tools/call` requests. Disable for very large bundles where the additional registry pressure outweighs the composition convenience, or when the three meta-tools are sufficient | +| Option | Type | Default | Notes | +| ---------------------------------- | -------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `source` | `BundleSourceOptions` | (required) | One of `static`, `npm`, `saas` — see [Sources](/frontmcp/plugins/skilled-openapi/sources) | +| `requireSignature` | `boolean` | `true` | Bundle must carry a verifiable `integrity` envelope. Default safe; never set false in production | +| `trustedKeys` | `SignatureKey[]` | `[]` | Public keys for signature verification. At least one is mandatory when `requireSignature: true` | +| `dev` | `boolean` | `false` | Bypasses signature verification and allows http://. **Loud startup warning when true.** Never in production | +| `unprotectedOps` | `'allow'\|'deny'` | `'allow'` | How to treat an op with **no** `requiredAuthorities` (neither skill- nor op-level) on the execution surface (`run_workflow` + internal per-op tools). `'allow'` (default, back-compat): callable — origin trust comes from the signed bundle. **`'deny'` (recommended for production):** policy-less ops are blocked unless explicitly marked `public: true`, so a single missing policy line can't silently expose a protected op | +| `outbound.allowPrivateNetworks` | `boolean` | `false` | Bypass the **private-network** IP blocklist (RFC 1918 / loopback / CGNAT) for self-hosted on a private network. The **cloud-metadata / link-local denylist** (169.254.169.254, `fd00:ec2::254`, fe80::/10, …) is **always enforced** regardless of this flag | +| `outbound.allowHttp` | `boolean` | `false` | Allow `http://` upstreams (for local dev) | +| `outbound.maxConcurrencyPerHost` | `number` | `10` | Per-host concurrent outbound-request cap, **enforced** by an async semaphore (per runtime isolate). Excess calls queue until a slot frees | +| `outbound.defaultTimeoutMs` | `number` | `30_000` | Default per-op timeout. Override per-op via `OperationDescriptor.timeoutMs` | +| `outbound.defaultMaxResponseBytes` | `number` | `262_144` (256KB) | Default response size cap. Override per-op via `OperationDescriptor.maxResponseBytes` | +| `sourceConflictPolicy` | `'static-wins'\|'last-wins'\|'reject'` | `'static-wins'` | Policy for when multiple sources register the same `bundleId` | +| `bundleCacheDir` | `string` | `.frontmcp/skilled-openapi` | Where the SaaS source caches the last-good bundle | +| `credentials` | `Record` | `undefined` | In-memory credential map keyed by `vaultRef`. For dev / single-tenant only — production should override the `SkilledOpenApiCredentialResolver` provider with a libs/auth-vault-backed resolver | +| `exposeOperationsAsInternalTools` | `boolean` | `true` | Register each bundle operation as an internal SDK tool (visibility: `internal`) so other tools / agents / CodeCall scripts / jobs can compose with it via `this.callTool('.', args)`. Internal tools are excluded from `tools/list` and rejected for external `tools/call` requests. Disable for very large bundles where the additional registry pressure outweighs the composition convenience, or when the three meta-tools are sufficient | ## `SignatureKey` shape diff --git a/docs/frontmcp/plugins/skilled-openapi/meta-tools.mdx b/docs/frontmcp/plugins/skilled-openapi/meta-tools.mdx index 3d504cc2a..6121ae645 100644 --- a/docs/frontmcp/plugins/skilled-openapi/meta-tools.mdx +++ b/docs/frontmcp/plugins/skilled-openapi/meta-tools.mdx @@ -130,13 +130,13 @@ A `callTool` failure (auth, schema, network, SSRF) **throws inside the script**. The exact prompt depends on your agent harness, but these phrasings work well in practice (cribbed from the tool descriptions the plugin ships): -- For `search_skill`: *"Use this tool first to discover what skills exist for the user's request."* -- For `load_skill`: *"Call this tool once per skill you intend to use. The instructions field is markdown — read it carefully before invoking any action."* -- For `run_workflow`: *"This is the only way to invoke upstream operations. Write a short AgentScript program and call `await callTool(actionId, input)` for each loaded action — you can chain several calls in one round-trip. End with `return `. Each `callTool` auto-validates input and applies authority checks; an unauthorized or failing call throws inside your script. The result comes back as `{ success, value, error, stats }`."* +- For `search_skill`: _"Use this tool first to discover what skills exist for the user's request."_ +- For `load_skill`: _"Call this tool once per skill you intend to use. The instructions field is markdown — read it carefully before invoking any action."_ +- For `run_workflow`: _"This is the only way to invoke upstream operations. Write a short AgentScript program and call `await callTool(actionId, input)` for each loaded action — you can chain several calls in one round-trip. End with `return `. Each `callTool` auto-validates input and applies authority checks; an unauthorized or failing call throws inside your script. The result comes back as `{ success, value, error, stats }`."_ ## Skills-only mode -If your FrontMCP server is configured with `skills_only` mode (set via `?mode=skills_only` on the MCP transport URL), the meta-tools stay visible — they're the only way to use skills, and the plugin treats them as exempt from the skills-only filter. See [Skills](/frontmcp/features/skills) for the skills-only contract. +If your FrontMCP server is configured with `skills_only` mode (set via `?mode=skills_only` on the MCP transport URL), the meta-tools stay visible — they're the only way to use skills, and the plugin treats them as exempt from the skills-only filter. See [Skills](/frontmcp/features/skill-based-workflows) for the skills-only contract. ## What about hidden tools? diff --git a/docs/frontmcp/plugins/skilled-openapi/overview.mdx b/docs/frontmcp/plugins/skilled-openapi/overview.mdx index 7f9705c6d..07f3b7ea2 100644 --- a/docs/frontmcp/plugins/skilled-openapi/overview.mdx +++ b/docs/frontmcp/plugins/skilled-openapi/overview.mdx @@ -6,7 +6,7 @@ icon: layer-group keywords: ['frontmcp', 'mcp', 'model context protocol', 'plugin', 'plugins', 'extension', 'skilled openapi', 'openapi', 'skills', 'typescript'] --- -The **Skilled OpenAPI plugin** turns an existing REST API into a **skilled MCP server** without rewriting any controllers. A signed *skill bundle* (an OpenAPI spec plus an OpenAPI Overlay annotated with skill grouping) is consumed at runtime by the plugin, projected into the FrontMCP `SkillRegistry`, and exposed through three meta-tools — `search_skill`, `load_skill`, `run_workflow`. The per-operation tools stay hidden from `tools/list` so the LLM never sees a 150-endpoint dump. +The **Skilled OpenAPI plugin** turns an existing REST API into a **skilled MCP server** without rewriting any controllers. A signed _skill bundle_ (an OpenAPI spec plus an OpenAPI Overlay annotated with skill grouping) is consumed at runtime by the plugin, projected into the FrontMCP `SkillRegistry`, and exposed through three meta-tools — `search_skill`, `load_skill`, `run_workflow`. The per-operation tools stay hidden from `tools/list` so the LLM never sees a 150-endpoint dump. @@ -65,13 +65,13 @@ The MCP client only ever sees the three meta-tools plus whatever skills the acti ## When to use this plugin (and when not to) -| Scenario | Use this plugin | Use the [OpenAPI adapter](/frontmcp/adapters/openapi-adapter) instead | -| --- | --- | --- | -| 5–20 hand-curated endpoints, MCP-native server | | ✅ | -| 50+ endpoints, multi-service, customer-facing | ✅ | | -| Schema changes ship through CI, not redeploys | ✅ | | -| You need every operation visible in `tools/list` | | ✅ | -| You need ABAC per operation + signed-bundle origin trust | ✅ | | +| Scenario | Use this plugin | Use the [OpenAPI adapter](/frontmcp/adapters/openapi-adapter) instead | +| -------------------------------------------------------- | --------------- | --------------------------------------------------------------------- | +| 5–20 hand-curated endpoints, MCP-native server | | ✅ | +| 50+ endpoints, multi-service, customer-facing | ✅ | | +| Schema changes ship through CI, not redeploys | ✅ | | +| You need every operation visible in `tools/list` | | ✅ | +| You need ABAC per operation + signed-bundle origin trust | ✅ | | The two are designed to coexist in the same FrontMCP server. See [Coexistence](/frontmcp/plugins/skilled-openapi/coexistence) for the integration story. diff --git a/docs/frontmcp/plugins/skilled-openapi/quickstart.mdx b/docs/frontmcp/plugins/skilled-openapi/quickstart.mdx index d82851948..40ff173a8 100644 --- a/docs/frontmcp/plugins/skilled-openapi/quickstart.mdx +++ b/docs/frontmcp/plugins/skilled-openapi/quickstart.mdx @@ -105,6 +105,7 @@ The fastest way to see the plugin in action is to point it at a **static** bundl } } ``` + @@ -143,6 +144,7 @@ The fastest way to see the plugin in action is to point it at a **static** bundl }) export default class Server {} ``` + @@ -190,6 +192,7 @@ The fastest way to see the plugin in action is to point it at a **static** bundl ```bash npx tsx src/mock-billing.ts ``` + @@ -200,6 +203,7 @@ The fastest way to see the plugin in action is to point it at a **static** bundl - `search_skill({ query: "create invoice" })` returns `[{ skillId: "invoices", ... }]`. - `load_skill({ skillId: "invoices" })` returns the markdown instructions plus three actions with their JSON Schemas (the `actionId`s you'll call from a workflow). - `run_workflow({ script: 'const inv = await callTool("createInvoice", { customerId: "cus_1", amount: 4200 }); return inv;' })` returns `{ success: true, value: { id: "inv_1", status: "open" }, stats: { durationMs, toolCalls: 1, steps } }` and the mock server logs the hit. The AgentScript runs inside the enclave sandbox; `callTool` is the only way it reaches an upstream operation. + diff --git a/docs/frontmcp/plugins/skilled-openapi/security.mdx b/docs/frontmcp/plugins/skilled-openapi/security.mdx index 2ab41a2f5..f25645aea 100644 --- a/docs/frontmcp/plugins/skilled-openapi/security.mdx +++ b/docs/frontmcp/plugins/skilled-openapi/security.mdx @@ -146,24 +146,24 @@ The check happens on each `callTool` the `run_workflow` script issues, with the ## OWASP MCP Top 10 (2026) coverage -| OWASP MCP risk | This plugin's defense | -| --- | --- | -| MCP-1 Tool poisoning | Bundle signing + bundle-diff log on every swap (rug-pull detection) | -| MCP-2 Prompt injection (direct) | Inbound auth + meta-tool input schema strict validation | -| MCP-3 Indirect prompt injection | Enclave-sandboxed `run_workflow` (script reaches upstream data only via `callTool`); output schema enforcement on each action | -| MCP-4 Excessive agency | Per-skill ABAC + credential scoping + structured ABAC denial path | -| MCP-5 Sensitive info disclosure | Outbound allowlist + audit log + no credential echo in logs/traces | -| MCP-6 Insecure tool description | Signed-bundle origin + bundle-diff log surfaces description changes | -| MCP-7 Confused deputy | RFC 8707 enforcement on every inbound JWT (caller-token passthrough) | -| MCP-8 Supply chain | Bundle signing (RS256/Ed25519 JWT-of-hashes) + signed `integrity` envelope + pinned npm version | -| MCP-9 Excessive permissions | Per-bundle credential allowlist + per-skill scoping | -| MCP-10 Insufficient observability | OTel spans + audit log + bundle-diff log on every swap | +| OWASP MCP risk | This plugin's defense | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| MCP-1 Tool poisoning | Bundle signing + bundle-diff log on every swap (rug-pull detection) | +| MCP-2 Prompt injection (direct) | Inbound auth + meta-tool input schema strict validation | +| MCP-3 Indirect prompt injection | Enclave-sandboxed `run_workflow` (script reaches upstream data only via `callTool`); output schema enforcement on each action | +| MCP-4 Excessive agency | Per-skill ABAC + credential scoping + structured ABAC denial path | +| MCP-5 Sensitive info disclosure | Outbound allowlist + audit log + no credential echo in logs/traces | +| MCP-6 Insecure tool description | Signed-bundle origin + bundle-diff log surfaces description changes | +| MCP-7 Confused deputy | RFC 8707 enforcement on every inbound JWT (caller-token passthrough) | +| MCP-8 Supply chain | Bundle signing (RS256/Ed25519 JWT-of-hashes) + signed `integrity` envelope + pinned npm version | +| MCP-9 Excessive permissions | Per-bundle credential allowlist + per-skill scoping | +| MCP-10 Insufficient observability | OTel spans + audit log + bundle-diff log on every swap | ## Indirect prompt injection mitigations The customer's REST API can return content (CRM names, ticket bodies, user-supplied fields) that contains instructions targeting the LLM. Defenses: -- **Structural separation**: upstream responses never reach the LLM directly. Each `callTool` returns the action's `data` *into the sandboxed AgentScript*, and only the script's final `return ` (surfaced as `run_workflow`'s `{ success, value, error, stats }`) is handed back to the LLM. Raw response bodies are opaque to the model unless the script explicitly extracts and returns them. +- **Structural separation**: upstream responses never reach the LLM directly. Each `callTool` returns the action's `data` _into the sandboxed AgentScript_, and only the script's final `return ` (surfaced as `run_workflow`'s `{ success, value, error, stats }`) is handed back to the LLM. Raw response bodies are opaque to the model unless the script explicitly extracts and returns them. - **Output schema validation as a bottleneck**: every response validated against the bundle's declared `outputSchema`. - **Response size cap** (`defaultMaxResponseBytes`, default 256KB; per-op override via `op.maxResponseBytes`). @@ -175,7 +175,7 @@ Before flipping to production: - [ ] `dev: false` (default) and `requireSignature: true` (default) - [ ] At least one entry in `trustedKeys[]` -- [ ] Credentials wired via the [auth vault](/frontmcp/authentication/auth) instead of the in-memory `MemoryCredentialResolver` for any non-trivial deployment +- [ ] Credentials wired via the [auth vault](/frontmcp/authentication/overview) instead of the in-memory `MemoryCredentialResolver` for any non-trivial deployment - [ ] `allowHttp: false` (default) unless your upstream is on `localhost` - [ ] `allowPrivateNetworks: false` (default) unless self-hosted on a private network - [ ] `outbound.maxConcurrencyPerHost` tuned to match your upstream's rate-limit budget diff --git a/docs/frontmcp/plugins/skilled-openapi/sources.mdx b/docs/frontmcp/plugins/skilled-openapi/sources.mdx index 28d534d7d..a498857e4 100644 --- a/docs/frontmcp/plugins/skilled-openapi/sources.mdx +++ b/docs/frontmcp/plugins/skilled-openapi/sources.mdx @@ -8,11 +8,11 @@ keywords: ['frontmcp', 'mcp', 'model context protocol', 'plugin', 'plugins', 'ex The plugin loads bundles through a `SkillBundleSource` interface. Three implementations are available; you choose by setting `source.type` in the plugin options. -| Source | Refresh | Production-ready | Use when | -| --- | --- | --- | --- | -| `static` | `fs.watch` (optional) | ✅ for self-hosted | Bundle lives on the FrontMCP server's filesystem; you control deployments | -| `npm` | Server redeploy only | ✅ | Bundle ships as an npm package pinned in your `package.json` | -| `saas` | Boot pull + interval polling | ✅ when paired with signing | Bundle is produced by an external service (FrontMCP Cloud or your own analyzer) | +| Source | Refresh | Production-ready | Use when | +| -------- | ---------------------------- | --------------------------- | ------------------------------------------------------------------------------- | +| `static` | `fs.watch` (optional) | ✅ for self-hosted | Bundle lives on the FrontMCP server's filesystem; you control deployments | +| `npm` | Server redeploy only | ✅ | Bundle ships as an npm package pinned in your `package.json` | +| `saas` | Boot pull + interval polling | ✅ when paired with signing | Bundle is produced by an external service (FrontMCP Cloud or your own analyzer) | ## `static` @@ -30,6 +30,7 @@ SkilledOpenApiPlugin.init({ ``` Bundle file extensions: + - `.json` → JSON - `.yaml` / `.yml` → YAML - anything else → format sniffed from the first non-whitespace byte @@ -96,6 +97,7 @@ SkilledOpenApiPlugin.init({ ``` Values: + - `'static-wins'` (default): static beats npm beats saas - `'last-wins'`: most recent apply wins (use with caution; race-prone) - `'reject'`: fail to apply when a conflict is detected diff --git a/docs/frontmcp/react/agent-components.mdx b/docs/frontmcp/react/agent-components.mdx index d79bb5cbb..96328e734 100644 --- a/docs/frontmcp/react/agent-components.mdx +++ b/docs/frontmcp/react/agent-components.mdx @@ -50,14 +50,14 @@ function App() { ### Options -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `name` | `string` | — | **Required.** MCP tool name agents will call | -| `description` | `string` | `name` | Tool description for agents | -| `schema` | `z.ZodObject` | — | **Required.** Zod schema for type-safe input | -| `fallback` | `ReactNode` | `null` | Shown before first invocation | -| `server` | `string` | — | Target a named server | -| `columns` | `McpColumnDef[]` | — | Table mode column definitions | +| Option | Type | Default | Description | +| ------------- | ---------------- | ------- | -------------------------------------------- | +| `name` | `string` | — | **Required.** MCP tool name agents will call | +| `description` | `string` | `name` | Tool description for agents | +| `schema` | `z.ZodObject` | — | **Required.** Zod schema for type-safe input | +| `fallback` | `ReactNode` | `null` | Shown before first invocation | +| `server` | `string` | — | Target a named server | +| `columns` | `McpColumnDef[]` | — | Table mode column definitions | ### Static Properties @@ -169,11 +169,11 @@ const OrderTable = mcpComponent(null, { ### McpColumnDef -| Field | Type | Description | -|-------|------|-------------| -| `key` | `string` | Property key in the row object | -| `header` | `string` | Column header text | -| `render` | `(value) => ReactNode` | Optional custom cell renderer | +| Field | Type | Description | +| -------- | ---------------------- | ------------------------------ | +| `key` | `string` | Property key in the row object | +| `header` | `string` | Column header text | +| `render` | `(value) => ReactNode` | Optional custom cell renderer | ### How Table Mode Works diff --git a/docs/frontmcp/react/ai-integration.mdx b/docs/frontmcp/react/ai-integration.mdx index 922fe90f6..89ae4e5cf 100644 --- a/docs/frontmcp/react/ai-integration.mdx +++ b/docs/frontmcp/react/ai-integration.mdx @@ -25,19 +25,19 @@ function AIChat() { ### Parameters -| Parameter | Type | Description | -|-----------|------|-------------| -| `platform` | `SupportedPlatform` | `'openai' \| 'claude' \| 'vercel-ai'` | -| `options.server` | `string` | Target a named server | +| Parameter | Type | Description | +| ---------------- | ------------------- | ------------------------------------- | +| `platform` | `SupportedPlatform` | `'openai' \| 'claude' \| 'vercel-ai'` | +| `options.server` | `string` | Target a named server | ### Return Value -| Field | Type | Description | -|-------|------|-------------| -| `tools` | `PlatformToolsMap[P] \| null` | Tools formatted for the target platform | -| `callTool` | `(name, args) => Promise` | Execute a tool and format the result | -| `loading` | `boolean` | True while formatting tools | -| `error` | `Error \| null` | Formatting or execution error | +| Field | Type | Description | +| ---------- | ---------------------------------------------- | --------------------------------------- | +| `tools` | `PlatformToolsMap[P] \| null` | Tools formatted for the target platform | +| `callTool` | `(name, args) => Promise` | Execute a tool and format the result | +| `loading` | `boolean` | True while formatting tools | +| `error` | `Error \| null` | Formatting or execution error | --- @@ -58,12 +58,12 @@ function ChatAgent() { ### Return Value -| Field | Type | Description | -|-------|------|-------------| -| `tools` | `PlatformToolsMap[P] \| null` | Formatted tools | +| Field | Type | Description | +| ------------------ | ------------------------------------------------ | ------------------------ | +| `tools` | `PlatformToolsMap[P] \| null` | Formatted tools | | `processToolCalls` | `(calls) => Promise` | Batch process tool calls | -| `loading` | `boolean` | Loading state | -| `error` | `Error \| null` | Error state | +| `loading` | `boolean` | Loading state | +| `error` | `Error \| null` | Error state | --- diff --git a/docs/frontmcp/react/api-client.mdx b/docs/frontmcp/react/api-client.mdx index e682b506a..19745c477 100644 --- a/docs/frontmcp/react/api-client.mdx +++ b/docs/frontmcp/react/api-client.mdx @@ -41,15 +41,15 @@ useApiClient({ ### Options -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `baseUrl` | `string` | — | **Required.** Base URL for all requests | -| `operations` | `ApiOperation[]` | — | **Required.** Operations to register as tools | -| `headers` | `Record \| () => Record` | — | Static headers or header factory | -| `prefix` | `string` | `'api'` | Tool name prefix | -| `client` | `HttpClient` | — | Custom HTTP client (takes precedence over `fetch`) | -| `fetch` | `typeof globalThis.fetch` | — | **Deprecated.** Use `client` instead | -| `server` | `string` | — | Target a named server | +| Option | Type | Default | Description | +| ------------ | -------------------------------------------------------- | ------- | -------------------------------------------------- | +| `baseUrl` | `string` | — | **Required.** Base URL for all requests | +| `operations` | `ApiOperation[]` | — | **Required.** Operations to register as tools | +| `headers` | `Record \| () => Record` | — | Static headers or header factory | +| `prefix` | `string` | `'api'` | Tool name prefix | +| `client` | `HttpClient` | — | Custom HTTP client (takes precedence over `fetch`) | +| `fetch` | `typeof globalThis.fetch` | — | **Deprecated.** Use `client` instead | +| `server` | `string` | — | Target a named server | ### Tool Naming diff --git a/docs/frontmcp/react/components.mdx b/docs/frontmcp/react/components.mdx index 83eba07ca..1c062c4f8 100644 --- a/docs/frontmcp/react/components.mdx +++ b/docs/frontmcp/react/components.mdx @@ -33,12 +33,12 @@ function ToolUI() { ### Props -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `tool` | `ToolInfo` | — | **Required.** Tool definition with `inputSchema` | -| `onSubmit` | `(args: Record) => void` | — | **Required.** Called with parsed form values | -| `renderField` | `(props: FieldRenderProps) => ReactNode` | — | Custom field renderer | -| `submitLabel` | `string` | `'Call Tool'` | Submit button text | +| Prop | Type | Default | Description | +| ------------- | ----------------------------------------- | ------------- | ------------------------------------------------ | +| `tool` | `ToolInfo` | — | **Required.** Tool definition with `inputSchema` | +| `onSubmit` | `(args: Record) => void` | — | **Required.** Called with parsed form values | +| `renderField` | `(props: FieldRenderProps) => ReactNode` | — | Custom field renderer | +| `submitLabel` | `string` | `'Call Tool'` | Submit button text | ### Custom Field Rendering @@ -58,15 +58,15 @@ function ToolUI() { ### FieldRenderProps -| Field | Type | Description | -|-------|------|-------------| -| `name` | `string` | Field name from schema | -| `type` | `string` | `'string' \| 'number' \| 'integer' \| 'boolean' \| 'enum'` | -| `required` | `boolean` | Whether the field is required | -| `description` | `string?` | Schema description | -| `enumValues` | `string[]?` | Enum options (renders ``) | +| `value` | `string` | Current value | +| `onChange` | `(value: string) => void` | Change handler | --- @@ -89,12 +89,12 @@ function PromptUI() { ### Props -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `prompt` | `PromptInfo` | — | **Required.** Prompt definition with `arguments` | -| `onSubmit` | `(args: Record) => void` | — | **Required.** Called with form values | -| `renderField` | `(props: FieldRenderProps) => ReactNode` | — | Custom field renderer | -| `submitLabel` | `string` | `'Get Prompt'` | Submit button text | +| Prop | Type | Default | Description | +| ------------- | ---------------------------------------- | -------------- | ------------------------------------------------ | +| `prompt` | `PromptInfo` | — | **Required.** Prompt definition with `arguments` | +| `onSubmit` | `(args: Record) => void` | — | **Required.** Called with form values | +| `renderField` | `(props: FieldRenderProps) => ReactNode` | — | Custom field renderer | +| `submitLabel` | `string` | `'Get Prompt'` | Submit button text | --- @@ -114,11 +114,11 @@ function ConfigViewer() { ### Props -| Prop | Type | Description | -|------|------|-------------| -| `data` | `{ contents?: ResourceContent[] } \| null` | Resource result | -| `loading` | `boolean` | Show loading indicator | -| `error` | `Error \| null` | Show error message | +| Prop | Type | Description | +| --------- | ------------------------------------------ | ---------------------- | +| `data` | `{ contents?: ResourceContent[] } \| null` | Resource result | +| `loading` | `boolean` | Show loading indicator | +| `error` | `Error \| null` | Show error message | JSON content (`application/json` mimeType) is automatically pretty-printed in a `
` block.
 
@@ -145,11 +145,11 @@ function ToolOutput() {
 
 ### Props
 
-| Prop | Type | Description |
-|------|------|-------------|
-| `data` | `unknown` | Output value — objects are JSON-stringified |
-| `loading` | `boolean` | Show loading indicator |
-| `error` | `Error \| null` | Show error message |
+| Prop      | Type            | Description                                 |
+| --------- | --------------- | ------------------------------------------- |
+| `data`    | `unknown`       | Output value — objects are JSON-stringified |
+| `loading` | `boolean`       | Show loading indicator                      |
+| `error`   | `Error \| null` | Show error message                          |
 
 ---
 
diff --git a/docs/frontmcp/react/dom-resources.mdx b/docs/frontmcp/react/dom-resources.mdx
index c5779c141..93eb658ca 100644
--- a/docs/frontmcp/react/dom-resources.mdx
+++ b/docs/frontmcp/react/dom-resources.mdx
@@ -27,11 +27,11 @@ const result = readDomById('main-content');
 
 ### Return Values
 
-| Scenario | mimeType | text |
-|----------|----------|------|
-| Element found | `application/json` | JSON with `outerHTML`, `textContent`, `tagName` |
-| Element not found | `text/plain` | `'Element with id "..." not found'` |
-| No DOM (SSR) | `text/plain` | `'DOM not available (not in a browser environment)'` |
+| Scenario          | mimeType           | text                                                 |
+| ----------------- | ------------------ | ---------------------------------------------------- |
+| Element found     | `application/json` | JSON with `outerHTML`, `textContent`, `tagName`      |
+| Element not found | `text/plain`       | `'Element with id "..." not found'`                  |
+| No DOM (SSR)      | `text/plain`       | `'DOM not available (not in a browser environment)'` |
 
 ---
 
@@ -54,12 +54,12 @@ const result = readDomBySelector('.card');
 
 ### Return Values
 
-| Scenario | mimeType | text |
-|----------|----------|------|
-| Elements found | `application/json` | JSON array of `{ outerHTML, textContent, tagName }` |
-| No matches | `text/plain` | `'No elements found matching "..."'` |
-| Invalid selector | `text/plain` | `'Invalid selector: "..."'` |
-| No DOM (SSR) | `text/plain` | `'DOM not available (not in a browser environment)'` |
+| Scenario         | mimeType           | text                                                 |
+| ---------------- | ------------------ | ---------------------------------------------------- |
+| Elements found   | `application/json` | JSON array of `{ outerHTML, textContent, tagName }`  |
+| No matches       | `text/plain`       | `'No elements found matching "..."'`                 |
+| Invalid selector | `text/plain`       | `'Invalid selector: "..."'`                          |
+| No DOM (SSR)     | `text/plain`       | `'DOM not available (not in a browser environment)'` |
 
 ---
 
diff --git a/docs/frontmcp/react/dynamic-tools.mdx b/docs/frontmcp/react/dynamic-tools.mdx
index 81b6c3582..bd311002d 100644
--- a/docs/frontmcp/react/dynamic-tools.mdx
+++ b/docs/frontmcp/react/dynamic-tools.mdx
@@ -41,6 +41,7 @@ function CartControls() {
 ```
 
 When a zod `schema` is provided:
+
 - The schema is converted to JSON Schema automatically via `toJSONSchema` from `zod/v4`
 - Input is validated via `safeParse` before reaching your `execute` callback
 - Invalid input returns an error `CallToolResult` with issue details
@@ -69,15 +70,15 @@ useDynamicTool({
 
 ### Options
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `name` | `string` | — | **Required.** MCP tool name |
-| `description` | `string` | — | **Required.** Description for agents |
-| `schema` | `z.ZodObject` | — | Zod schema (mutually exclusive with `inputSchema`) |
-| `inputSchema` | `Record` | — | JSON Schema (mutually exclusive with `schema`) |
-| `execute` | `(args) => Promise` | — | **Required.** Tool handler |
-| `enabled` | `boolean` | `true` | Conditionally enable/disable |
-| `server` | `string` | — | Target a named server |
+| Option        | Type                                | Default | Description                                        |
+| ------------- | ----------------------------------- | ------- | -------------------------------------------------- |
+| `name`        | `string`                            | —       | **Required.** MCP tool name                        |
+| `description` | `string`                            | —       | **Required.** Description for agents               |
+| `schema`      | `z.ZodObject`                       | —       | Zod schema (mutually exclusive with `inputSchema`) |
+| `inputSchema` | `Record`           | —       | JSON Schema (mutually exclusive with `schema`)     |
+| `execute`     | `(args) => Promise` | —       | **Required.** Tool handler                         |
+| `enabled`     | `boolean`                           | `true`  | Conditionally enable/disable                       |
+| `server`      | `string`                            | —       | Target a named server                              |
 
 ### Conditional Registration
 
@@ -136,15 +137,15 @@ function UserPreferences({ preferences }: { preferences: UserPrefs }) {
 
 ### Options
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `uri` | `string` | — | **Required.** Resource URI |
-| `name` | `string` | — | **Required.** Human-readable name |
-| `description` | `string` | — | Description for agents |
-| `mimeType` | `string` | — | Content MIME type |
-| `read` | `() => Promise` | — | **Required.** Read handler |
-| `enabled` | `boolean` | `true` | Conditionally enable/disable |
-| `server` | `string` | — | Target a named server |
+| Option        | Type                                | Default | Description                       |
+| ------------- | ----------------------------------- | ------- | --------------------------------- |
+| `uri`         | `string`                            | —       | **Required.** Resource URI        |
+| `name`        | `string`                            | —       | **Required.** Human-readable name |
+| `description` | `string`                            | —       | Description for agents            |
+| `mimeType`    | `string`                            | —       | Content MIME type                 |
+| `read`        | `() => Promise` | —       | **Required.** Read handler        |
+| `enabled`     | `boolean`                           | `true`  | Conditionally enable/disable      |
+| `server`      | `string`                            | —       | Target a named server             |
 
 ---
 
@@ -177,13 +178,13 @@ function Dashboard() {
 
 ### Options
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `rootRef` | `RefObject` | — | **Required.** Root element ref |
-| `uri` | `string` | `'react://component-tree'` | Resource URI |
-| `maxDepth` | `number` | `10` | Maximum traversal depth |
-| `includeProps` | `boolean` | `false` | Include `data-*` attributes as props |
-| `server` | `string` | — | Target a named server |
+| Option         | Type                             | Default                    | Description                          |
+| -------------- | -------------------------------- | -------------------------- | ------------------------------------ |
+| `rootRef`      | `RefObject` | —                          | **Required.** Root element ref       |
+| `uri`          | `string`                         | `'react://component-tree'` | Resource URI                         |
+| `maxDepth`     | `number`                         | `10`                       | Maximum traversal depth              |
+| `includeProps` | `boolean`                        | `false`                    | Include `data-*` attributes as props |
+| `server`       | `string`                         | —                          | Target a named server                |
 
 ### Output Format
 
diff --git a/docs/frontmcp/react/getting-started.mdx b/docs/frontmcp/react/getting-started.mdx
index 4f556d681..60fc3c9f2 100644
--- a/docs/frontmcp/react/getting-started.mdx
+++ b/docs/frontmcp/react/getting-started.mdx
@@ -102,12 +102,12 @@ const [callTool, state] = useCallTool('track_event', { server: 'analytics' });
 
 The provider status progresses through:
 
-| Status | Description |
-|--------|-------------|
-| `idle` | Not connected yet (`autoConnect={false}`) |
-| `connecting` | `server.connect()` in progress |
-| `connected` | Client ready, tools/resources/prompts populated |
-| `error` | Connection failed — see `error` field |
+| Status       | Description                                     |
+| ------------ | ----------------------------------------------- |
+| `idle`       | Not connected yet (`autoConnect={false}`)       |
+| `connecting` | `server.connect()` in progress                  |
+| `connected`  | Client ready, tools/resources/prompts populated |
+| `error`      | Connection failed — see `error` field           |
 
 Monitor status with `useFrontMcp()`:
 
diff --git a/docs/frontmcp/react/hooks.mdx b/docs/frontmcp/react/hooks.mdx
index 73d1fe2d9..b0ad982c9 100644
--- a/docs/frontmcp/react/hooks.mdx
+++ b/docs/frontmcp/react/hooks.mdx
@@ -18,21 +18,21 @@ const [callTool, state, reset] = useCallTool(toolName, op
 
 ### Parameters
 
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `toolName` | `string` | Name of the MCP tool |
-| `options.server` | `string` | Target a named server |
-| `options.onSuccess` | `(data) => void` | Success callback |
-| `options.onError` | `(error) => void` | Error callback |
-| `options.resetOnToolChange` | `boolean` | Reset state when `toolName` changes (default: `true`) |
+| Parameter                   | Type              | Description                                           |
+| --------------------------- | ----------------- | ----------------------------------------------------- |
+| `toolName`                  | `string`          | Name of the MCP tool                                  |
+| `options.server`            | `string`          | Target a named server                                 |
+| `options.onSuccess`         | `(data) => void`  | Success callback                                      |
+| `options.onError`           | `(error) => void` | Error callback                                        |
+| `options.resetOnToolChange` | `boolean`         | Reset state when `toolName` changes (default: `true`) |
 
 ### Return Value
 
-| Index | Type | Description |
-|-------|------|-------------|
-| `[0]` | `(args: TInput) => Promise` | Call function |
-| `[1]` | `ToolState` | `{ data, loading, error, called }` |
-| `[2]` | `() => void` | Reset state |
+| Index | Type                                         | Description                        |
+| ----- | -------------------------------------------- | ---------------------------------- |
+| `[0]` | `(args: TInput) => Promise` | Call function                      |
+| `[1]` | `ToolState`                         | `{ data, loading, error, called }` |
+| `[2]` | `() => void`                                 | Reset state                        |
 
 ### Example
 
@@ -254,15 +254,15 @@ useDynamicTool({
 
 ### Options
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `name` | `string` | — | **Required.** Tool name |
-| `description` | `string` | — | **Required.** Description for agents |
-| `schema` | `z.ZodObject` | — | Zod schema (mutually exclusive with `inputSchema`) |
-| `inputSchema` | `Record` | — | JSON Schema (mutually exclusive with `schema`) |
-| `execute` | `(args) => Promise` | — | **Required.** Handler |
-| `enabled` | `boolean` | `true` | Conditionally enable/disable |
-| `server` | `string` | — | Target a named server |
+| Option        | Type                                | Default | Description                                        |
+| ------------- | ----------------------------------- | ------- | -------------------------------------------------- |
+| `name`        | `string`                            | —       | **Required.** Tool name                            |
+| `description` | `string`                            | —       | **Required.** Description for agents               |
+| `schema`      | `z.ZodObject`                       | —       | Zod schema (mutually exclusive with `inputSchema`) |
+| `inputSchema` | `Record`           | —       | JSON Schema (mutually exclusive with `schema`)     |
+| `execute`     | `(args) => Promise` | —       | **Required.** Handler                              |
+| `enabled`     | `boolean`                           | `true`  | Conditionally enable/disable                       |
+| `server`      | `string`                            | —       | Target a named server                              |
 
 ---
 
@@ -286,15 +286,15 @@ useDynamicResource({
 
 ### Options
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `uri` | `string` | — | **Required.** Resource URI |
-| `name` | `string` | — | **Required.** Human-readable name |
-| `description` | `string` | — | Description for agents |
-| `mimeType` | `string` | — | Content MIME type |
-| `read` | `() => Promise` | — | **Required.** Handler |
-| `enabled` | `boolean` | `true` | Conditionally enable/disable |
-| `server` | `string` | — | Target a named server |
+| Option        | Type                                | Default | Description                       |
+| ------------- | ----------------------------------- | ------- | --------------------------------- |
+| `uri`         | `string`                            | —       | **Required.** Resource URI        |
+| `name`        | `string`                            | —       | **Required.** Human-readable name |
+| `description` | `string`                            | —       | Description for agents            |
+| `mimeType`    | `string`                            | —       | Content MIME type                 |
+| `read`        | `() => Promise` | —       | **Required.** Handler             |
+| `enabled`     | `boolean`                           | `true`  | Conditionally enable/disable      |
+| `server`      | `string`                            | —       | Target a named server             |
 
 ---
 
@@ -318,13 +318,13 @@ useComponentTree({
 
 ### Options
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `rootRef` | `RefObject` | — | **Required.** Root element ref |
-| `uri` | `string` | `'react://component-tree'` | Resource URI |
-| `maxDepth` | `number` | `10` | Max traversal depth |
-| `includeProps` | `boolean` | `false` | Include `data-*` attributes |
-| `server` | `string` | — | Target a named server |
+| Option         | Type                             | Default                    | Description                    |
+| -------------- | -------------------------------- | -------------------------- | ------------------------------ |
+| `rootRef`      | `RefObject` | —                          | **Required.** Root element ref |
+| `uri`          | `string`                         | `'react://component-tree'` | Resource URI                   |
+| `maxDepth`     | `number`                         | `10`                       | Max traversal depth            |
+| `includeProps` | `boolean`                        | `false`                    | Include `data-*` attributes    |
+| `server`       | `string`                         | —                          | Target a named server          |
 
 ---
 
diff --git a/docs/frontmcp/react/overview.mdx b/docs/frontmcp/react/overview.mdx
index 724449173..5926fea6c 100644
--- a/docs/frontmcp/react/overview.mdx
+++ b/docs/frontmcp/react/overview.mdx
@@ -12,13 +12,13 @@ keywords: ['frontmcp', 'mcp', 'model context protocol', 'react', 'ui', 'frontend
 
 The package exposes five entry points:
 
-| Import | Purpose |
-|--------|---------|
-| `@frontmcp/react` | Provider, hooks, components, `ServerRegistry`, and SDK re-exports |
-| `@frontmcp/react/ai` | AI SDK integration (`useAITools`, `useTools`, `createToolCallHandler`) |
-| `@frontmcp/react/router` | React Router bridge (`useRouterBridge`, navigation tools, route resource) |
-| `@frontmcp/react/state` | State management integration (hooks + store adapters: `reduxStore`, `valtioStore`, `createStore`) |
-| `@frontmcp/react/api` | API client integration (`useApiClient`, `parseOpenApiSpec`, `HttpClient`) |
+| Import                   | Purpose                                                                                           |
+| ------------------------ | ------------------------------------------------------------------------------------------------- |
+| `@frontmcp/react`        | Provider, hooks, components, `ServerRegistry`, and SDK re-exports                                 |
+| `@frontmcp/react/ai`     | AI SDK integration (`useAITools`, `useTools`, `createToolCallHandler`)                            |
+| `@frontmcp/react/router` | React Router bridge (`useRouterBridge`, navigation tools, route resource)                         |
+| `@frontmcp/react/state`  | State management integration (hooks + store adapters: `reduxStore`, `valtioStore`, `createStore`) |
+| `@frontmcp/react/api`    | API client integration (`useApiClient`, `parseOpenApiSpec`, `HttpClient`)                         |
 
 ## Architecture
 
@@ -61,12 +61,12 @@ yarn add @frontmcp/react react react-dom
 
 `@frontmcp/sdk` and `@frontmcp/utils` are bundled as dependencies and installed automatically.
 
-| Package | Version | Required |
-|---------|---------|----------|
-| `react` | `^18.0.0 \|\| ^19.0.0` | Yes |
-| `react-dom` | `^18.0.0 \|\| ^19.0.0` | Yes |
-| `react-router-dom` | `^7.0.0` | Optional (only for `/router`) |
-| `zod` | `^4.0.0` | Optional (for `mcpComponent` and zod-based `useDynamicTool`) |
+| Package            | Version                | Required                                                     |
+| ------------------ | ---------------------- | ------------------------------------------------------------ |
+| `react`            | `^18.0.0 \|\| ^19.0.0` | Yes                                                          |
+| `react-dom`        | `^18.0.0 \|\| ^19.0.0` | Yes                                                          |
+| `react-router-dom` | `^7.0.0`               | Optional (only for `/router`)                                |
+| `zod`              | `^4.0.0`               | Optional (for `mcpComponent` and zod-based `useDynamicTool`) |
 
 ## Quick Example
 
diff --git a/docs/frontmcp/react/provider.mdx b/docs/frontmcp/react/provider.mdx
index 53144f3eb..cf4566450 100644
--- a/docs/frontmcp/react/provider.mdx
+++ b/docs/frontmcp/react/provider.mdx
@@ -28,16 +28,16 @@ import { FrontMcpProvider } from '@frontmcp/react';
 
 ### Props
 
-| Prop | Type | Default | Description |
-|------|------|---------|-------------|
-| `server` | `DirectMcpServer` | — | **Required.** Primary MCP server instance |
-| `name` | `string` | `'default'` | Logical name for the primary server in the registry |
-| `servers` | `Record` | — | Additional named servers |
-| `components` | `Record` | — | Components for `DynamicRenderer` and `ComponentRegistry` |
-| `autoConnect` | `boolean` | `true` | Connect on mount |
-| `children` | `ReactNode` | — | **Required.** Child elements |
-| `onConnected` | `(client: DirectClient) => void` | — | Called after successful connection |
-| `onError` | `(error: Error) => void` | — | Called if connection fails |
+| Prop          | Type                              | Default     | Description                                              |
+| ------------- | --------------------------------- | ----------- | -------------------------------------------------------- |
+| `server`      | `DirectMcpServer`                 | —           | **Required.** Primary MCP server instance                |
+| `name`        | `string`                          | `'default'` | Logical name for the primary server in the registry      |
+| `servers`     | `Record` | —           | Additional named servers                                 |
+| `components`  | `Record`   | —           | Components for `DynamicRenderer` and `ComponentRegistry` |
+| `autoConnect` | `boolean`                         | `true`      | Connect on mount                                         |
+| `children`    | `ReactNode`                       | —           | **Required.** Child elements                             |
+| `onConnected` | `(client: DirectClient) => void`  | —           | Called after successful connection                       |
+| `onError`     | `(error: Error) => void`          | —           | Called if connection fails                               |
 
 ### Lifecycle
 
@@ -83,16 +83,16 @@ const unsub = serverRegistry.subscribe(() => {
 
 ### ServerEntry
 
-| Field | Type | Description |
-|-------|------|-------------|
-| `server` | `DirectMcpServer` | The server instance |
-| `client` | `DirectClient \| null` | Connected client (null until connected) |
-| `status` | `FrontMcpStatus` | `'idle' \| 'connecting' \| 'connected' \| 'error'` |
-| `error` | `Error \| null` | Connection error |
-| `tools` | `ToolInfo[]` | Discovered tools |
-| `resources` | `ResourceInfo[]` | Discovered resources |
-| `resourceTemplates` | `ResourceTemplateInfo[]` | Discovered resource templates |
-| `prompts` | `PromptInfo[]` | Discovered prompts |
+| Field               | Type                     | Description                                        |
+| ------------------- | ------------------------ | -------------------------------------------------- |
+| `server`            | `DirectMcpServer`        | The server instance                                |
+| `client`            | `DirectClient \| null`   | Connected client (null until connected)            |
+| `status`            | `FrontMcpStatus`         | `'idle' \| 'connecting' \| 'connected' \| 'error'` |
+| `error`             | `Error \| null`          | Connection error                                   |
+| `tools`             | `ToolInfo[]`             | Discovered tools                                   |
+| `resources`         | `ResourceInfo[]`         | Discovered resources                               |
+| `resourceTemplates` | `ResourceTemplateInfo[]` | Discovered resource templates                      |
+| `prompts`           | `PromptInfo[]`           | Discovered prompts                                 |
 
 ## useFrontMcp
 
diff --git a/docs/frontmcp/react/router.mdx b/docs/frontmcp/react/router.mdx
index 03c37540f..3546d7c0e 100644
--- a/docs/frontmcp/react/router.mdx
+++ b/docs/frontmcp/react/router.mdx
@@ -93,17 +93,17 @@ Must be called inside a React Router tree (``, ``,
 
 MCP tool that navigates to a URL path.
 
-| Field | Value |
-|-------|-------|
-| **Name** | `navigate` |
+| Field           | Value                                     |
+| --------------- | ----------------------------------------- |
+| **Name**        | `navigate`                                |
 | **Description** | Navigate to a URL path in the application |
 
 ### Input Schema
 
-| Parameter | Type | Required | Description |
-|-----------|------|----------|-------------|
-| `path` | `string` | Yes | URL path to navigate to |
-| `replace` | `boolean` | No | Replace history entry instead of pushing |
+| Parameter | Type      | Required | Description                              |
+| --------- | --------- | -------- | ---------------------------------------- |
+| `path`    | `string`  | Yes      | URL path to navigate to                  |
+| `replace` | `boolean` | No       | Replace history entry instead of pushing |
 
 ### Example
 
@@ -119,9 +119,9 @@ Returns: `"Navigated to /dashboard"`
 
 MCP tool that navigates back in browser history.
 
-| Field | Value |
-|-------|-------|
-| **Name** | `go_back` |
+| Field           | Value                                           |
+| --------------- | ----------------------------------------------- |
+| **Name**        | `go_back`                                       |
 | **Description** | Go back to the previous page in browser history |
 
 Takes no input parameters.
@@ -132,10 +132,10 @@ Takes no input parameters.
 
 MCP resource that reads the current URL/path/params.
 
-| Field | Value |
-|-------|-------|
-| **URI** | `route://current` |
-| **Name** | Current Route |
+| Field    | Value             |
+| -------- | ----------------- |
+| **URI**  | `route://current` |
+| **Name** | Current Route     |
 
 ### Response
 
@@ -178,13 +178,13 @@ import {
 } from '@frontmcp/react/router';
 ```
 
-| Function | Description |
-|----------|-------------|
-| `setNavigate(fn)` | Set the navigate function |
-| `setLocation(loc)` | Set the current location |
-| `getNavigate()` | Get the navigate function (or null) |
-| `getLocation()` | Get the current location (or null) |
-| `clearBridge()` | Reset both navigate and location to null |
+| Function           | Description                              |
+| ------------------ | ---------------------------------------- |
+| `setNavigate(fn)`  | Set the navigate function                |
+| `setLocation(loc)` | Set the current location                 |
+| `getNavigate()`    | Get the navigate function (or null)      |
+| `getLocation()`    | Get the current location (or null)       |
+| `clearBridge()`    | Reset both navigate and location to null |
 
 
 If the bridge is not connected (e.g., `useRouterBridge()` not called), `NavigateTool` and `GoBackTool` return an error message instead of navigating. `CurrentRouteResource` returns an error JSON object.
diff --git a/docs/frontmcp/react/state-management.mdx b/docs/frontmcp/react/state-management.mdx
index ae6ac2129..781e1e034 100644
--- a/docs/frontmcp/react/state-management.mdx
+++ b/docs/frontmcp/react/state-management.mdx
@@ -40,22 +40,22 @@ function StoreProvider() {
 
 ### What Gets Registered
 
-| MCP Entity | URI / Name | Description |
-|------------|-----------|-------------|
-| Resource | `state://{name}` | Full state snapshot |
-| Resource | `state://{name}/{selectorKey}` | Each selector as a sub-resource |
-| Tool | `{name}_{actionKey}` | Each action as a callable tool |
+| MCP Entity | URI / Name                     | Description                     |
+| ---------- | ------------------------------ | ------------------------------- |
+| Resource   | `state://{name}`               | Full state snapshot             |
+| Resource   | `state://{name}/{selectorKey}` | Each selector as a sub-resource |
+| Tool       | `{name}_{actionKey}`           | Each action as a callable tool  |
 
 ### Options
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `name` | `string` | — | **Required.** Name prefix for resources and tools |
-| `getState` | `() => unknown` | — | **Required.** Returns current state snapshot |
-| `subscribe` | `(cb: () => void) => () => void` | — | **Required.** Subscribe to changes, return unsubscribe |
-| `selectors` | `Record unknown>` | — | Named selectors, each becomes a sub-resource |
-| `actions` | `Record unknown>` | — | Named actions, each becomes a tool |
-| `server` | `string` | — | Target a named server |
+| Option      | Type                                   | Default | Description                                            |
+| ----------- | -------------------------------------- | ------- | ------------------------------------------------------ |
+| `name`      | `string`                               | —       | **Required.** Name prefix for resources and tools      |
+| `getState`  | `() => unknown`                        | —       | **Required.** Returns current state snapshot           |
+| `subscribe` | `(cb: () => void) => () => void`       | —       | **Required.** Subscribe to changes, return unsubscribe |
+| `selectors` | `Record unknown>`   | —       | Named selectors, each becomes a sub-resource           |
+| `actions`   | `Record unknown>` | —       | Named actions, each becomes a tool                     |
+| `server`    | `string`                               | —       | Target a named server                                  |
 
 ### Live Updates
 
@@ -92,13 +92,13 @@ function ReduxBridge() {
 
 ### Options
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `store` | `{ getState, dispatch, subscribe }` | — | **Required.** Redux store |
-| `name` | `string` | `'redux'` | Name prefix |
-| `selectors` | `Record unknown>` | — | Named selectors |
-| `actions` | `Record action>` | — | Action creators (auto-dispatched) |
-| `server` | `string` | — | Target a named server |
+| Option      | Type                                  | Default   | Description                       |
+| ----------- | ------------------------------------- | --------- | --------------------------------- |
+| `store`     | `{ getState, dispatch, subscribe }`   | —         | **Required.** Redux store         |
+| `name`      | `string`                              | `'redux'` | Name prefix                       |
+| `selectors` | `Record unknown>`  | —         | Named selectors                   |
+| `actions`   | `Record action>` | —         | Action creators (auto-dispatched) |
+| `server`    | `string`                              | —         | Target a named server             |
 
 ---
 
@@ -137,14 +137,14 @@ function ValtioBridge() {
 
 ### Options
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `proxy` | `Record` | — | **Required.** Valtio proxy object |
-| `subscribe` | `(proxy, cb) => () => void` | — | **Required.** Valtio's subscribe function |
-| `name` | `string` | `'valtio'` | Name prefix |
-| `paths` | `Record` | — | Deep path selectors (dot notation) |
-| `mutations` | `Record void>` | — | Named mutations (each becomes a tool) |
-| `server` | `string` | — | Target a named server |
+| Option      | Type                                | Default    | Description                               |
+| ----------- | ----------------------------------- | ---------- | ----------------------------------------- |
+| `proxy`     | `Record`           | —          | **Required.** Valtio proxy object         |
+| `subscribe` | `(proxy, cb) => () => void`         | —          | **Required.** Valtio's subscribe function |
+| `name`      | `string`                            | `'valtio'` | Name prefix                               |
+| `paths`     | `Record`            | —          | Deep path selectors (dot notation)        |
+| `mutations` | `Record void>` | —          | Named mutations (each becomes a tool)     |
+| `server`    | `string`                            | —          | Target a named server                     |
 
 
 You must pass valtio's `subscribe` function yourself since valtio is an optional peer dependency. Import it from `valtio/utils`.
@@ -198,12 +198,12 @@ reduxStore({
 })
 ```
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `store` | `{ getState, dispatch, subscribe }` | — | **Required.** Redux store |
-| `name` | `string` | `'redux'` | Logical name |
-| `selectors` | `Record unknown>` | — | Named selectors |
-| `actions` | `Record action>` | — | Action creators (auto-dispatched) |
+| Option      | Type                                  | Default   | Description                       |
+| ----------- | ------------------------------------- | --------- | --------------------------------- |
+| `store`     | `{ getState, dispatch, subscribe }`   | —         | **Required.** Redux store         |
+| `name`      | `string`                              | `'redux'` | Logical name                      |
+| `selectors` | `Record unknown>`  | —         | Named selectors                   |
+| `actions`   | `Record action>` | —         | Action creators (auto-dispatched) |
 
 ### valtioStore
 
@@ -222,13 +222,13 @@ valtioStore({
 })
 ```
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `proxy` | `Record` | — | **Required.** Valtio proxy |
-| `subscribe` | `(proxy, cb) => () => void` | — | **Required.** Valtio subscribe |
-| `name` | `string` | `'valtio'` | Logical name |
-| `paths` | `Record` | — | Deep path selectors (dot notation) |
-| `mutations` | `Record void>` | — | Named mutations |
+| Option      | Type                                | Default    | Description                        |
+| ----------- | ----------------------------------- | ---------- | ---------------------------------- |
+| `proxy`     | `Record`           | —          | **Required.** Valtio proxy         |
+| `subscribe` | `(proxy, cb) => () => void`         | —          | **Required.** Valtio subscribe     |
+| `name`      | `string`                            | `'valtio'` | Logical name                       |
+| `paths`     | `Record`            | —          | Deep path selectors (dot notation) |
+| `mutations` | `Record void>` | —          | Named mutations                    |
 
 ### createStore
 
@@ -246,13 +246,13 @@ createStore({
 })
 ```
 
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `name` | `string` | — | **Required.** Logical name |
-| `getState` | `() => unknown` | — | **Required.** Returns state snapshot |
-| `subscribe` | `(cb) => () => void` | — | **Required.** Subscribe to changes |
-| `selectors` | `Record unknown>` | — | Named selectors |
-| `actions` | `Record unknown>` | — | Named actions |
+| Option      | Type                                   | Default | Description                          |
+| ----------- | -------------------------------------- | ------- | ------------------------------------ |
+| `name`      | `string`                               | —       | **Required.** Logical name           |
+| `getState`  | `() => unknown`                        | —       | **Required.** Returns state snapshot |
+| `subscribe` | `(cb) => () => void`                   | —       | **Required.** Subscribe to changes   |
+| `selectors` | `Record unknown>`   | —       | Named selectors                      |
+| `actions`   | `Record unknown>` | —       | Named actions                        |
 
 ### StoreAdapter Interface
 
diff --git a/docs/frontmcp/sdk-reference/contexts/channel-context.mdx b/docs/frontmcp/sdk-reference/contexts/channel-context.mdx
index 3822c5d4f..169f63a30 100644
--- a/docs/frontmcp/sdk-reference/contexts/channel-context.mdx
+++ b/docs/frontmcp/sdk-reference/contexts/channel-context.mdx
@@ -28,23 +28,23 @@ Transform an incoming event payload into a channel notification.
 abstract onEvent(payload: unknown): Promise;
 ```
 
-| Parameter | Type | Description |
-| --- | --- | --- |
+| Parameter | Type      | Description                                                      |
+| --------- | --------- | ---------------------------------------------------------------- |
 | `payload` | `unknown` | Raw event payload from the source. Shape depends on source type. |
 
 **Returns:** `Promise` — the notification to push to Claude Code sessions.
 
 ### Payload Shapes by Source
 
-| Source | Payload Shape |
-| --- | --- |
-| `webhook` | `WebhookPayload { body, headers, method, query }` |
-| `app-event` | Whatever was passed to `eventBus.emit(event, payload)` |
-| `agent-completion` | `AgentCompletionEvent { agentId, agentName, status, durationMs, output, error }` |
-| `job-completion` | `JobCompletionEvent { jobName, jobId, status, durationMs, output, error, sessionId }` |
-| `service` | Whatever `pushIncoming(payload)` receives from your connection listener |
-| `file-watcher` | Whatever `pushIncoming(payload)` receives from your file watcher |
-| `manual` | Whatever was passed to `handleEvent(payload)` |
+| Source             | Payload Shape                                                                         |
+| ------------------ | ------------------------------------------------------------------------------------- |
+| `webhook`          | `WebhookPayload { body, headers, method, query }`                                     |
+| `app-event`        | Whatever was passed to `eventBus.emit(event, payload)`                                |
+| `agent-completion` | `AgentCompletionEvent { agentId, agentName, status, durationMs, output, error }`      |
+| `job-completion`   | `JobCompletionEvent { jobName, jobId, status, durationMs, output, error, sessionId }` |
+| `service`          | Whatever `pushIncoming(payload)` receives from your connection listener               |
+| `file-watcher`     | Whatever `pushIncoming(payload)` receives from your file watcher                      |
+| `manual`           | Whatever was passed to `handleEvent(payload)`                                         |
 
 ## Optional Methods
 
@@ -56,10 +56,10 @@ Handle a reply from Claude Code. Only called when the channel has `twoWay: true`
 async onReply(reply: string, meta?: Record): Promise
 ```
 
-| Parameter | Type | Description |
-| --- | --- | --- |
-| `reply` | `string` | The reply text from Claude |
-| `meta` | `Record` | Metadata from the `channel-reply` tool call (e.g., `chat_id`) |
+| Parameter | Type                     | Description                                                   |
+| --------- | ------------------------ | ------------------------------------------------------------- |
+| `reply`   | `string`                 | The reply text from Claude                                    |
+| `meta`    | `Record` | Metadata from the `channel-reply` tool call (e.g., `chat_id`) |
 
 Default implementation logs a warning. Override to forward replies to external systems.
 
@@ -110,8 +110,8 @@ Push an incoming event from a service connection into the notification pipeline.
 protected pushIncoming(payload: unknown): void
 ```
 
-| Parameter | Type | Description |
-| --- | --- | --- |
+| Parameter | Type      | Description                                      |
+| --------- | --------- | ------------------------------------------------ |
 | `payload` | `unknown` | Raw event payload to process through `onEvent()` |
 
 
@@ -122,22 +122,22 @@ Only call `pushIncoming()` inside `onConnect()` event listeners. Calling it befo
 
 From `ExecutionContextBase`:
 
-| Property | Type | Description |
-| --- | --- | --- |
-| `logger` | `FrontMcpLogger` | Scoped logger for this channel |
-| `metadata` | `ChannelMetadata` | The channel's decorator metadata |
-| `channelName` | `string` | The channel name (from metadata) |
+| Property      | Type              | Description                      |
+| ------------- | ----------------- | -------------------------------- |
+| `logger`      | `FrontMcpLogger`  | Scoped logger for this channel   |
+| `metadata`    | `ChannelMetadata` | The channel's decorator metadata |
+| `channelName` | `string`          | The channel name (from metadata) |
 
 ## Inherited Methods
 
 From `ExecutionContextBase`:
 
-| Method | Description |
-| --- | --- |
-| `get(token)` | Resolve a dependency from the DI container |
+| Method             | Description                                |
+| ------------------ | ------------------------------------------ |
+| `get(token)`    | Resolve a dependency from the DI container |
 | `tryGet(token)` | Resolve or return `undefined` if not found |
-| `scope` | Access the parent scope |
-| `fail(error)` | Throw an MCP error |
+| `scope`            | Access the parent scope                    |
+| `fail(error)`      | Throw an MCP error                         |
 
 ## Examples
 
diff --git a/docs/frontmcp/sdk-reference/contexts/skill-context.mdx b/docs/frontmcp/sdk-reference/contexts/skill-context.mdx
index 53c09cd87..d487ee459 100644
--- a/docs/frontmcp/sdk-reference/contexts/skill-context.mdx
+++ b/docs/frontmcp/sdk-reference/contexts/skill-context.mdx
@@ -33,6 +33,7 @@ Override these methods only when you construct a `SkillContext` subclass
 manually outside the `@Skill` decorator pipeline and need bespoke
 loading/assembly logic. The detailed examples below show that advanced
 pattern.
+
 
 
 ### loadInstructions()
diff --git a/docs/frontmcp/sdk-reference/core/server.mdx b/docs/frontmcp/sdk-reference/core/server.mdx
index 1658e2fae..dd9ecc5c4 100644
--- a/docs/frontmcp/sdk-reference/core/server.mdx
+++ b/docs/frontmcp/sdk-reference/core/server.mdx
@@ -212,11 +212,11 @@ curl http://localhost:3001/readyz
 curl http://localhost:3001/health
 ```
 
-| Endpoint | Purpose | I/O | Runtime |
-| --- | --- | --- | --- |
-| `/healthz` | Liveness probe | None | All |
-| `/readyz` | Readiness probe with dependency checks | Yes | Node, Bun, Deno, Browser |
-| `/health` | Legacy alias for `/healthz` | None | All |
+| Endpoint   | Purpose                                | I/O  | Runtime                  |
+| ---------- | -------------------------------------- | ---- | ------------------------ |
+| `/healthz` | Liveness probe                         | None | All                      |
+| `/readyz`  | Readiness probe with dependency checks | Yes  | Node, Bun, Deno, Browser |
+| `/health`  | Legacy alias for `/healthz`            | None | All                      |
 
 The readiness endpoint automatically discovers and probes session stores (Redis/Vercel KV) and remote MCP app connections. Add custom probes for databases and APIs via `health.probes`.
 
diff --git a/docs/frontmcp/sdk-reference/decorators/app.mdx b/docs/frontmcp/sdk-reference/decorators/app.mdx
index f09cdaf9f..78d3e905f 100644
--- a/docs/frontmcp/sdk-reference/decorators/app.mdx
+++ b/docs/frontmcp/sdk-reference/decorators/app.mdx
@@ -45,26 +45,26 @@ function App(providedMetadata: LocalAppMetadata): ClassDecorator
 
 ### Optional Properties
 
-| Property      | Type                                | Description                                                                                                                |
-| ------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
-| `id`          | `string`                            | Stable identifier for tracking                                                                                             |
-| `description` | `string`                            | Human-readable description                                                                                                 |
-| `auth`        | `AuthOptionsInput`                  | App-level auth configuration (overrides gateway auth)                                                                      |
-| `standalone`  | `boolean \| 'includeInParent'`      | If `true`, the app gets its own scope. If `'includeInParent'`, it acts as a separated scope under the app-name prefix.     |
-| `output`      | `OutputPolicy`                      | App-level output-validation + output-schema exposure policy (`allowNonFinite`, `schemaMode`, `schemaDescriptionFormat`). Overrides the server policy; overridable per `@Tool` (Tool > App > server). See [Output schema exposure](/frontmcp/servers/tools#output-schema-exposure) |
+| Property      | Type                           | Description                                                                                                                                                                                                                                                                       |
+| ------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `id`          | `string`                       | Stable identifier for tracking                                                                                                                                                                                                                                                    |
+| `description` | `string`                       | Human-readable description                                                                                                                                                                                                                                                        |
+| `auth`        | `AuthOptionsInput`             | App-level auth configuration (overrides gateway auth)                                                                                                                                                                                                                             |
+| `standalone`  | `boolean \| 'includeInParent'` | If `true`, the app gets its own scope. If `'includeInParent'`, it acts as a separated scope under the app-name prefix.                                                                                                                                                            |
+| `output`      | `OutputPolicy`                 | App-level output-validation + output-schema exposure policy (`allowNonFinite`, `schemaMode`, `schemaDescriptionFormat`). Overrides the server policy; overridable per `@Tool` (Tool > App > server). See [Output schema exposure](/frontmcp/servers/tools#output-schema-exposure) |
 
 ### Components
 
-| Property     | Type              | Description                            |
-| ------------ | ----------------- | -------------------------------------- |
-| `tools`      | `ToolType[]`      | Tools defined by this app              |
-| `resources`  | `ResourceType[]`  | Resources defined by this app          |
-| `prompts`    | `PromptType[]`    | Prompts defined by this app            |
-| `agents`     | `AgentType[]`     | Agents defined by this app             |
-| `skills`     | `SkillType[]`     | Skills defined by this app             |
-| `jobs`       | `JobType[]`       | Jobs registered by this app            |
-| `workflows`  | `WorkflowType[]`  | Workflows registered by this app       |
-| `channels`   | `ChannelType[]`   | Notification channels for this app     |
+| Property    | Type             | Description                        |
+| ----------- | ---------------- | ---------------------------------- |
+| `tools`     | `ToolType[]`     | Tools defined by this app          |
+| `resources` | `ResourceType[]` | Resources defined by this app      |
+| `prompts`   | `PromptType[]`   | Prompts defined by this app        |
+| `agents`    | `AgentType[]`    | Agents defined by this app         |
+| `skills`    | `SkillType[]`    | Skills defined by this app         |
+| `jobs`      | `JobType[]`      | Jobs registered by this app        |
+| `workflows` | `WorkflowType[]` | Workflows registered by this app   |
+| `channels`  | `ChannelType[]`  | Notification channels for this app |
 
 ```typescript
 @App({
diff --git a/docs/frontmcp/sdk-reference/decorators/channel.mdx b/docs/frontmcp/sdk-reference/decorators/channel.mdx
index dd642ed43..7535a97a5 100644
--- a/docs/frontmcp/sdk-reference/decorators/channel.mdx
+++ b/docs/frontmcp/sdk-reference/decorators/channel.mdx
@@ -52,22 +52,22 @@ const ErrorChannel = channel({
 
 ### Required Properties
 
-| Property | Type | Description |
-| --- | --- | --- |
-| `name` | `string` | Unique channel identifier. Becomes the `source` attribute in notifications. |
-| `source` | `ChannelSourceConfig` | Event source configuration (see Source Types below). |
+| Property | Type                  | Description                                                                 |
+| -------- | --------------------- | --------------------------------------------------------------------------- |
+| `name`   | `string`              | Unique channel identifier. Becomes the `source` attribute in notifications. |
+| `source` | `ChannelSourceConfig` | Event source configuration (see Source Types below).                        |
 
 ### Optional Properties
 
-| Property | Type | Default | Description |
-| --- | --- | --- | --- |
-| `description` | `string` | — | Human-readable description. Included in server instructions for Claude. |
-| `twoWay` | `boolean` | `false` | Enable reply support. Auto-registers `channel-reply` tool when `true`. |
-| `meta` | `Record` | — | Static metadata appended to every notification from this channel. |
-| `tools` | `ToolType[]` | — | Tools contributed by this channel. Auto-registered in the scope's tool registry. |
-| `replay` | `{ enabled: boolean; maxEvents?: number }` | — | Buffer events for replay when sessions connect later. |
-| `tags` | `string[]` | — | Tags for categorization and filtering. |
-| `availableWhen` | `EntryAvailability` | — | Environment constraints (platform, runtime, env). |
+| Property        | Type                                       | Default | Description                                                                      |
+| --------------- | ------------------------------------------ | ------- | -------------------------------------------------------------------------------- |
+| `description`   | `string`                                   | —       | Human-readable description. Included in server instructions for Claude.          |
+| `twoWay`        | `boolean`                                  | `false` | Enable reply support. Auto-registers `channel-reply` tool when `true`.           |
+| `meta`          | `Record`                   | —       | Static metadata appended to every notification from this channel.                |
+| `tools`         | `ToolType[]`                               | —       | Tools contributed by this channel. Auto-registered in the scope's tool registry. |
+| `replay`        | `{ enabled: boolean; maxEvents?: number }` | —       | Buffer events for replay when sessions connect later.                            |
+| `tags`          | `string[]`                                 | —       | Tags for categorization and filtering.                                           |
+| `availableWhen` | `EntryAvailability`                        | —       | Environment constraints (platform, runtime, env).                                |
 
 ### Source Types
 
@@ -77,10 +77,10 @@ const ErrorChannel = channel({
 source: { type: 'webhook', path: '/hooks/github' }
 ```
 
-| Field | Type | Description |
-| --- | --- | --- |
-| `type` | `'webhook'` | Source discriminator |
-| `path` | `string` | HTTP POST endpoint path |
+| Field  | Type        | Description             |
+| ------ | ----------- | ----------------------- |
+| `type` | `'webhook'` | Source discriminator    |
+| `path` | `string`    | HTTP POST endpoint path |
 
 #### App Event
 
@@ -88,10 +88,10 @@ source: { type: 'webhook', path: '/hooks/github' }
 source: { type: 'app-event', event: 'app:error' }
 ```
 
-| Field | Type | Description |
-| --- | --- | --- |
-| `type` | `'app-event'` | Source discriminator |
-| `event` | `string` | Event name to subscribe to on `ChannelEventBus` |
+| Field   | Type          | Description                                     |
+| ------- | ------------- | ----------------------------------------------- |
+| `type`  | `'app-event'` | Source discriminator                            |
+| `event` | `string`      | Event name to subscribe to on `ChannelEventBus` |
 
 #### Agent Completion
 
@@ -99,10 +99,10 @@ source: { type: 'app-event', event: 'app:error' }
 source: { type: 'agent-completion', agentIds: ['code-reviewer'] }
 ```
 
-| Field | Type | Description |
-| --- | --- | --- |
-| `type` | `'agent-completion'` | Source discriminator |
-| `agentIds` | `string[]` | Optional filter — only these agent IDs trigger the channel |
+| Field      | Type                 | Description                                                |
+| ---------- | -------------------- | ---------------------------------------------------------- |
+| `type`     | `'agent-completion'` | Source discriminator                                       |
+| `agentIds` | `string[]`           | Optional filter — only these agent IDs trigger the channel |
 
 #### Job Completion
 
@@ -110,10 +110,10 @@ source: { type: 'agent-completion', agentIds: ['code-reviewer'] }
 source: { type: 'job-completion', jobNames: ['daily-report'] }
 ```
 
-| Field | Type | Description |
-| --- | --- | --- |
-| `type` | `'job-completion'` | Source discriminator |
-| `jobNames` | `string[]` | Optional filter — only these job names trigger the channel |
+| Field      | Type               | Description                                                |
+| ---------- | ------------------ | ---------------------------------------------------------- |
+| `type`     | `'job-completion'` | Source discriminator                                       |
+| `jobNames` | `string[]`         | Optional filter — only these job names trigger the channel |
 
 #### Service
 
@@ -121,10 +121,10 @@ source: { type: 'job-completion', jobNames: ['daily-report'] }
 source: { type: 'service', service: 'whatsapp-business' }
 ```
 
-| Field | Type | Description |
-| --- | --- | --- |
-| `type` | `'service'` | Source discriminator |
-| `service` | `string` | Human-readable service identifier |
+| Field     | Type        | Description                       |
+| --------- | ----------- | --------------------------------- |
+| `type`    | `'service'` | Source discriminator              |
+| `service` | `string`    | Human-readable service identifier |
 
 Channels with `service` source use `onConnect()` / `onDisconnect()` lifecycle hooks and `pushIncoming()` for feeding incoming events.
 
@@ -134,10 +134,10 @@ Channels with `service` source use `onConnect()` / `onDisconnect()` lifecycle ho
 source: { type: 'file-watcher', paths: ['./logs/*.log'], events: ['change'] }
 ```
 
-| Field | Type | Description |
-| --- | --- | --- |
-| `type` | `'file-watcher'` | Source discriminator |
-| `paths` | `string[]` | Glob patterns or file paths to watch |
+| Field    | Type                                                  | Description                          |
+| -------- | ----------------------------------------------------- | ------------------------------------ |
+| `type`   | `'file-watcher'`                                      | Source discriminator                 |
+| `paths`  | `string[]`                                            | Glob patterns or file paths to watch |
 | `events` | `Array<'change' \| 'create' \| 'delete' \| 'rename'>` | Optional file system events to watch |
 
 #### Manual
@@ -192,11 +192,11 @@ class ChatBridge extends ChannelContext {
 
 The `channel-reply` tool is auto-registered with this input schema:
 
-| Field | Type | Required | Description |
-| --- | --- | --- | --- |
-| `channel_name` | `string` | Yes | Target channel name |
-| `text` | `string` | Yes | Reply text |
-| `meta` | `Record` | No | Metadata passed to `onReply()` |
+| Field          | Type                     | Required | Description                    |
+| -------------- | ------------------------ | -------- | ------------------------------ |
+| `channel_name` | `string`                 | Yes      | Target channel name            |
+| `text`         | `string`                 | Yes      | Reply text                     |
+| `meta`         | `Record` | No       | Metadata passed to `onReply()` |
 
 ## Server Configuration
 
@@ -211,10 +211,10 @@ Enable channels at the server level:
 })
 ```
 
-| Option | Type | Default | Description |
-| --- | --- | --- | --- |
-| `enabled` | `boolean` | `false` | Enable the channel system |
-| `defaultMeta` | `Record` | — | Metadata appended to all channel notifications |
+| Option        | Type                     | Default | Description                                    |
+| ------------- | ------------------------ | ------- | ---------------------------------------------- |
+| `enabled`     | `boolean`                | `false` | Enable the channel system                      |
+| `defaultMeta` | `Record` | —       | Metadata appended to all channel notifications |
 
 ## Related
 
diff --git a/docs/frontmcp/sdk-reference/decorators/frontmcp.mdx b/docs/frontmcp/sdk-reference/decorators/frontmcp.mdx
index 36c9029ff..baa83e2c1 100644
--- a/docs/frontmcp/sdk-reference/decorators/frontmcp.mdx
+++ b/docs/frontmcp/sdk-reference/decorators/frontmcp.mdx
@@ -131,25 +131,25 @@ Prompts, agents, jobs, workflows, channels, and adapters are declared at the `@A
 
 ### Feature Configuration
 
-| Property        | Type                                        | Description                                                                                                                                                                |
-| --------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `auth`          | `AuthOptionsInput`                          | Authentication mode + provider configuration                                                                                                                               |
-| `authorities`   | `AuthoritiesConfig` (`@frontmcp/auth`)      | RBAC/ABAC/ReBAC profiles & enforcement                                                                                                                                     |
-| `logging`       | `LoggingOptionsInput`                       | Logging configuration                                                                                                                                                      |
-| `observability` | `ObservabilityOptionsInterface \| boolean`  | OpenTelemetry tracing + metrics                                                                                                                                            |
-| `health`        | `HealthOptionsInput`                        | Health check endpoints                                                                                                                                                     |
-| `pagination`    | `PaginationOptions`                         | List operation pagination                                                                                                                                                  |
-| `elicitation`   | `ElicitationOptionsInput`                   | Interactive user input                                                                                                                                                     |
-| `skillsConfig`  | `SkillsConfigOptionsInput`                  | Skills HTTP endpoints, `injectInstructions` policy, tamper-evident `audit` log — see [Skill catalog injection](#skill-catalog-injection) and [Audit log](#audit-log) below |
-| `tasks`         | object — see source for shape               | Background task store + scheduler                                                                                                                                          |
-| `jobs`          | `{ enabled: boolean; store?: ... }`         | Jobs runtime configuration                                                                                                                                                 |
-| `throttle`      | `GuardConfig` (`@frontmcp/guard`)           | Per-tool rate limiting / concurrency / timeouts                                                                                                                            |
-| `extApps`       | `ExtAppsOptionsInput`                       | External MCP Apps widget configuration                                                                                                                                     |
-| `loader`        | `PackageLoader`                             | ESM dynamic loader configuration                                                                                                                                           |
+| Property        | Type                                        | Description                                                                                                                                                                                                                                                     |
+| --------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `auth`          | `AuthOptionsInput`                          | Authentication mode + provider configuration                                                                                                                                                                                                                    |
+| `authorities`   | `AuthoritiesConfig` (`@frontmcp/auth`)      | RBAC/ABAC/ReBAC profiles & enforcement                                                                                                                                                                                                                          |
+| `logging`       | `LoggingOptionsInput`                       | Logging configuration                                                                                                                                                                                                                                           |
+| `observability` | `ObservabilityOptionsInterface \| boolean`  | OpenTelemetry tracing + metrics                                                                                                                                                                                                                                 |
+| `health`        | `HealthOptionsInput`                        | Health check endpoints                                                                                                                                                                                                                                          |
+| `pagination`    | `PaginationOptions`                         | List operation pagination                                                                                                                                                                                                                                       |
+| `elicitation`   | `ElicitationOptionsInput`                   | Interactive user input                                                                                                                                                                                                                                          |
+| `skillsConfig`  | `SkillsConfigOptionsInput`                  | Skills HTTP endpoints, `injectInstructions` policy, tamper-evident `audit` log — see [Skill catalog injection](#skill-catalog-injection) and [Audit log](#audit-log) below                                                                                      |
+| `tasks`         | object — see source for shape               | Background task store + scheduler                                                                                                                                                                                                                               |
+| `jobs`          | `{ enabled: boolean; store?: ... }`         | Jobs runtime configuration                                                                                                                                                                                                                                      |
+| `throttle`      | `GuardConfig` (`@frontmcp/guard`)           | Per-tool rate limiting / concurrency / timeouts                                                                                                                                                                                                                 |
+| `extApps`       | `ExtAppsOptionsInput`                       | External MCP Apps widget configuration                                                                                                                                                                                                                          |
+| `loader`        | `PackageLoader`                             | ESM dynamic loader configuration                                                                                                                                                                                                                                |
 | `output`        | `OutputPolicy`                              | Server-wide output-validation + output-schema exposure policy (`allowNonFinite`, `schemaMode`, `schemaDescriptionFormat`). Overridable per `@App` / `@Tool` (Tool > App > server). See [Output schema exposure](/frontmcp/servers/tools#output-schema-exposure) |
-| `sqlite`        | `SqliteOptionsInput`                        | SQLite session/store configuration                                                                                                                                         |
-| `ui`            | `{ cdnOverrides?: Record }` | UI CDN override configuration                                                                                                                                              |
-| `channels`      | `ChannelsConfigInput`                       | Channel notifications configuration                                                                                                                                        |
+| `sqlite`        | `SqliteOptionsInput`                        | SQLite session/store configuration                                                                                                                                                                                                                              |
+| `ui`            | `{ cdnOverrides?: Record }` | UI CDN override configuration                                                                                                                                                                                                                                   |
+| `channels`      | `ChannelsConfigInput`                       | Channel notifications configuration                                                                                                                                                                                                                             |
 
 ```typescript
 @FrontMcp({
diff --git a/docs/frontmcp/sdk-reference/decorators/plugin.mdx b/docs/frontmcp/sdk-reference/decorators/plugin.mdx
index 6e32e12e5..ad97cff34 100644
--- a/docs/frontmcp/sdk-reference/decorators/plugin.mdx
+++ b/docs/frontmcp/sdk-reference/decorators/plugin.mdx
@@ -61,16 +61,16 @@ function Plugin(providedMetadata: PluginMetadata): ClassDecorator
 
 ### Components
 
-| Property    | Type             | Description                                                            |
-| ----------- | ---------------- | ---------------------------------------------------------------------- |
-| `providers` | `ProviderType[]` | Plugin-scoped providers                                                |
-| `exports`   | `ProviderType[]` | Providers re-exported to the host app or other plugins                 |
-| `tools`     | `ToolType[]`     | Plugin tools                                                           |
-| `resources` | `ResourceType[]` | Plugin resources                                                       |
-| `prompts`   | `PromptType[]`   | Plugin prompts                                                         |
-| `skills`    | `SkillType[]`    | Plugin skills                                                          |
-| `adapters`  | `AdapterType[]`  | Framework adapters                                                     |
-| `plugins`   | `PluginType[]`   | Nested plugins                                                         |
+| Property    | Type             | Description                                            |
+| ----------- | ---------------- | ------------------------------------------------------ |
+| `providers` | `ProviderType[]` | Plugin-scoped providers                                |
+| `exports`   | `ProviderType[]` | Providers re-exported to the host app or other plugins |
+| `tools`     | `ToolType[]`     | Plugin tools                                           |
+| `resources` | `ResourceType[]` | Plugin resources                                       |
+| `prompts`   | `PromptType[]`   | Plugin prompts                                         |
+| `skills`    | `SkillType[]`    | Plugin skills                                          |
+| `adapters`  | `AdapterType[]`  | Framework adapters                                     |
+| `plugins`   | `PluginType[]`   | Nested plugins                                         |
 
 ### Extensions
 
diff --git a/docs/frontmcp/sdk-reference/decorators/prompt.mdx b/docs/frontmcp/sdk-reference/decorators/prompt.mdx
index 3d16022e9..d7067e299 100644
--- a/docs/frontmcp/sdk-reference/decorators/prompt.mdx
+++ b/docs/frontmcp/sdk-reference/decorators/prompt.mdx
@@ -58,13 +58,13 @@ The `@Prompt` decorator validates at compile time that the decorated class exten
 
 ### Optional Properties
 
-| Property        | Type                 | Description                               |
-| --------------- | -------------------- | ----------------------------------------- |
-| `title`         | `string`             | Human-readable title                      |
-| `description`   | `string`             | Prompt description                        |
-| `arguments`     | `PromptArgument[]`   | Prompt argument definitions               |
-| `icons`         | `Icon[]`             | Icons for display                         |
-| `availableWhen` | `EntryAvailability`  | Environment availability constraint        |
+| Property        | Type                | Description                         |
+| --------------- | ------------------- | ----------------------------------- |
+| `title`         | `string`            | Human-readable title                |
+| `description`   | `string`            | Prompt description                  |
+| `arguments`     | `PromptArgument[]`  | Prompt argument definitions         |
+| `icons`         | `Icon[]`            | Icons for display                   |
+| `availableWhen` | `EntryAvailability` | Environment availability constraint |
 
 ### Argument Definition
 
diff --git a/docs/frontmcp/sdk-reference/decorators/provider.mdx b/docs/frontmcp/sdk-reference/decorators/provider.mdx
index 26c570700..a696e2d42 100644
--- a/docs/frontmcp/sdk-reference/decorators/provider.mdx
+++ b/docs/frontmcp/sdk-reference/decorators/provider.mdx
@@ -30,12 +30,12 @@ function Provider(providedMetadata: ProviderMetadata): ClassDecorator
 
 ## Configuration Options
 
-| Property      | Type                    | Default    | Description                        |
-| ------------- | ----------------------- | ---------- | ---------------------------------- |
-| `name`        | `string` (required)     | —          | Human-readable provider name       |
-| `id`          | `string`                | —          | Optional unique identifier         |
-| `description` | `string`                | —          | Optional description               |
-| `scope`       | `'global' \| 'context'` | `'global'` | Provider lifetime scope            |
+| Property      | Type                    | Default    | Description                  |
+| ------------- | ----------------------- | ---------- | ---------------------------- |
+| `name`        | `string` (required)     | —          | Human-readable provider name |
+| `id`          | `string`                | —          | Optional unique identifier   |
+| `description` | `string`                | —          | Optional description         |
+| `scope`       | `'global' \| 'context'` | `'global'` | Provider lifetime scope      |
 
 `'session'` and `'request'` are kept as deprecated aliases of `'context'`.
 
diff --git a/docs/frontmcp/sdk-reference/decorators/resource.mdx b/docs/frontmcp/sdk-reference/decorators/resource.mdx
index f6055b1b2..1c2ebf77f 100644
--- a/docs/frontmcp/sdk-reference/decorators/resource.mdx
+++ b/docs/frontmcp/sdk-reference/decorators/resource.mdx
@@ -55,7 +55,7 @@ The same applies to `@ResourceTemplate`.
 | Property        | Type                | Description                          |
 | --------------- | ------------------- | ------------------------------------ |
 | `title`         | `string`            | Human-readable title                 |
-| `description`  | `string`            | Resource description                 |
+| `description`   | `string`            | Resource description                 |
 | `mimeType`      | `string`            | MIME type (e.g., 'application/json') |
 | `icons`         | `Icon[]`            | Icons for display                    |
 | `availableWhen` | `EntryAvailability` | Environment availability constraint  |
diff --git a/docs/frontmcp/sdk-reference/decorators/skill.mdx b/docs/frontmcp/sdk-reference/decorators/skill.mdx
index 2a67a37c3..4d184422c 100644
--- a/docs/frontmcp/sdk-reference/decorators/skill.mdx
+++ b/docs/frontmcp/sdk-reference/decorators/skill.mdx
@@ -93,13 +93,13 @@ function Skill(providedMetadata: SkillMetadata): ClassDecorator
 
 These properties align with the [Anthropic Agent Skills specification](https://agentskills.io/specification):
 
-| Property        | Type                     | Description                                                       |
-| --------------- | ------------------------ | ----------------------------------------------------------------- |
-| `license`       | `string`                 | License name or reference (e.g. `'MIT'`, `'Apache-2.0'`)          |
-| `compatibility` | `string`                 | Environment requirements (max 500 chars)                          |
-| `specMetadata`  | `Record` | Arbitrary key-value metadata (maps to spec `metadata`)            |
-| `allowedTools`  | `string`                 | Space-delimited pre-approved tools (maps to spec `allowed-tools`) |
-| `resources`     | `SkillResources`         | Bundled resource directories (`scripts`, `references`, `examples`, `assets`)  |
+| Property        | Type                     | Description                                                                  |
+| --------------- | ------------------------ | ---------------------------------------------------------------------------- |
+| `license`       | `string`                 | License name or reference (e.g. `'MIT'`, `'Apache-2.0'`)                     |
+| `compatibility` | `string`                 | Environment requirements (max 500 chars)                                     |
+| `specMetadata`  | `Record` | Arbitrary key-value metadata (maps to spec `metadata`)                       |
+| `allowedTools`  | `string`                 | Space-delimited pre-approved tools (maps to spec `allowed-tools`)            |
+| `resources`     | `SkillResources`         | Bundled resource directories (`scripts`, `references`, `examples`, `assets`) |
 
 ## Instruction Sources
 
diff --git a/docs/frontmcp/sdk-reference/decorators/tool.mdx b/docs/frontmcp/sdk-reference/decorators/tool.mdx
index 5225b8ff2..d7bd241a2 100644
--- a/docs/frontmcp/sdk-reference/decorators/tool.mdx
+++ b/docs/frontmcp/sdk-reference/decorators/tool.mdx
@@ -77,24 +77,24 @@ class BadTool extends ToolContext {
 
 ### Optional Properties
 
-| Property         | Type                        | Description                                                                       |
-| ---------------- | --------------------------- | --------------------------------------------------------------------------------- |
-| `description`    | `string`                    | Tool description for AI                                                            |
-| `outputSchema`   | `ZodType`                   | Output validation schema                                                           |
-| `id`             | `string`                    | Stable identifier for tracking                                                     |
-| `tags`           | `string[]`                  | Categorization tags                                                                |
-| `visibility`     | `'public' \| 'hidden' \| 'internal'` | Discovery visibility. `hidden` hides from public lists; `internal` keeps it for in-process callers only |
-| `rateLimit`      | `RateLimitConfig`           | Per-tool rate limit (`maxRequests`, `windowMs`, `partitionBy`)                     |
-| `concurrency`    | `ConcurrencyConfig`         | Per-tool concurrency cap (`maxConcurrent`, optional queue config)                  |
-| `timeout`        | `TimeoutConfig`             | Per-tool execution timeout (`executeMs`)                                           |
-| `availableWhen`  | `EntryAvailability`         | Restrict discovery to specific platforms/runtimes/deployments. See [Environment Awareness](/frontmcp/features/environment-awareness) |
-| `execution`      | `{ taskSupport?: 'required' \| 'optional' \| 'forbidden' }` | MCP 2025-11-25 task-execution mode for `tools/list` items |
-| `authProviders`  | `ToolAuthProviderRef[]`     | Required auth providers for this tool (string name or `{ name, required?, scopes?, alias? }`) |
-| `annotations`    | `ToolAnnotations`           | MCP hints (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) |
-| `examples`       | `ToolExample[]`             | Usage examples (`description`, `input`, optional `output`)                          |
-| `ui`             | `ToolUIConfig`              | UI widget configuration (`template`, `csp`, sanitization options)                   |
-| `output`         | `OutputPolicy`              | Output-validation + output-schema exposure policy (`allowNonFinite`, `schemaMode`, `schemaDescriptionFormat`). Resolved Tool > App > server > default. See [Output schema exposure](/frontmcp/servers/tools#output-schema-exposure) |
-| `hideFromDiscovery` | `boolean`                | Deprecated alias for `visibility: 'hidden'`                                         |
+| Property            | Type                                                        | Description                                                                                                                                                                                                                         |
+| ------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `description`       | `string`                                                    | Tool description for AI                                                                                                                                                                                                             |
+| `outputSchema`      | `ZodType`                                                   | Output validation schema                                                                                                                                                                                                            |
+| `id`                | `string`                                                    | Stable identifier for tracking                                                                                                                                                                                                      |
+| `tags`              | `string[]`                                                  | Categorization tags                                                                                                                                                                                                                 |
+| `visibility`        | `'public' \| 'hidden' \| 'internal'`                        | Discovery visibility. `hidden` hides from public lists; `internal` keeps it for in-process callers only                                                                                                                             |
+| `rateLimit`         | `RateLimitConfig`                                           | Per-tool rate limit (`maxRequests`, `windowMs`, `partitionBy`)                                                                                                                                                                      |
+| `concurrency`       | `ConcurrencyConfig`                                         | Per-tool concurrency cap (`maxConcurrent`, optional queue config)                                                                                                                                                                   |
+| `timeout`           | `TimeoutConfig`                                             | Per-tool execution timeout (`executeMs`)                                                                                                                                                                                            |
+| `availableWhen`     | `EntryAvailability`                                         | Restrict discovery to specific platforms/runtimes/deployments. See [Environment Awareness](/frontmcp/features/environment-awareness)                                                                                                |
+| `execution`         | `{ taskSupport?: 'required' \| 'optional' \| 'forbidden' }` | MCP 2025-11-25 task-execution mode for `tools/list` items                                                                                                                                                                           |
+| `authProviders`     | `ToolAuthProviderRef[]`                                     | Required auth providers for this tool (string name or `{ name, required?, scopes?, alias? }`)                                                                                                                                       |
+| `annotations`       | `ToolAnnotations`                                           | MCP hints (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`)                                                                                                                                           |
+| `examples`          | `ToolExample[]`                                             | Usage examples (`description`, `input`, optional `output`)                                                                                                                                                                          |
+| `ui`                | `ToolUIConfig`                                              | UI widget configuration (`template`, `csp`, sanitization options)                                                                                                                                                                   |
+| `output`            | `OutputPolicy`                                              | Output-validation + output-schema exposure policy (`allowNonFinite`, `schemaMode`, `schemaDescriptionFormat`). Resolved Tool > App > server > default. See [Output schema exposure](/frontmcp/servers/tools#output-schema-exposure) |
+| `hideFromDiscovery` | `boolean`                                                   | Deprecated alias for `visibility: 'hidden'`                                                                                                                                                                                         |
 
 ### Output Types
 
diff --git a/docs/frontmcp/sdk-reference/errors/auth-errors.mdx b/docs/frontmcp/sdk-reference/errors/auth-errors.mdx
index 21cdcf714..1e9fc361b 100644
--- a/docs/frontmcp/sdk-reference/errors/auth-errors.mdx
+++ b/docs/frontmcp/sdk-reference/errors/auth-errors.mdx
@@ -195,15 +195,15 @@ throw new AuthorizationRequiredError({
 
 Thrown when a tool is not in the active skill session's allowlist. Used by the [Tool Authorization Guard](/frontmcp/authentication/skills-auth#tool-authorization-guard).
 
-| Property | Type | Value |
-| -------- | ---- | ----- |
-| `code` | `string` | `TOOL_NOT_ALLOWED` |
-| `statusCode` | `number` | `400` |
-| `isPublic` | `boolean` | `true` |
-| `toolName` | `string` | The denied tool name |
-| `skillId` | `string \| undefined` | Active skill session ID |
-| `reason` | `string` | `not_in_allowlist`, `denied`, `rate_limited`, or `no_active_skill` |
-| `allowedTools` | `string[]` | List of allowed tool names |
+| Property       | Type                  | Value                                                              |
+| -------------- | --------------------- | ------------------------------------------------------------------ |
+| `code`         | `string`              | `TOOL_NOT_ALLOWED`                                                 |
+| `statusCode`   | `number`              | `400`                                                              |
+| `isPublic`     | `boolean`             | `true`                                                             |
+| `toolName`     | `string`              | The denied tool name                                               |
+| `skillId`      | `string \| undefined` | Active skill session ID                                            |
+| `reason`       | `string`              | `not_in_allowlist`, `denied`, `rate_limited`, or `no_active_skill` |
+| `allowedTools` | `string[]`            | List of allowed tool names                                         |
 
 ```typescript
 import { ToolNotAllowedError } from '@frontmcp/sdk';
@@ -215,13 +215,13 @@ import { ToolNotAllowedError } from '@frontmcp/sdk';
 
 Thrown when a tool requires explicit approval before use within a skill session (approval policy mode).
 
-| Property | Type | Value |
-| -------- | ---- | ----- |
-| `code` | `string` | `TOOL_APPROVAL_REQUIRED` |
-| `statusCode` | `number` | `400` |
-| `isPublic` | `boolean` | `true` |
-| `toolName` | `string` | Tool requiring approval |
-| `skillId` | `string \| undefined` | Active skill session ID |
+| Property     | Type                  | Value                    |
+| ------------ | --------------------- | ------------------------ |
+| `code`       | `string`              | `TOOL_APPROVAL_REQUIRED` |
+| `statusCode` | `number`              | `400`                    |
+| `isPublic`   | `boolean`             | `true`                   |
+| `toolName`   | `string`              | Tool requiring approval  |
+| `skillId`    | `string \| undefined` | Active skill session ID  |
 
 ```typescript
 import { ToolApprovalRequiredError } from '@frontmcp/sdk';
@@ -231,15 +231,15 @@ import { ToolApprovalRequiredError } from '@frontmcp/sdk';
 
 ### ToolNotConsentedError
 
-Thrown when [consent](/frontmcp/authentication/consent) is enabled and the token's authorized-tools claim does not include the requested tool — the runtime enforcement of the consent screen. A user who unchecked a tool during authorization cannot invoke it. Distinguished from a missing tool (`-32601`) so clients can prompt the user to re-authorize and select it.
+Thrown when [consent](/frontmcp/authentication/local) is enabled and the token's authorized-tools claim does not include the requested tool — the runtime enforcement of the consent screen. A user who unchecked a tool during authorization cannot invoke it. Distinguished from a missing tool (`-32601`) so clients can prompt the user to re-authorize and select it.
 
-| Property | Type | Value |
-| -------- | ---- | ----- |
-| `code` | `string` | `TOOL_NOT_CONSENTED` |
-| `mcpErrorCode` | `number` | `-32003` (FORBIDDEN) |
-| `statusCode` | `number` | `403` |
-| `isPublic` | `boolean` | `true` |
-| `toolName` | `string` | The withheld tool name |
+| Property       | Type      | Value                  |
+| -------------- | --------- | ---------------------- |
+| `code`         | `string`  | `TOOL_NOT_CONSENTED`   |
+| `mcpErrorCode` | `number`  | `-32003` (FORBIDDEN)   |
+| `statusCode`   | `number`  | `403`                  |
+| `isPublic`     | `boolean` | `true`                 |
+| `toolName`     | `string`  | The withheld tool name |
 
 ```typescript
 import { ToolNotConsentedError } from '@frontmcp/sdk';
@@ -255,15 +255,15 @@ The JSON-RPC `data` payload carries `{ tool }`.
 
 Thrown by the call-tool flow's credential gate when a tool declares one or more `authProviders` with `required: true` and the credential for at least one of them is not available for the current session. This is the **tool-level** credential gate (distinct from `AuthorizationRequiredError`, which gates at the **app** level): a tool can be reachable yet still miss a per-provider credential. The call aborts before `execute()` runs.
 
-| Property | Type | Value |
-| -------- | ---- | ----- |
-| `code` | `string` | `TOOL_CREDENTIALS_REQUIRED` |
-| `mcpErrorCode` | `number` | `-32001` (UNAUTHORIZED) |
-| `statusCode` | `number` | `401` |
-| `isPublic` | `boolean` | `true` |
-| `toolId` | `string` | Tool that triggered the gate |
-| `providers` | `string[]` | Provider id(s) whose credential is required but missing |
-| `authUrl` | `string \| undefined` | Framework-signed connect/authorize URL, if resolvable |
+| Property       | Type                  | Value                                                   |
+| -------------- | --------------------- | ------------------------------------------------------- |
+| `code`         | `string`              | `TOOL_CREDENTIALS_REQUIRED`                             |
+| `mcpErrorCode` | `number`              | `-32001` (UNAUTHORIZED)                                 |
+| `statusCode`   | `number`              | `401`                                                   |
+| `isPublic`     | `boolean`             | `true`                                                  |
+| `toolId`       | `string`              | Tool that triggered the gate                            |
+| `providers`    | `string[]`            | Provider id(s) whose credential is required but missing |
+| `authUrl`      | `string \| undefined` | Framework-signed connect/authorize URL, if resolvable   |
 
 ```typescript
 import { ToolCredentialsRequiredError } from '@frontmcp/sdk';
diff --git a/docs/frontmcp/sdk-reference/errors/auth-internal-errors.mdx b/docs/frontmcp/sdk-reference/errors/auth-internal-errors.mdx
index 9ed40a5b7..f9fe65ae6 100644
--- a/docs/frontmcp/sdk-reference/errors/auth-internal-errors.mdx
+++ b/docs/frontmcp/sdk-reference/errors/auth-internal-errors.mdx
@@ -324,11 +324,11 @@ new OrchestratorJwksNotAvailableError()
 
 Thrown when an auth API receives malformed input (e.g., invalid JWK, malformed scope string).
 
-| Property     | Type      | Value                  |
-| ------------ | --------- | ---------------------- |
-| `code`       | `string`  | `AUTH_INVALID_INPUT`   |
-| `statusCode` | `number`  | `500`                  |
-| `isPublic`   | `boolean` | `false`                |
+| Property     | Type      | Value                |
+| ------------ | --------- | -------------------- |
+| `code`       | `string`  | `AUTH_INVALID_INPUT` |
+| `statusCode` | `number`  | `500`                |
+| `isPublic`   | `boolean` | `false`              |
 
 ```typescript
 new AuthInvalidInputError(message: string)
@@ -340,11 +340,11 @@ new AuthInvalidInputError(message: string)
 
 Thrown when a credential storage operation fails (vault read/write, key persistence).
 
-| Property     | Type      | Value                       |
-| ------------ | --------- | --------------------------- |
-| `code`       | `string`  | `CREDENTIAL_STORAGE_ERROR`  |
-| `statusCode` | `number`  | `500`                       |
-| `isPublic`   | `boolean` | `false`                     |
+| Property     | Type      | Value                      |
+| ------------ | --------- | -------------------------- |
+| `code`       | `string`  | `CREDENTIAL_STORAGE_ERROR` |
+| `statusCode` | `number`  | `500`                      |
+| `isPublic`   | `boolean` | `false`                    |
 
 ```typescript
 new CredentialStorageError(message: string)
@@ -356,11 +356,11 @@ new CredentialStorageError(message: string)
 
 Thrown when a federated auth flow encounters an error (state mismatch, callback failure, etc.).
 
-| Property     | Type      | Value             |
-| ------------ | --------- | ----------------- |
+| Property     | Type      | Value                                           |
+| ------------ | --------- | ----------------------------------------------- |
 | `code`       | `string`  | `AUTH_FLOW_ERROR` (or override via constructor) |
-| `statusCode` | `number`  | `500`                 |
-| `isPublic`   | `boolean` | `false`               |
+| `statusCode` | `number`  | `500`                                           |
+| `isPublic`   | `boolean` | `false`                                         |
 
 ```typescript
 new AuthFlowError(message: string, code?: string)
diff --git a/docs/frontmcp/sdk-reference/errors/esm-errors.mdx b/docs/frontmcp/sdk-reference/errors/esm-errors.mdx
index 8572394f9..57c7c8657 100644
--- a/docs/frontmcp/sdk-reference/errors/esm-errors.mdx
+++ b/docs/frontmcp/sdk-reference/errors/esm-errors.mdx
@@ -21,14 +21,14 @@ import {
 
 ## Overview
 
-| Error | Base Class | Error Code | HTTP | Description |
-|-------|-----------|------------|------|-------------|
-| `EsmPackageLoadError` | `InternalMcpError` | `ESM_PACKAGE_LOAD_ERROR` | 500 | Bundle fetch or module evaluation failed |
-| `EsmVersionResolutionError` | `InternalMcpError` | `ESM_VERSION_RESOLUTION_ERROR` | 500 | npm registry version resolution failed |
-| `EsmManifestInvalidError` | `PublicMcpError` | `-32602` (INVALID_PARAMS) | 400 | Package manifest is invalid or missing required fields |
-| `EsmCacheError` | `InternalMcpError` | `ESM_CACHE_ERROR` | 500 | Cache read/write operation failed |
-| `EsmRegistryAuthError` | `PublicMcpError` | `-32001` (UNAUTHORIZED) | 401 | Private registry authentication failed |
-| `EsmInvalidSpecifierError` | `PublicMcpError` | `-32602` (INVALID_PARAMS) | 400 | Package specifier string is malformed |
+| Error                       | Base Class         | Error Code                     | HTTP | Description                                            |
+| --------------------------- | ------------------ | ------------------------------ | ---- | ------------------------------------------------------ |
+| `EsmPackageLoadError`       | `InternalMcpError` | `ESM_PACKAGE_LOAD_ERROR`       | 500  | Bundle fetch or module evaluation failed               |
+| `EsmVersionResolutionError` | `InternalMcpError` | `ESM_VERSION_RESOLUTION_ERROR` | 500  | npm registry version resolution failed                 |
+| `EsmManifestInvalidError`   | `PublicMcpError`   | `-32602` (INVALID_PARAMS)      | 400  | Package manifest is invalid or missing required fields |
+| `EsmCacheError`             | `InternalMcpError` | `ESM_CACHE_ERROR`              | 500  | Cache read/write operation failed                      |
+| `EsmRegistryAuthError`      | `PublicMcpError`   | `-32001` (UNAUTHORIZED)        | 401  | Private registry authentication failed                 |
+| `EsmInvalidSpecifierError`  | `PublicMcpError`   | `-32602` (INVALID_PARAMS)      | 400  | Package specifier string is malformed                  |
 
 ---
 
@@ -44,11 +44,11 @@ class EsmPackageLoadError extends InternalMcpError {
 }
 ```
 
-| Property | Type | Description |
-|----------|------|-------------|
-| `packageName` | `string` | Full package name (e.g., `@acme/tools`) |
-| `version` | `string?` | Resolved version that failed to load |
-| `originalError` | `Error?` | Underlying fetch or evaluation error |
+| Property        | Type      | Description                             |
+| --------------- | --------- | --------------------------------------- |
+| `packageName`   | `string`  | Full package name (e.g., `@acme/tools`) |
+| `version`       | `string?` | Resolved version that failed to load    |
+| `originalError` | `Error?`  | Underlying fetch or evaluation error    |
 
 ---
 
@@ -64,11 +64,11 @@ class EsmVersionResolutionError extends InternalMcpError {
 }
 ```
 
-| Property | Type | Description |
-|----------|------|-------------|
-| `packageName` | `string` | Full package name |
-| `range` | `string` | Semver range that failed to resolve (e.g., `^3.0.0`) |
-| `originalError` | `Error?` | Underlying registry error |
+| Property        | Type     | Description                                          |
+| --------------- | -------- | ---------------------------------------------------- |
+| `packageName`   | `string` | Full package name                                    |
+| `range`         | `string` | Semver range that failed to resolve (e.g., `^3.0.0`) |
+| `originalError` | `Error?` | Underlying registry error                            |
 
 ---
 
@@ -84,10 +84,10 @@ class EsmManifestInvalidError extends PublicMcpError {
 }
 ```
 
-| Property | Type | Description |
-|----------|------|-------------|
-| `packageName` | `string` | Full package name |
-| `details` | `string?` | Zod validation error details |
+| Property      | Type      | Description                  |
+| ------------- | --------- | ---------------------------- |
+| `packageName` | `string`  | Full package name            |
+| `details`     | `string?` | Zod validation error details |
 
 ---
 
@@ -103,11 +103,11 @@ class EsmCacheError extends InternalMcpError {
 }
 ```
 
-| Property | Type | Description |
-|----------|------|-------------|
-| `operation` | `string` | Cache operation that failed (e.g., `'get'`, `'put'`, `'cleanup'`) |
-| `packageName` | `string?` | Package involved in the operation |
-| `originalError` | `Error?` | Underlying I/O error |
+| Property        | Type      | Description                                                       |
+| --------------- | --------- | ----------------------------------------------------------------- |
+| `operation`     | `string`  | Cache operation that failed (e.g., `'get'`, `'put'`, `'cleanup'`) |
+| `packageName`   | `string?` | Package involved in the operation                                 |
+| `originalError` | `Error?`  | Underlying I/O error                                              |
 
 ---
 
@@ -123,10 +123,10 @@ class EsmRegistryAuthError extends PublicMcpError {
 }
 ```
 
-| Property | Type | Description |
-|----------|------|-------------|
+| Property      | Type      | Description                               |
+| ------------- | --------- | ----------------------------------------- |
 | `registryUrl` | `string?` | Registry URL that rejected authentication |
-| `details` | `string?` | Additional error details |
+| `details`     | `string?` | Additional error details                  |
 
 ---
 
@@ -141,6 +141,6 @@ class EsmInvalidSpecifierError extends PublicMcpError {
 }
 ```
 
-| Property | Type | Description |
-|----------|------|-------------|
+| Property    | Type     | Description                  |
+| ----------- | -------- | ---------------------------- |
 | `specifier` | `string` | The invalid specifier string |
diff --git a/docs/frontmcp/sdk-reference/errors/overview.mdx b/docs/frontmcp/sdk-reference/errors/overview.mdx
index 647c42756..8e8e97565 100644
--- a/docs/frontmcp/sdk-reference/errors/overview.mdx
+++ b/docs/frontmcp/sdk-reference/errors/overview.mdx
@@ -205,14 +205,14 @@ class ResourceNotFoundError extends PublicMcpError {
 
 ## Error Properties
 
-| Property       | Type      | Description                             |
-| -------------- | --------- | --------------------------------------- |
-| `errorId`      | `string`  | Unique ID for tracking (auto-generated) |
-| `isPublic`     | `boolean` | Whether to expose message to clients    |
-| `statusCode`   | `number`  | HTTP status code equivalent             |
+| Property          | Type      | Description                                                                   |
+| ----------------- | --------- | ----------------------------------------------------------------------------- |
+| `errorId`         | `string`  | Unique ID for tracking (auto-generated)                                       |
+| `isPublic`        | `boolean` | Whether to expose message to clients                                          |
+| `statusCode`      | `number`  | HTTP status code equivalent                                                   |
 | `wwwAuthenticate` | `string?` | Optional `WWW-Authenticate` header value (e.g. `'Bearer'`) for 401 challenges |
-| `code`         | `string`  | Error code for categorization           |
-| `mcpErrorCode` | `number`  | JSON-RPC error code (optional)          |
+| `code`            | `string`  | Error code for categorization                                                 |
+| `mcpErrorCode`    | `number`  | JSON-RPC error code (optional)                                                |
 
 ## Best Practices
 
diff --git a/docs/frontmcp/sdk-reference/errors/task-errors.mdx b/docs/frontmcp/sdk-reference/errors/task-errors.mdx
index dd628df31..cfa6dc120 100644
--- a/docs/frontmcp/sdk-reference/errors/task-errors.mdx
+++ b/docs/frontmcp/sdk-reference/errors/task-errors.mdx
@@ -9,13 +9,13 @@ These errors surface from the task-augmented tool invocation system (per the MCP
 
 ## Reference
 
-| Class                                | Inherits          | JSON-RPC Code | When                                                                          |
-| ------------------------------------ | ----------------- | ------------- | ----------------------------------------------------------------------------- |
-| `TaskNotFoundError`                  | `PublicMcpError`  | `-32602`      | `tasks/get`, `tasks/cancel`, or `tasks/result` referenced an unknown task ID  |
-| `TaskAlreadyTerminalError`           | `PublicMcpError`  | `-32602`      | `tasks/cancel` invoked on a task already in `succeeded`/`failed`/`cancelled`  |
-| `TaskAugmentationNotSupportedError`  | `PublicMcpError`  | `-32601`      | Client invoked `tools/call` with task augmentation on a tool that opts out    |
-| `TaskAugmentationRequiredError`      | `PublicMcpError`  | `-32601`      | Tool declared `taskSupport: 'required'` but client did not request a task     |
-| `TaskStoreNotInitializedError`       | `InternalMcpError`| —             | Internal: task store provider not registered when task subsystem is required  |
+| Class                               | Inherits           | JSON-RPC Code | When                                                                         |
+| ----------------------------------- | ------------------ | ------------- | ---------------------------------------------------------------------------- |
+| `TaskNotFoundError`                 | `PublicMcpError`   | `-32602`      | `tasks/get`, `tasks/cancel`, or `tasks/result` referenced an unknown task ID |
+| `TaskAlreadyTerminalError`          | `PublicMcpError`   | `-32602`      | `tasks/cancel` invoked on a task already in `succeeded`/`failed`/`cancelled` |
+| `TaskAugmentationNotSupportedError` | `PublicMcpError`   | `-32601`      | Client invoked `tools/call` with task augmentation on a tool that opts out   |
+| `TaskAugmentationRequiredError`     | `PublicMcpError`   | `-32601`      | Tool declared `taskSupport: 'required'` but client did not request a task    |
+| `TaskStoreNotInitializedError`      | `InternalMcpError` | —             | Internal: task store provider not registered when task subsystem is required |
 
 ## Usage
 
diff --git a/docs/frontmcp/sdk-reference/errors/transport-errors.mdx b/docs/frontmcp/sdk-reference/errors/transport-errors.mdx
index 8d576d02d..5548a400f 100644
--- a/docs/frontmcp/sdk-reference/errors/transport-errors.mdx
+++ b/docs/frontmcp/sdk-reference/errors/transport-errors.mdx
@@ -171,10 +171,10 @@ throw new UnsupportedContentTypeError('text/xml');
 
 Thrown when the transport service is requested but not registered (typically a misconfigured server bootstrap).
 
-| Property     | Type      | Value                              |
-| ------------ | --------- | ---------------------------------- |
-| `code`       | `string`  | `TRANSPORT_SERVICE_NOT_AVAILABLE`  |
-| `isPublic`   | `boolean` | `false`                            |
+| Property   | Type      | Value                             |
+| ---------- | --------- | --------------------------------- |
+| `code`     | `string`  | `TRANSPORT_SERVICE_NOT_AVAILABLE` |
+| `isPublic` | `boolean` | `false`                           |
 
 ---
 
@@ -182,10 +182,10 @@ Thrown when the transport service is requested but not registered (typically a m
 
 Thrown in HA deployments when another pod claims an active session ID. The local pod must drop its in-memory state for that session.
 
-| Property     | Type      | Value                    |
-| ------------ | --------- | ------------------------ |
-| `code`       | `string`  | `SESSION_CLAIM_CONFLICT` |
-| `isPublic`   | `boolean` | `false`                  |
+| Property   | Type      | Value                    |
+| ---------- | --------- | ------------------------ |
+| `code`     | `string`  | `SESSION_CLAIM_CONFLICT` |
+| `isPublic` | `boolean` | `false`                  |
 
 ```typescript
 import { SessionClaimConflictError } from '@frontmcp/sdk';
diff --git a/docs/frontmcp/sdk-reference/errors/workflow-errors.mdx b/docs/frontmcp/sdk-reference/errors/workflow-errors.mdx
index 6c91751cf..b7a06344a 100644
--- a/docs/frontmcp/sdk-reference/errors/workflow-errors.mdx
+++ b/docs/frontmcp/sdk-reference/errors/workflow-errors.mdx
@@ -9,12 +9,12 @@ These errors surface from the workflow runtime when a multi-step DAG fails to va
 
 ## Reference
 
-| Class                       | Inherits           | When                                                         |
-| --------------------------- | ------------------ | ------------------------------------------------------------ |
-| `WorkflowStepNotFoundError` | `InternalMcpError` | A step alias was referenced before it completed              |
-| `WorkflowTimeoutError`      | `InternalMcpError` | The whole workflow exceeded its `timeoutMs`                  |
-| `WorkflowDagValidationError`| `InternalMcpError` | The declared DAG had a cycle, missing dependency, or bad ref |
-| `WorkflowJobTimeoutError`   | `InternalMcpError` | An individual job step exceeded its `timeoutMs`              |
+| Class                        | Inherits           | When                                                         |
+| ---------------------------- | ------------------ | ------------------------------------------------------------ |
+| `WorkflowStepNotFoundError`  | `InternalMcpError` | A step alias was referenced before it completed              |
+| `WorkflowTimeoutError`       | `InternalMcpError` | The whole workflow exceeded its `timeoutMs`                  |
+| `WorkflowDagValidationError` | `InternalMcpError` | The declared DAG had a cycle, missing dependency, or bad ref |
+| `WorkflowJobTimeoutError`    | `InternalMcpError` | An individual job step exceeded its `timeoutMs`              |
 
 ## Usage
 
diff --git a/docs/frontmcp/sdk-reference/guard.mdx b/docs/frontmcp/sdk-reference/guard.mdx
index 70b06eccf..865598393 100644
--- a/docs/frontmcp/sdk-reference/guard.mdx
+++ b/docs/frontmcp/sdk-reference/guard.mdx
@@ -24,57 +24,57 @@ When using `@frontmcp/sdk`, guard features are integrated automatically via the
 
 Top-level configuration for the guard system. Passed to `@FrontMcp({ throttle: ... })` or `createGuardManager()`.
 
-| Field | Type | Default | Description |
-| ----- | ---- | ------- | ----------- |
-| `enabled` | `boolean` | *required* | Enable or disable all guard features |
-| `storage` | `StorageConfig` | in-memory | Storage backend configuration |
-| `keyPrefix` | `string` | `'mcp:guard:'` | Prefix for all storage keys |
-| `global` | `RateLimitConfig` | — | Global rate limit for all requests |
-| `globalConcurrency` | `ConcurrencyConfig` | — | Global concurrency limit |
-| `defaultRateLimit` | `RateLimitConfig` | — | Default rate limit for entities without explicit config |
-| `defaultConcurrency` | `ConcurrencyConfig` | — | Default concurrency for entities without explicit config |
-| `defaultTimeout` | `TimeoutConfig` | — | Default timeout for entity execution |
-| `ipFilter` | `IpFilterConfig` | — | IP filtering configuration |
+| Field                | Type                | Default        | Description                                              |
+| -------------------- | ------------------- | -------------- | -------------------------------------------------------- |
+| `enabled`            | `boolean`           | _required_     | Enable or disable all guard features                     |
+| `storage`            | `StorageConfig`     | in-memory      | Storage backend configuration                            |
+| `keyPrefix`          | `string`            | `'mcp:guard:'` | Prefix for all storage keys                              |
+| `global`             | `RateLimitConfig`   | —              | Global rate limit for all requests                       |
+| `globalConcurrency`  | `ConcurrencyConfig` | —              | Global concurrency limit                                 |
+| `defaultRateLimit`   | `RateLimitConfig`   | —              | Default rate limit for entities without explicit config  |
+| `defaultConcurrency` | `ConcurrencyConfig` | —              | Default concurrency for entities without explicit config |
+| `defaultTimeout`     | `TimeoutConfig`     | —              | Default timeout for entity execution                     |
+| `ipFilter`           | `IpFilterConfig`    | —              | IP filtering configuration                               |
 
 ### `RateLimitConfig`
 
 Configuration for sliding window rate limiting.
 
-| Field | Type | Default | Description |
-| ----- | ---- | ------- | ----------- |
-| `maxRequests` | `number` | *required* | Maximum requests allowed in the window |
-| `windowMs` | `number` | `60000` | Time window in milliseconds |
-| `partitionBy` | `PartitionKey` | `'global'` | How to bucket rate limits |
+| Field         | Type           | Default    | Description                            |
+| ------------- | -------------- | ---------- | -------------------------------------- |
+| `maxRequests` | `number`       | _required_ | Maximum requests allowed in the window |
+| `windowMs`    | `number`       | `60000`    | Time window in milliseconds            |
+| `partitionBy` | `PartitionKey` | `'global'` | How to bucket rate limits              |
 
 ### `ConcurrencyConfig`
 
 Configuration for distributed semaphore concurrency control.
 
-| Field | Type | Default | Description |
-| ----- | ---- | ------- | ----------- |
-| `maxConcurrent` | `number` | *required* | Maximum simultaneous executions |
-| `queueTimeoutMs` | `number` | `0` | Max time (ms) to wait for a slot. `0` = reject immediately |
-| `partitionBy` | `PartitionKey` | `'global'` | How to bucket concurrency limits |
+| Field            | Type           | Default    | Description                                                |
+| ---------------- | -------------- | ---------- | ---------------------------------------------------------- |
+| `maxConcurrent`  | `number`       | _required_ | Maximum simultaneous executions                            |
+| `queueTimeoutMs` | `number`       | `0`        | Max time (ms) to wait for a slot. `0` = reject immediately |
+| `partitionBy`    | `PartitionKey` | `'global'` | How to bucket concurrency limits                           |
 
 ### `TimeoutConfig`
 
 Configuration for execution timeout.
 
-| Field | Type | Default | Description |
-| ----- | ---- | ------- | ----------- |
-| `executeMs` | `number` | *required* | Maximum execution time in milliseconds |
+| Field       | Type     | Default    | Description                            |
+| ----------- | -------- | ---------- | -------------------------------------- |
+| `executeMs` | `number` | _required_ | Maximum execution time in milliseconds |
 
 ### `IpFilterConfig`
 
 Configuration for IP-based access control.
 
-| Field | Type | Default | Description |
-| ----- | ---- | ------- | ----------- |
-| `allowList` | `string[]` | `[]` | IPs or CIDR ranges to always allow |
-| `denyList` | `string[]` | `[]` | IPs or CIDR ranges to always block |
-| `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list |
-| `trustProxy` | `boolean` | `false` | Trust `X-Forwarded-For` header |
-| `trustedProxyDepth` | `number` | `1` | Max proxy hops to trust |
+| Field               | Type                | Default   | Description                         |
+| ------------------- | ------------------- | --------- | ----------------------------------- |
+| `allowList`         | `string[]`          | `[]`      | IPs or CIDR ranges to always allow  |
+| `denyList`          | `string[]`          | `[]`      | IPs or CIDR ranges to always block  |
+| `defaultAction`     | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list |
+| `trustProxy`        | `boolean`           | `false`   | Trust `X-Forwarded-For` header      |
+| `trustedProxyDepth` | `number`            | `1`       | Max proxy hops to trust             |
 
 ### `PartitionKey`
 
@@ -90,11 +90,11 @@ type CustomPartitionKeyFn = (ctx: PartitionKeyContext) => string;
 
 **`PartitionKeyContext`:**
 
-| Field | Type | Description |
-| ----- | ---- | ----------- |
-| `sessionId` | `string` | MCP session identifier |
-| `clientIp` | `string \| undefined` | Client IP address |
-| `userId` | `string \| undefined` | Authenticated user identifier |
+| Field       | Type                  | Description                   |
+| ----------- | --------------------- | ----------------------------- |
+| `sessionId` | `string`              | MCP session identifier        |
+| `clientIp`  | `string \| undefined` | Client IP address             |
+| `userId`    | `string \| undefined` | Authenticated user identifier |
 
 ---
 
@@ -124,11 +124,11 @@ async checkRateLimit(
 ): Promise
 ```
 
-| Parameter | Description |
-| --------- | ----------- |
-| `entityName` | Tool or agent name |
+| Parameter      | Description                                                                           |
+| -------------- | ------------------------------------------------------------------------------------- |
+| `entityName`   | Tool or agent name                                                                    |
 | `entityConfig` | Per-entity rate limit config. Falls back to `config.defaultRateLimit` if not provided |
-| `context` | Partition key context for key resolution |
+| `context`      | Partition key context for key resolution                                              |
 
 Returns `{ allowed: true, remaining: Infinity, resetMs: 0 }` if no config applies.
 
@@ -156,11 +156,11 @@ async acquireSemaphore(
 ): Promise
 ```
 
-| Parameter | Description |
-| --------- | ----------- |
-| `entityName` | Tool or agent name |
+| Parameter      | Description                                                              |
+| -------------- | ------------------------------------------------------------------------ |
+| `entityName`   | Tool or agent name                                                       |
 | `entityConfig` | Per-entity concurrency config. Falls back to `config.defaultConcurrency` |
-| `context` | Partition key context for key resolution |
+| `context`      | Partition key context for key resolution                                 |
 
 Returns `SemaphoreTicket` on success, `null` if no config applies. May throw `QueueTimeoutError` if queue timeout expires.
 
@@ -238,11 +238,11 @@ If `estimatedCount < maxRequests`, the request is allowed and the current window
 
 **`RateLimitResult`:**
 
-| Field | Type | Description |
-| ----- | ---- | ----------- |
-| `allowed` | `boolean` | Whether the request is allowed |
-| `remaining` | `number` | Requests remaining in the window |
-| `resetMs` | `number` | Milliseconds until window resets |
+| Field          | Type                  | Description                               |
+| -------------- | --------------------- | ----------------------------------------- |
+| `allowed`      | `boolean`             | Whether the request is allowed            |
+| `remaining`    | `number`              | Requests remaining in the window          |
+| `resetMs`      | `number`              | Milliseconds until window resets          |
 | `retryAfterMs` | `number \| undefined` | Recommended retry time (only when denied) |
 
 #### `reset()`
@@ -288,9 +288,9 @@ Uses pub/sub when available for efficient slot release detection, falls back to
 
 **`SemaphoreTicket`:**
 
-| Field | Type | Description |
-| ----- | ---- | ----------- |
-| `ticket` | `string` | Unique ticket identifier (UUID) |
+| Field       | Type                  | Description                       |
+| ----------- | --------------------- | --------------------------------- |
+| `ticket`    | `string`              | Unique ticket identifier (UUID)   |
 | `release()` | `() => Promise` | Release the slot back to the pool |
 
 #### `getActiveCount()`
@@ -332,17 +332,18 @@ check(clientIp: string): IpFilterResult
 ```
 
 **Evaluation order:**
+
 1. Deny list (takes precedence)
 2. Allow list
 3. Default action
 
 **`IpFilterResult`:**
 
-| Field | Type | Description |
-| ----- | ---- | ----------- |
-| `allowed` | `boolean` | Whether the IP is allowed |
-| `reason` | `'allowlisted' \| 'denylisted' \| 'default' \| undefined` | Reason for the decision |
-| `matchedRule` | `string \| undefined` | Specific IP or CIDR that matched |
+| Field         | Type                                                      | Description                      |
+| ------------- | --------------------------------------------------------- | -------------------------------- |
+| `allowed`     | `boolean`                                                 | Whether the IP is allowed        |
+| `reason`      | `'allowlisted' \| 'denylisted' \| 'default' \| undefined` | Reason for the decision          |
+| `matchedRule` | `string \| undefined`                                     | Specific IP or CIDR that matched |
 
 #### `isAllowListed()`
 
@@ -366,12 +367,13 @@ async function createGuardManager(args: CreateGuardManagerArgs): Promise(
 ): Promise
 ```
 
-| Parameter | Type | Description |
-| --------- | ---- | ----------- |
-| `fn` | `() => Promise` | Async function to execute |
-| `timeoutMs` | `number` | Maximum execution time in milliseconds |
-| `entityName` | `string` | Name included in error message |
+| Parameter    | Type               | Description                            |
+| ------------ | ------------------ | -------------------------------------- |
+| `fn`         | `() => Promise` | Async function to execute              |
+| `timeoutMs`  | `number`           | Maximum execution time in milliseconds |
+| `entityName` | `string`           | Name included in error message         |
 
 Throws `ExecutionTimeoutError` if the deadline is exceeded. Uses `AbortController` + `Promise.race` internally.
 
@@ -435,14 +437,14 @@ function resolvePartitionKey(
 ): string
 ```
 
-| Strategy | Resolved Value |
-| -------- | -------------- |
-| `undefined` | `'global'` |
-| `'global'` | `'global'` |
-| `'ip'` | `context.clientIp` or `'unknown-ip'` |
-| `'session'` | `context.sessionId` |
-| `'userId'` | `context.userId` or `'anonymous'` |
-| Custom function | `fn(context)` |
+| Strategy        | Resolved Value                       |
+| --------------- | ------------------------------------ |
+| `undefined`     | `'global'`                           |
+| `'global'`      | `'global'`                           |
+| `'ip'`          | `context.clientIp` or `'unknown-ip'` |
+| `'session'`     | `context.sessionId`                  |
+| `'userId'`      | `context.userId` or `'anonymous'`    |
+| Custom function | `fn(context)`                        |
 
 ---
 
@@ -485,54 +487,54 @@ class GuardError extends Error {
 
 Thrown when execution exceeds the configured timeout.
 
-| Property | Type | Value |
-| -------- | ---- | ----- |
-| `code` | `string` | `'EXECUTION_TIMEOUT'` |
-| `statusCode` | `number` | `408` |
-| `entityName` | `string` | Name of the tool/agent |
-| `timeoutMs` | `number` | Configured timeout value |
+| Property     | Type     | Value                    |
+| ------------ | -------- | ------------------------ |
+| `code`       | `string` | `'EXECUTION_TIMEOUT'`    |
+| `statusCode` | `number` | `408`                    |
+| `entityName` | `string` | Name of the tool/agent   |
+| `timeoutMs`  | `number` | Configured timeout value |
 
 ### `ConcurrencyLimitError`
 
 Thrown when no concurrency slot is available and `queueTimeoutMs` is 0.
 
-| Property | Type | Value |
-| -------- | ---- | ----- |
-| `code` | `string` | `'CONCURRENCY_LIMIT'` |
-| `statusCode` | `number` | `429` |
-| `entityName` | `string` | Name of the tool/agent |
+| Property        | Type     | Value                        |
+| --------------- | -------- | ---------------------------- |
+| `code`          | `string` | `'CONCURRENCY_LIMIT'`        |
+| `statusCode`    | `number` | `429`                        |
+| `entityName`    | `string` | Name of the tool/agent       |
 | `maxConcurrent` | `number` | Configured concurrency limit |
 
 ### `QueueTimeoutError`
 
 Thrown when a queued request exceeds its wait time.
 
-| Property | Type | Value |
-| -------- | ---- | ----- |
-| `code` | `string` | `'QUEUE_TIMEOUT'` |
-| `statusCode` | `number` | `429` |
-| `entityName` | `string` | Name of the tool/agent |
+| Property         | Type     | Value                    |
+| ---------------- | -------- | ------------------------ |
+| `code`           | `string` | `'QUEUE_TIMEOUT'`        |
+| `statusCode`     | `number` | `429`                    |
+| `entityName`     | `string` | Name of the tool/agent   |
 | `queueTimeoutMs` | `number` | Configured queue timeout |
 
 ### `IpBlockedError`
 
 Thrown when a client IP matches the deny list.
 
-| Property | Type | Value |
-| -------- | ---- | ----- |
-| `code` | `string` | `'IP_BLOCKED'` |
-| `statusCode` | `number` | `403` |
-| `clientIp` | `string` | The blocked IP address |
+| Property     | Type     | Value                  |
+| ------------ | -------- | ---------------------- |
+| `code`       | `string` | `'IP_BLOCKED'`         |
+| `statusCode` | `number` | `403`                  |
+| `clientIp`   | `string` | The blocked IP address |
 
 ### `IpNotAllowedError`
 
 Thrown when a client IP is not on the allow list and `defaultAction` is `'deny'`.
 
-| Property | Type | Value |
-| -------- | ---- | ----- |
-| `code` | `string` | `'IP_NOT_ALLOWED'` |
-| `statusCode` | `number` | `403` |
-| `clientIp` | `string` | The rejected IP address |
+| Property     | Type     | Value                   |
+| ------------ | -------- | ----------------------- |
+| `code`       | `string` | `'IP_NOT_ALLOWED'`      |
+| `statusCode` | `number` | `403`                   |
+| `clientIp`   | `string` | The rejected IP address |
 
 ---
 
@@ -551,11 +553,11 @@ import {
 } from '@frontmcp/guard';
 ```
 
-| Schema | Validates |
-| ------ | --------- |
-| `guardConfigSchema` | `GuardConfig` |
-| `rateLimitConfigSchema` | `RateLimitConfig` |
-| `concurrencyConfigSchema` | `ConcurrencyConfig` |
-| `timeoutConfigSchema` | `TimeoutConfig` |
-| `ipFilterConfigSchema` | `IpFilterConfig` |
-| `partitionKeySchema` | `PartitionKey` (string strategy or function) |
+| Schema                    | Validates                                    |
+| ------------------------- | -------------------------------------------- |
+| `guardConfigSchema`       | `GuardConfig`                                |
+| `rateLimitConfigSchema`   | `RateLimitConfig`                            |
+| `concurrencyConfigSchema` | `ConcurrencyConfig`                          |
+| `timeoutConfigSchema`     | `TimeoutConfig`                              |
+| `ipFilterConfigSchema`    | `IpFilterConfig`                             |
+| `partitionKeySchema`      | `PartitionKey` (string strategy or function) |
diff --git a/docs/frontmcp/sdk-reference/registries/auth-registry.mdx b/docs/frontmcp/sdk-reference/registries/auth-registry.mdx
index 212ae1b6b..9e5da791f 100644
--- a/docs/frontmcp/sdk-reference/registries/auth-registry.mdx
+++ b/docs/frontmcp/sdk-reference/registries/auth-registry.mdx
@@ -248,30 +248,30 @@ import type { FrontMcpAuthContext } from '@frontmcp/auth';
 
 ### Properties
 
-| Property        | Type                                  | Description                                                  |
-| --------------- | ------------------------------------- | ------------------------------------------------------------ |
-| `user`          | `FrontMcpAuthUser`                    | Resolved user identity (`sub`, `name`, `email`, `picture`)   |
-| `isAnonymous`   | `boolean`                             | True when `sub` starts with `anon:` or is empty              |
-| `mode`          | `string`                              | Authentication mode (`public`, `transparent`, `local`, `remote`) |
-| `sessionId`     | `string`                              | Session identifier (empty string if no session)              |
-| `scopes`        | `readonly string[]`                   | OAuth scopes granted to this session                         |
-| `claims`        | `Readonly>`   | Raw JWT claims                                               |
-| `roles`         | `readonly string[]`                   | Resolved roles (via `claimsMapping` or direct extraction)    |
-| `permissions`   | `readonly string[]`                   | Resolved permissions (via `claimsMapping` or direct)         |
+| Property      | Type                                | Description                                                      |
+| ------------- | ----------------------------------- | ---------------------------------------------------------------- |
+| `user`        | `FrontMcpAuthUser`                  | Resolved user identity (`sub`, `name`, `email`, `picture`)       |
+| `isAnonymous` | `boolean`                           | True when `sub` starts with `anon:` or is empty                  |
+| `mode`        | `string`                            | Authentication mode (`public`, `transparent`, `local`, `remote`) |
+| `sessionId`   | `string`                            | Session identifier (empty string if no session)                  |
+| `scopes`      | `readonly string[]`                 | OAuth scopes granted to this session                             |
+| `claims`      | `Readonly>` | Raw JWT claims                                                   |
+| `roles`       | `readonly string[]`                 | Resolved roles (via `claimsMapping` or direct extraction)        |
+| `permissions` | `readonly string[]`                 | Resolved permissions (via `claimsMapping` or direct)             |
 
 ### Methods
 
-| Method               | Signature                                      | Description                                  |
-| -------------------- | ---------------------------------------------- | -------------------------------------------- |
-| `hasRole`            | `(role: string) => boolean`                    | Check if user has a specific role             |
-| `hasAllRoles`        | `(roles: readonly string[]) => boolean`        | Check if user has ALL specified roles         |
-| `hasAnyRole`         | `(roles: readonly string[]) => boolean`        | Check if user has at least one role           |
-| `hasPermission`      | `(permission: string) => boolean`              | Check if user has a specific permission       |
-| `hasAllPermissions`  | `(permissions: readonly string[]) => boolean`  | Check if user has ALL specified permissions   |
-| `hasAnyPermission`   | `(permissions: readonly string[]) => boolean`  | Check if user has at least one permission     |
-| `hasScope`           | `(scope: string) => boolean`                   | Check if session has a specific OAuth scope   |
-| `hasAllScopes`       | `(scopes: readonly string[]) => boolean`       | Check if session has ALL specified scopes     |
-| `hasAnyScope`        | `(scopes: readonly string[]) => boolean`       | Check if session has at least one scope       |
+| Method              | Signature                                     | Description                                 |
+| ------------------- | --------------------------------------------- | ------------------------------------------- |
+| `hasRole`           | `(role: string) => boolean`                   | Check if user has a specific role           |
+| `hasAllRoles`       | `(roles: readonly string[]) => boolean`       | Check if user has ALL specified roles       |
+| `hasAnyRole`        | `(roles: readonly string[]) => boolean`       | Check if user has at least one role         |
+| `hasPermission`     | `(permission: string) => boolean`             | Check if user has a specific permission     |
+| `hasAllPermissions` | `(permissions: readonly string[]) => boolean` | Check if user has ALL specified permissions |
+| `hasAnyPermission`  | `(permissions: readonly string[]) => boolean` | Check if user has at least one permission   |
+| `hasScope`          | `(scope: string) => boolean`                  | Check if session has a specific OAuth scope |
+| `hasAllScopes`      | `(scopes: readonly string[]) => boolean`      | Check if session has ALL specified scopes   |
+| `hasAnyScope`       | `(scopes: readonly string[]) => boolean`      | Check if session has at least one scope     |
 
 ### Extension
 
diff --git a/docs/frontmcp/sdk-reference/registries/job-registry.mdx b/docs/frontmcp/sdk-reference/registries/job-registry.mdx
index 7ead45515..54390fbbc 100644
--- a/docs/frontmcp/sdk-reference/registries/job-registry.mdx
+++ b/docs/frontmcp/sdk-reference/registries/job-registry.mdx
@@ -177,13 +177,13 @@ hasAny(): boolean
 
 When the jobs system is enabled, the registry's capabilities are exposed through these MCP tools:
 
-| Tool             | Description                                                                                  |
-| ---------------- | -------------------------------------------------------------------------------------------- |
-| `list_jobs`      | List all registered jobs with optional filtering                                             |
-| `execute_job`    | Execute a job by name (inline or background)                                                 |
-| `get_job_status` | Get execution status by `runId`                                                              |
-| `register_job`   | Register a dynamic job with JavaScript source (`hideFromDiscovery: true`)                    |
-| `remove_job`     | Remove a dynamic job (`hideFromDiscovery: true`)                                             |
+| Tool             | Description                                                               |
+| ---------------- | ------------------------------------------------------------------------- |
+| `list_jobs`      | List all registered jobs with optional filtering                          |
+| `execute_job`    | Execute a job by name (inline or background)                              |
+| `get_job_status` | Get execution status by `runId`                                           |
+| `register_job`   | Register a dynamic job with JavaScript source (`hideFromDiscovery: true`) |
+| `remove_job`     | Remove a dynamic job (`hideFromDiscovery: true`)                          |
 
 Hyphen aliases (`list-jobs`, `execute-job`, …) still resolve with a deprecation log line for one release — agents and code that hardcoded the old form keep working.
 
diff --git a/docs/frontmcp/sdk-reference/registries/workflow-registry.mdx b/docs/frontmcp/sdk-reference/registries/workflow-registry.mdx
index a68db2c04..41cd20cdf 100644
--- a/docs/frontmcp/sdk-reference/registries/workflow-registry.mdx
+++ b/docs/frontmcp/sdk-reference/registries/workflow-registry.mdx
@@ -147,13 +147,13 @@ hasAny(): boolean
 
 When the jobs & workflows system is enabled, workflow capabilities are exposed through these MCP tools:
 
-| Tool                  | Description                                                                  |
-| --------------------- | ---------------------------------------------------------------------------- |
-| `list_workflows`      | List all registered workflows with optional filtering                        |
-| `execute_workflow`    | Execute a workflow by name (inline or background)                            |
-| `get_workflow_status` | Get execution status with per-step results                                   |
-| `register_workflow`   | Register a dynamic workflow at runtime (`hideFromDiscovery: true`)           |
-| `remove_workflow`     | Remove a dynamic workflow (`hideFromDiscovery: true`)                        |
+| Tool                  | Description                                                        |
+| --------------------- | ------------------------------------------------------------------ |
+| `list_workflows`      | List all registered workflows with optional filtering              |
+| `execute_workflow`    | Execute a workflow by name (inline or background)                  |
+| `get_workflow_status` | Get execution status with per-step results                         |
+| `register_workflow`   | Register a dynamic workflow at runtime (`hideFromDiscovery: true`) |
+| `remove_workflow`     | Remove a dynamic workflow (`hideFromDiscovery: true`)              |
 
 Hyphen aliases (`list-workflows`, `execute-workflow`, …) still resolve with a deprecation log line for one release — agents and code that hardcoded the old form keep working.
 
diff --git a/docs/frontmcp/servers/apps.mdx b/docs/frontmcp/servers/apps.mdx
index 61d5eddba..4f1adb2c6 100644
--- a/docs/frontmcp/servers/apps.mdx
+++ b/docs/frontmcp/servers/apps.mdx
@@ -149,17 +149,17 @@ export default class GatewayServer {}
 
 `App.remote(url, options?)` accepts the MCP server URL as the first argument and an optional options object:
 
-| Option             | Type                              | Description                                                             |
-| ------------------ | --------------------------------- | ----------------------------------------------------------------------- |
-| `name`             | `string`                          | Override the auto-derived app name (defaults to hostname)               |
-| `namespace`        | `string`                          | Prefix for tool/resource/prompt names (e.g., `mintlify:SearchMintlify`) |
-| `description`      | `string`                          | Human-readable description                                              |
-| `standalone`       | `boolean \| 'includeInParent'`    | Isolation mode (default: `false`)                                       |
-| `transportOptions` | `object`                          | Connection settings (timeout, retry attempts)                           |
-| `remoteAuth`       | `RemoteAuthConfig`                | Authentication config for the remote server                             |
-| `refreshInterval`  | `number`                          | Interval (ms) to refresh capabilities from the remote server            |
-| `cacheTTL`         | `number`                          | TTL (ms) for cached capabilities                                        |
-| `filter`           | `AppFilterConfig`                 | Include/exclude filter for selectively importing primitives              |
+| Option             | Type                           | Description                                                             |
+| ------------------ | ------------------------------ | ----------------------------------------------------------------------- |
+| `name`             | `string`                       | Override the auto-derived app name (defaults to hostname)               |
+| `namespace`        | `string`                       | Prefix for tool/resource/prompt names (e.g., `mintlify:SearchMintlify`) |
+| `description`      | `string`                       | Human-readable description                                              |
+| `standalone`       | `boolean \| 'includeInParent'` | Isolation mode (default: `false`)                                       |
+| `transportOptions` | `object`                       | Connection settings (timeout, retry attempts)                           |
+| `remoteAuth`       | `RemoteAuthConfig`             | Authentication config for the remote server                             |
+| `refreshInterval`  | `number`                       | Interval (ms) to refresh capabilities from the remote server            |
+| `cacheTTL`         | `number`                       | TTL (ms) for cached capabilities                                        |
+| `filter`           | `AppFilterConfig`              | Include/exclude filter for selectively importing primitives             |
 
 ### Transport Options
 
diff --git a/docs/frontmcp/servers/channels.mdx b/docs/frontmcp/servers/channels.mdx
index 79fcc7ef7..3df5a705d 100644
--- a/docs/frontmcp/servers/channels.mdx
+++ b/docs/frontmcp/servers/channels.mdx
@@ -16,13 +16,13 @@ This feature implements the [Claude Code Channels extension](https://code.claude
 
 Channels fill a gap that tools, resources, and prompts cannot address — **server-initiated push notifications**:
 
-| Aspect           | Tool                    | Resource              | Channel                          |
-| ---------------- | ----------------------- | --------------------- | -------------------------------- |
-| **Purpose**      | Execute actions         | Provide data          | Push real-time events            |
-| **Direction**    | Client triggers         | Client pulls          | Server pushes                    |
-| **Side effects** | Yes                     | No                    | No (notification only)           |
-| **Two-way**      | Request/response        | Read-only             | Optional reply tool              |
-| **Use case**     | Actions, calculations   | Context loading       | Alerts, chat bridges, monitoring |
+| Aspect           | Tool                  | Resource        | Channel                          |
+| ---------------- | --------------------- | --------------- | -------------------------------- |
+| **Purpose**      | Execute actions       | Provide data    | Push real-time events            |
+| **Direction**    | Client triggers       | Client pulls    | Server pushes                    |
+| **Side effects** | Yes                   | No              | No (notification only)           |
+| **Two-way**      | Request/response      | Read-only       | Optional reply tool              |
+| **Use case**     | Actions, calculations | Context loading | Alerts, chat bridges, monitoring |
 
 Channels are ideal for:
 
@@ -86,14 +86,14 @@ const ErrorChannel = channel({
 
 Every channel has a source that determines how events flow into it:
 
-| Source | Trigger | Config |
-| --- | --- | --- |
-| `webhook` | HTTP POST to a path | `{ type: 'webhook', path: '/hooks/deploy' }` |
-| `app-event` | In-process event bus | `{ type: 'app-event', event: 'error' }` |
-| `agent-completion` | Agent finishes | `{ type: 'agent-completion', agentIds?: ['reviewer'] }` |
-| `job-completion` | Job completes | `{ type: 'job-completion', jobNames?: ['daily-report'] }` |
-| `service` | Persistent connection | `{ type: 'service', service: 'whatsapp-business' }` |
-| `manual` | Programmatic push | `{ type: 'manual' }` |
+| Source             | Trigger               | Config                                                    |
+| ------------------ | --------------------- | --------------------------------------------------------- |
+| `webhook`          | HTTP POST to a path   | `{ type: 'webhook', path: '/hooks/deploy' }`              |
+| `app-event`        | In-process event bus  | `{ type: 'app-event', event: 'error' }`                   |
+| `agent-completion` | Agent finishes        | `{ type: 'agent-completion', agentIds?: ['reviewer'] }`   |
+| `job-completion`   | Job completes         | `{ type: 'job-completion', jobNames?: ['daily-report'] }` |
+| `service`          | Persistent connection | `{ type: 'service', service: 'whatsapp-business' }`       |
+| `manual`           | Programmatic push     | `{ type: 'manual' }`                                      |
 
 ### Webhook Source
 
@@ -424,12 +424,12 @@ Channel notifications are **session-scoped** to prevent data leaking between con
 
 ### Delivery Matrix
 
-| Source | Target | Delivery |
-| --- | --- | --- |
-| Webhook, file-watcher, app-event | All subscribers | Every subscribed session |
-| Agent completion | Originating session | ONLY the session that triggered the agent |
-| Job completion | Originating session | ONLY the session that triggered the job |
-| Manual push | Configurable | `send()` = all subscribers, `sendToSession()` = one session |
+| Source                           | Target              | Delivery                                                    |
+| -------------------------------- | ------------------- | ----------------------------------------------------------- |
+| Webhook, file-watcher, app-event | All subscribers     | Every subscribed session                                    |
+| Agent completion                 | Originating session | ONLY the session that triggered the agent                   |
+| Job completion                   | Originating session | ONLY the session that triggered the job                     |
+| Manual push                      | Configurable        | `send()` = all subscribers, `sendToSession()` = one session |
 
 ### Selective Subscriptions
 
diff --git a/docs/frontmcp/servers/esm-packages.mdx b/docs/frontmcp/servers/esm-packages.mdx
index afb937804..c5e57d6fd 100644
--- a/docs/frontmcp/servers/esm-packages.mdx
+++ b/docs/frontmcp/servers/esm-packages.mdx
@@ -157,18 +157,19 @@ App.esm('@internal/tools@latest', {
   },
 })
 ```
+
 
 
 ---
 
 ## Package Specifier Format
 
-| Pattern | Example | Description |
-|---------|---------|-------------|
-| `@scope/name@range` | `@acme/tools@^1.0.0` | Scoped package with semver range |
-| `@scope/name@tag` | `@acme/tools@latest` | Scoped package with dist-tag |
-| `name@range` | `my-tools@~2.0.0` | Unscoped package with semver range |
-| `name` | `my-tools` | Unscoped package, defaults to `latest` |
+| Pattern             | Example              | Description                            |
+| ------------------- | -------------------- | -------------------------------------- |
+| `@scope/name@range` | `@acme/tools@^1.0.0` | Scoped package with semver range       |
+| `@scope/name@tag`   | `@acme/tools@latest` | Scoped package with dist-tag           |
+| `name@range`        | `my-tools@~2.0.0`    | Unscoped package with semver range     |
+| `name`              | `my-tools`           | Unscoped package, defaults to `latest` |
 
 Supported semver ranges include `^1.0.0`, `~1.0.0`, `>=1.0.0 <2.0.0`, exact versions like `1.2.3`, and dist-tags like `latest`, `next`, `beta`.
 
@@ -198,12 +199,12 @@ Individual apps can override the gateway loader via the `loader` option in `App.
 
 ### PackageLoader Fields
 
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `url` | `string` | `https://esm.sh` (bundles), `https://registry.npmjs.org` (registry) | Base URL for both registry API and bundle fetching |
-| `registryUrl` | `string` | Same as `url` | Separate registry URL for version resolution (if different from bundle URL) |
-| `token` | `string` | — | Bearer token for authentication |
-| `tokenEnvVar` | `string` | — | Environment variable name containing the bearer token |
+| Field         | Type     | Default                                                             | Description                                                                 |
+| ------------- | -------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------- |
+| `url`         | `string` | `https://esm.sh` (bundles), `https://registry.npmjs.org` (registry) | Base URL for both registry API and bundle fetching                          |
+| `registryUrl` | `string` | Same as `url`                                                       | Separate registry URL for version resolution (if different from bundle URL) |
+| `token`       | `string` | —                                                                   | Bearer token for authentication                                             |
+| `tokenEnvVar` | `string` | —                                                                   | Environment variable name containing the bearer token                       |
 
 
 When `url` is set but `registryUrl` is not, both the registry API and bundle downloads use `url`. When `registryUrl` is also set, the registry uses `registryUrl` while bundles use `url`.
@@ -256,10 +257,10 @@ frontmcp package esm-update --all
 
 ESM packages use a **two-tier cache** for fast startup and offline resilience:
 
-| Tier | Environment | Persistence | Speed |
-|------|-------------|-------------|-------|
-| **Memory** | All | Process lifetime | Instant |
-| **Disk** | Node.js only | Survives restarts | Fast (local I/O) |
+| Tier       | Environment  | Persistence       | Speed            |
+| ---------- | ------------ | ----------------- | ---------------- |
+| **Memory** | All          | Process lifetime  | Instant          |
+| **Disk**   | Node.js only | Survives restarts | Fast (local I/O) |
 
 ### Cache Locations (Node.js)
 
@@ -322,6 +323,7 @@ In browser environments, only the in-memory cache is used. Bundles are evaluated
   ],
 })
 ```
+
 
 
 
@@ -334,16 +336,16 @@ The Bearer token is sent in the `Authorization` header for both registry API cal
 
 ## Comparison: ESM vs Remote vs Local Apps
 
-| Aspect | Local Apps | ESM Packages | Remote Apps |
-|--------|-----------|--------------|-------------|
-| **Declaration** | `@App` class | `App.esm()` | `App.remote()` |
-| **Execution** | In-process | In-process | Out-of-process (HTTP) |
-| **Transport** | None | None | Streamable HTTP / SSE |
-| **Caching** | N/A | Two-tier (memory + disk) | Optional via CachePlugin |
-| **Auth** | N/A | npm registry auth | remoteAuth config |
-| **Hot-Reload** | Requires restart | Version polling | N/A |
-| **Plugins/Adapters** | Full support | Not supported | Not supported |
-| **Best For** | First-party code | Community/npm packages | External MCP servers |
+| Aspect               | Local Apps       | ESM Packages             | Remote Apps              |
+| -------------------- | ---------------- | ------------------------ | ------------------------ |
+| **Declaration**      | `@App` class     | `App.esm()`              | `App.remote()`           |
+| **Execution**        | In-process       | In-process               | Out-of-process (HTTP)    |
+| **Transport**        | None             | None                     | Streamable HTTP / SSE    |
+| **Caching**          | N/A              | Two-tier (memory + disk) | Optional via CachePlugin |
+| **Auth**             | N/A              | npm registry auth        | remoteAuth config        |
+| **Hot-Reload**       | Requires restart | Version polling          | N/A                      |
+| **Plugins/Adapters** | Full support     | Not supported            | Not supported            |
+| **Best For**         | First-party code | Community/npm packages   | External MCP servers     |
 
 ---
 
@@ -351,14 +353,14 @@ The Bearer token is sent in the `Authorization` header for both registry API cal
 
 ESM loading can fail at several stages. FrontMCP provides specific error classes for each:
 
-| Error | When | HTTP |
-|-------|------|------|
-| `EsmInvalidSpecifierError` | Package specifier format is invalid | 400 |
-| `EsmVersionResolutionError` | npm registry query fails or no matching version | 500 |
-| `EsmRegistryAuthError` | Private registry authentication fails | 401 |
-| `EsmPackageLoadError` | Bundle fetch or evaluation fails | 500 |
-| `EsmManifestInvalidError` | Package export doesn't match manifest contract | 400 |
-| `EsmCacheError` | Cache read/write operation fails | 500 |
+| Error                       | When                                            | HTTP |
+| --------------------------- | ----------------------------------------------- | ---- |
+| `EsmInvalidSpecifierError`  | Package specifier format is invalid             | 400  |
+| `EsmVersionResolutionError` | npm registry query fails or no matching version | 500  |
+| `EsmRegistryAuthError`      | Private registry authentication fails           | 401  |
+| `EsmPackageLoadError`       | Bundle fetch or evaluation fails                | 500  |
+| `EsmManifestInvalidError`   | Package export doesn't match manifest contract  | 400  |
+| `EsmCacheError`             | Cache read/write operation fails                | 500  |
 
 See the [ESM Errors reference](/frontmcp/sdk-reference/errors/esm-errors) for full details.
 
diff --git a/docs/frontmcp/servers/guard.mdx b/docs/frontmcp/servers/guard.mdx
index 465a820b8..bda24ab3e 100644
--- a/docs/frontmcp/servers/guard.mdx
+++ b/docs/frontmcp/servers/guard.mdx
@@ -14,12 +14,12 @@ Guard is powered by the `@frontmcp/guard` library and integrates directly into t
 
 ## Why Guard?
 
-| Threat | Without Guard | With Guard |
-| ------ | ------------- | ---------- |
-| **Client flooding requests** | Server overwhelmed | Rate-limited per user/IP |
-| **Tool running forever** | Hangs, resource leak | Timeout protection |
-| **Unbounded parallelism** | Resource exhaustion | Controlled concurrency |
-| **Malicious IPs** | Open access | IP allow/deny filtering |
+| Threat                       | Without Guard        | With Guard               |
+| ---------------------------- | -------------------- | ------------------------ |
+| **Client flooding requests** | Server overwhelmed   | Rate-limited per user/IP |
+| **Tool running forever**     | Hangs, resource leak | Timeout protection       |
+| **Unbounded parallelism**    | Resource exhaustion  | Controlled concurrency   |
+| **Malicious IPs**            | Open access          | IP allow/deny filtering  |
 
 ---
 
@@ -33,18 +33,19 @@ import { Tool, ToolContext } from '@frontmcp/sdk';
 import { z } from '@frontmcp/sdk';
 
 @Tool({
-  name: 'search',
-  description: 'Search documents',
-  inputSchema: { query: z.string() },
-  rateLimit: { maxRequests: 60, windowMs: 60_000, partitionBy: 'userId' },
-  timeout: { executeMs: 10_000 },
+name: 'search',
+description: 'Search documents',
+inputSchema: { query: z.string() },
+rateLimit: { maxRequests: 60, windowMs: 60_000, partitionBy: 'userId' },
+timeout: { executeMs: 10_000 },
 })
 class SearchTool extends ToolContext {
-  async execute({ query }: { query: string }) {
-    return { results: await this.get(SearchService).search(query) };
-  }
+async execute({ query }: { query: string }) {
+return { results: await this.get(SearchService).search(query) };
 }
-```
+}
+
+````
 
 ```typescript Function Style
 import { tool } from '@frontmcp/sdk';
@@ -59,7 +60,8 @@ const SearchTool = tool({
 })(async ({ query }, ctx) => {
   return { results: await ctx.get(SearchService).search(query) };
 });
-```
+````
+
 
 
 ---
@@ -131,13 +133,13 @@ The global rate limit is checked **before** per-entity limits. Both must pass fo
 
 Partition keys determine how rate limits are bucketed:
 
-| Strategy | Description | Use Case |
-| -------- | ----------- | -------- |
-| `'global'` | Single shared bucket | Server-wide limits |
-| `'ip'` | Per client IP address | Prevent IP-based abuse |
-| `'session'` | Per MCP session ID | Per-connection limits |
-| `'userId'` | Per authenticated user | Per-user quotas |
-| Custom function | `(ctx) => string` | Tenant, org, or custom grouping |
+| Strategy        | Description            | Use Case                        |
+| --------------- | ---------------------- | ------------------------------- |
+| `'global'`      | Single shared bucket   | Server-wide limits              |
+| `'ip'`          | Per client IP address  | Prevent IP-based abuse          |
+| `'session'`     | Per MCP session ID     | Per-connection limits           |
+| `'userId'`      | Per authenticated user | Per-user quotas                 |
+| Custom function | `(ctx) => string`      | Tenant, org, or custom grouping |
 
 **Custom partition key example:**
 
@@ -274,12 +276,12 @@ class MyApp {}
 
 ### Supported IP Formats
 
-| Format | Example |
-| ------ | ------- |
-| IPv4 address | `192.168.1.1` |
-| IPv4 CIDR | `10.0.0.0/8` |
-| IPv6 address | `2001:db8::1` |
-| IPv6 CIDR | `2001:db8::/32` |
+| Format           | Example              |
+| ---------------- | -------------------- |
+| IPv4 address     | `192.168.1.1`        |
+| IPv4 CIDR        | `10.0.0.0/8`         |
+| IPv6 address     | `2001:db8::1`        |
+| IPv6 CIDR        | `2001:db8::/32`      |
 | IPv4-mapped IPv6 | `::ffff:192.168.1.1` |
 
 ### Proxy Configuration
@@ -334,13 +336,13 @@ class ProductionApp {}
 
 ### Configuration Precedence
 
-| Guard Type | Per-Entity Config | App Default | Fallback |
-| ---------- | ---------------- | ----------- | -------- |
-| Rate limit | `@Tool({ rateLimit })` | `throttle.defaultRateLimit` | No limit |
-| Concurrency | `@Tool({ concurrency })` | `throttle.defaultConcurrency` | No limit |
-| Timeout | `@Tool({ timeout })` | `throttle.defaultTimeout` | No timeout |
-| IP filter | N/A (app-level only) | `throttle.ipFilter` | No filter |
-| Global rate limit | N/A (app-level only) | `throttle.global` | No limit |
+| Guard Type        | Per-Entity Config        | App Default                   | Fallback   |
+| ----------------- | ------------------------ | ----------------------------- | ---------- |
+| Rate limit        | `@Tool({ rateLimit })`   | `throttle.defaultRateLimit`   | No limit   |
+| Concurrency       | `@Tool({ concurrency })` | `throttle.defaultConcurrency` | No limit   |
+| Timeout           | `@Tool({ timeout })`     | `throttle.defaultTimeout`     | No timeout |
+| IP filter         | N/A (app-level only)     | `throttle.ipFilter`           | No filter  |
+| Global rate limit | N/A (app-level only)     | `throttle.global`             | No limit   |
 
 ---
 
@@ -403,14 +405,14 @@ throttle: {
 
 Guard throws specific error classes when limits are exceeded:
 
-| Error Class | Code | HTTP Status | When Thrown |
-| ----------- | ---- | ----------- | ---------- |
-| `RateLimitError` | `RATE_LIMIT_EXCEEDED` | 429 | Request exceeds rate limit |
-| `ConcurrencyLimitError` | `CONCURRENCY_LIMIT` | 429 | No concurrency slot available |
-| `QueueTimeoutError` | `QUEUE_TIMEOUT` | 429 | Queue wait time exceeded |
-| `ExecutionTimeoutError` | `EXECUTION_TIMEOUT` | 408 | Execution exceeded deadline |
-| `IpBlockedError` | `IP_BLOCKED` | 403 | Client IP is on deny list |
-| `IpNotAllowedError` | `IP_NOT_ALLOWED` | 403 | Client IP not on allow list |
+| Error Class             | Code                  | HTTP Status | When Thrown                   |
+| ----------------------- | --------------------- | ----------- | ----------------------------- |
+| `RateLimitError`        | `RATE_LIMIT_EXCEEDED` | 429         | Request exceeds rate limit    |
+| `ConcurrencyLimitError` | `CONCURRENCY_LIMIT`   | 429         | No concurrency slot available |
+| `QueueTimeoutError`     | `QUEUE_TIMEOUT`       | 429         | Queue wait time exceeded      |
+| `ExecutionTimeoutError` | `EXECUTION_TIMEOUT`   | 408         | Execution exceeded deadline   |
+| `IpBlockedError`        | `IP_BLOCKED`          | 403         | Client IP is on deny list     |
+| `IpNotAllowedError`     | `IP_NOT_ALLOWED`      | 403         | Client IP not on allow list   |
 
 These errors are automatically serialized to appropriate MCP error responses by the transport layer.
 
@@ -443,46 +445,46 @@ The agent flow follows the same stage ordering: `acquireQuota` → `acquireSemap
 
 ### `RateLimitConfig`
 
-| Field | Type | Default | Description |
-| ----- | ---- | ------- | ----------- |
-| `maxRequests` | `number` | *required* | Maximum requests allowed in the window |
-| `windowMs` | `number` | `60000` | Time window in milliseconds |
-| `partitionBy` | `PartitionKey` | `'global'` | Partition strategy for bucketing |
+| Field         | Type           | Default    | Description                            |
+| ------------- | -------------- | ---------- | -------------------------------------- |
+| `maxRequests` | `number`       | _required_ | Maximum requests allowed in the window |
+| `windowMs`    | `number`       | `60000`    | Time window in milliseconds            |
+| `partitionBy` | `PartitionKey` | `'global'` | Partition strategy for bucketing       |
 
 ### `ConcurrencyConfig`
 
-| Field | Type | Default | Description |
-| ----- | ---- | ------- | ----------- |
-| `maxConcurrent` | `number` | *required* | Maximum simultaneous executions |
-| `queueTimeoutMs` | `number` | `0` | Max wait time for a slot (0 = no wait) |
-| `partitionBy` | `PartitionKey` | `'global'` | Partition strategy for bucketing |
+| Field            | Type           | Default    | Description                            |
+| ---------------- | -------------- | ---------- | -------------------------------------- |
+| `maxConcurrent`  | `number`       | _required_ | Maximum simultaneous executions        |
+| `queueTimeoutMs` | `number`       | `0`        | Max wait time for a slot (0 = no wait) |
+| `partitionBy`    | `PartitionKey` | `'global'` | Partition strategy for bucketing       |
 
 ### `TimeoutConfig`
 
-| Field | Type | Default | Description |
-| ----- | ---- | ------- | ----------- |
-| `executeMs` | `number` | *required* | Maximum execution time in milliseconds |
+| Field       | Type     | Default    | Description                            |
+| ----------- | -------- | ---------- | -------------------------------------- |
+| `executeMs` | `number` | _required_ | Maximum execution time in milliseconds |
 
 ### `IpFilterConfig`
 
-| Field | Type | Default | Description |
-| ----- | ---- | ------- | ----------- |
-| `allowList` | `string[]` | `[]` | IPs or CIDR ranges to always allow |
-| `denyList` | `string[]` | `[]` | IPs or CIDR ranges to always block |
-| `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list |
-| `trustProxy` | `boolean` | `false` | Trust `X-Forwarded-For` header |
-| `trustedProxyDepth` | `number` | `1` | Max proxy hops to trust |
+| Field               | Type                | Default   | Description                         |
+| ------------------- | ------------------- | --------- | ----------------------------------- |
+| `allowList`         | `string[]`          | `[]`      | IPs or CIDR ranges to always allow  |
+| `denyList`          | `string[]`          | `[]`      | IPs or CIDR ranges to always block  |
+| `defaultAction`     | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list |
+| `trustProxy`        | `boolean`           | `false`   | Trust `X-Forwarded-For` header      |
+| `trustedProxyDepth` | `number`            | `1`       | Max proxy hops to trust             |
 
 ### `GuardConfig` (App-Level)
 
-| Field | Type | Default | Description |
-| ----- | ---- | ------- | ----------- |
-| `enabled` | `boolean` | *required* | Enable or disable all guard features |
-| `storage` | `StorageConfig` | in-memory | Storage backend configuration |
-| `keyPrefix` | `string` | `'mcp:guard:'` | Prefix for all storage keys |
-| `global` | `RateLimitConfig` | — | Global rate limit for all requests |
-| `globalConcurrency` | `ConcurrencyConfig` | — | Global concurrency limit |
-| `defaultRateLimit` | `RateLimitConfig` | — | Default per-entity rate limit |
-| `defaultConcurrency` | `ConcurrencyConfig` | — | Default per-entity concurrency |
-| `defaultTimeout` | `TimeoutConfig` | — | Default per-entity timeout |
-| `ipFilter` | `IpFilterConfig` | — | IP filtering configuration |
+| Field                | Type                | Default        | Description                          |
+| -------------------- | ------------------- | -------------- | ------------------------------------ |
+| `enabled`            | `boolean`           | _required_     | Enable or disable all guard features |
+| `storage`            | `StorageConfig`     | in-memory      | Storage backend configuration        |
+| `keyPrefix`          | `string`            | `'mcp:guard:'` | Prefix for all storage keys          |
+| `global`             | `RateLimitConfig`   | —              | Global rate limit for all requests   |
+| `globalConcurrency`  | `ConcurrencyConfig` | —              | Global concurrency limit             |
+| `defaultRateLimit`   | `RateLimitConfig`   | —              | Default per-entity rate limit        |
+| `defaultConcurrency` | `ConcurrencyConfig` | —              | Default per-entity concurrency       |
+| `defaultTimeout`     | `TimeoutConfig`     | —              | Default per-entity timeout           |
+| `ipFilter`           | `IpFilterConfig`    | —              | IP filtering configuration           |
diff --git a/docs/frontmcp/servers/jobs.mdx b/docs/frontmcp/servers/jobs.mdx
index 872e9ab97..8183abf89 100644
--- a/docs/frontmcp/servers/jobs.mdx
+++ b/docs/frontmcp/servers/jobs.mdx
@@ -394,13 +394,13 @@ For production, configure Redis storage:
 
 When jobs are enabled, the following MCP tools are automatically registered:
 
-| Tool             | Description                                                                       |
-| ---------------- | --------------------------------------------------------------------------------- |
-| `list_jobs`      | List registered jobs with optional tag/label filtering                            |
-| `execute_job`    | Execute a job (inline or background)                                              |
-| `get_job_status` | Get execution status by `runId`                                                   |
-| `register_job`   | Register a dynamic job at runtime (`hideFromDiscovery: true`)                     |
-| `remove_job`     | Remove a dynamic job (`hideFromDiscovery: true`)                                  |
+| Tool             | Description                                                   |
+| ---------------- | ------------------------------------------------------------- |
+| `list_jobs`      | List registered jobs with optional tag/label filtering        |
+| `execute_job`    | Execute a job (inline or background)                          |
+| `get_job_status` | Get execution status by `runId`                               |
+| `register_job`   | Register a dynamic job at runtime (`hideFromDiscovery: true`) |
+| `remove_job`     | Remove a dynamic job (`hideFromDiscovery: true`)              |
 
 Hyphen aliases (`list-jobs`, `execute-job`, …) still resolve with a deprecation log line for one release — agents and code that hardcoded the old form keep working.
 
diff --git a/docs/frontmcp/servers/prompts.mdx b/docs/frontmcp/servers/prompts.mdx
index 45c3bdbbb..db0f61f31 100644
--- a/docs/frontmcp/servers/prompts.mdx
+++ b/docs/frontmcp/servers/prompts.mdx
@@ -221,13 +221,13 @@ execute(args: Record) {
 
 **Field descriptions:**
 
-| Field         | Description                                                  |
-| ------------- | ------------------------------------------------------------ |
-| `name`        | Programmatic identifier used internally and in MCP responses |
-| `title`       | Human-friendly name for UI display                           |
-| `description` | Helps clients and models understand when to use this prompt  |
-| `arguments`   | Named parameters that can be filled in by clients            |
-| `icons`       | Array of icons for visual representation in clients          |
+| Field           | Description                                                                                                        |
+| --------------- | ------------------------------------------------------------------------------------------------------------------ |
+| `name`          | Programmatic identifier used internally and in MCP responses                                                       |
+| `title`         | Human-friendly name for UI display                                                                                 |
+| `description`   | Helps clients and models understand when to use this prompt                                                        |
+| `arguments`     | Named parameters that can be filled in by clients                                                                  |
+| `icons`         | Array of icons for visual representation in clients                                                                |
 | `availableWhen` | Restrict discovery to specific environments. See [Environment Awareness](/frontmcp/features/environment-awareness) |
 
 ---
diff --git a/docs/frontmcp/servers/resources.mdx b/docs/frontmcp/servers/resources.mdx
index afaac42ff..052112d32 100644
--- a/docs/frontmcp/servers/resources.mdx
+++ b/docs/frontmcp/servers/resources.mdx
@@ -267,14 +267,14 @@ execute(uri: string) {
 
 **Field descriptions:**
 
-| Field                 | Description                                                  |
-| --------------------- | ------------------------------------------------------------ |
-| `name`                | Programmatic identifier used internally and in MCP responses |
-| `uri` / `uriTemplate` | The address clients use to request this resource             |
-| `title`               | Human-friendly name for UI display                           |
-| `description`         | Helps the model understand when to use this resource         |
-| `mimeType`            | Content type hint; auto-detected for JSON/text if omitted    |
-| `icons`               | Array of icons for visual representation in clients          |
+| Field                 | Description                                                                                                        |
+| --------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| `name`                | Programmatic identifier used internally and in MCP responses                                                       |
+| `uri` / `uriTemplate` | The address clients use to request this resource                                                                   |
+| `title`               | Human-friendly name for UI display                                                                                 |
+| `description`         | Helps the model understand when to use this resource                                                               |
+| `mimeType`            | Content type hint; auto-detected for JSON/text if omitted                                                          |
+| `icons`               | Array of icons for visual representation in clients                                                                |
 | `availableWhen`       | Restrict discovery to specific environments. See [Environment Awareness](/frontmcp/features/environment-awareness) |
 
 ---
@@ -542,6 +542,7 @@ notes://note/x-coredata%3A%2F%2F3823FD9C%2FICNote%2Fp216
 ```
 
 When the framework matches this URI back against the template, parameters are automatically decoded to their original values. The round-trip is consistent:
+
 - `expandUriTemplate("notes://note/{noteId}", { noteId: "x-coredata://..." })` → percent-encoded URI
 - `matchUriTemplate("notes://note/{noteId}", encodedUri)` → `{ noteId: "x-coredata://..." }` (decoded)
 
diff --git a/docs/frontmcp/servers/server.mdx b/docs/frontmcp/servers/server.mdx
index 1b3269541..bd765d02a 100644
--- a/docs/frontmcp/servers/server.mdx
+++ b/docs/frontmcp/servers/server.mdx
@@ -159,13 +159,13 @@ http: {
 }
 ```
 
-| Field         | Description                                                                  |
-| ------------- | ---------------------------------------------------------------------------- |
-| `port`        | HTTP listening port (default: `process.env.PORT` or `3000`)                  |
-| `entryPath`   | JSON-RPC entry path; must match `.well-known` discovery                      |
-| `hostFactory` | Custom host implementation for advanced setups                               |
-| `socketPath`  | Unix socket path; when set, server listens on a socket instead of a TCP port |
-| `cors`        | CORS configuration (see [CORS](#cors) below)                                 |
+| Field         | Description                                                                          |
+| ------------- | ------------------------------------------------------------------------------------ |
+| `port`        | HTTP listening port (default: `process.env.PORT` or `3000`)                          |
+| `entryPath`   | JSON-RPC entry path; must match `.well-known` discovery                              |
+| `hostFactory` | Custom host implementation for advanced setups                                       |
+| `socketPath`  | Unix socket path; when set, server listens on a socket instead of a TCP port         |
+| `cors`        | CORS configuration (see [CORS](#cors) below)                                         |
 | `routes`      | First-class custom HTTP routes (see [Custom HTTP routes](#custom-http-routes) below) |
 
 - **Port**: listening port for Streamable HTTP. Defaults to `process.env.PORT` or `3000` when omitted.
@@ -279,12 +279,12 @@ http: {
 }
 ```
 
-| Field     | Type                   | Default          | Description                                                              |
-| --------- | ---------------------- | ---------------- | ------------------------------------------------------------------------ |
-| `method`  | `HttpMethod`           | —                | `'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE' \| 'OPTIONS' \| 'HEAD'`  |
+| Field     | Type                   | Default          | Description                                                                                |
+| --------- | ---------------------- | ---------------- | ------------------------------------------------------------------------------------------ |
+| `method`  | `HttpMethod`           | —                | `'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE' \| 'OPTIONS' \| 'HEAD'`                   |
 | `path`    | `string`               | —                | Express-style path (`/files/:id`); rejected at startup if it collides with a reserved path |
-| `handler` | `ServerRequestHandler` | —                | `(req, res, next) => void \| Promise`                              |
-| `auth`    | `boolean`              | `false` (public) | When `true`, gate the route behind the MCP `session:verify` flow         |
+| `handler` | `ServerRequestHandler` | —                | `(req, res, next) => void \| Promise`                                                |
+| `auth`    | `boolean`              | `false` (public) | When `true`, gate the route behind the MCP `session:verify` flow                           |
 
 `req` / `res` are exported as `ServerRequest` / `ServerResponse` from `@frontmcp/sdk`; `ServerRequestHandler` types the handler. Respond with `res.status(...).json(...)` / `res.send(...)`, or call `next()` to fall through.
 
diff --git a/docs/frontmcp/servers/skills.mdx b/docs/frontmcp/servers/skills.mdx
index 46c8133d1..6f8c85af7 100644
--- a/docs/frontmcp/servers/skills.mdx
+++ b/docs/frontmcp/servers/skills.mdx
@@ -212,24 +212,24 @@ class MyApp {}
 
 **Field descriptions:**
 
-| Field               | Description                                                                  |
-| ------------------- | ---------------------------------------------------------------------------- |
-| `name`              | Unique identifier, must be kebab-case (max 64 chars, no consecutive hyphens) |
-| `description`       | Short text for discovery (1–1024 chars, non-empty)                           |
-| `instructions`      | Detailed step-by-step guidance (inline, file, or URL)                        |
-| `tools`             | Tools this skill uses, with optional purpose descriptions                    |
-| `tags`              | Categorization for filtering and organization                                |
-| `parameters`        | Input values that customize skill behavior                                   |
-| `examples`          | Scenarios demonstrating when and how to use the skill                        |
-| `priority`          | Higher values appear earlier in search results                               |
-| `hideFromDiscovery` | When `true`, skill is loadable but not listed in search                      |
-| `visibility`        | Where this skill is discoverable: `mcp`, `http`, or `both`                   |
-| `toolValidation`    | How to handle missing tool references: `strict`, `warn`, `ignore`            |
-| `license`           | License name or reference (per Agent Skills spec)                            |
-| `compatibility`     | Environment requirements (max 500 chars, per Agent Skills spec)              |
-| `specMetadata`      | Arbitrary key-value metadata (maps to spec `metadata` field)                 |
-| `allowedTools`      | Space-delimited pre-approved tools (maps to spec `allowed-tools`)            |
-| `resources`         | Bundled resource directories (`scripts/`, `references/`, `assets/`)          |
+| Field               | Description                                                                                                        |
+| ------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| `name`              | Unique identifier, must be kebab-case (max 64 chars, no consecutive hyphens)                                       |
+| `description`       | Short text for discovery (1–1024 chars, non-empty)                                                                 |
+| `instructions`      | Detailed step-by-step guidance (inline, file, or URL)                                                              |
+| `tools`             | Tools this skill uses, with optional purpose descriptions                                                          |
+| `tags`              | Categorization for filtering and organization                                                                      |
+| `parameters`        | Input values that customize skill behavior                                                                         |
+| `examples`          | Scenarios demonstrating when and how to use the skill                                                              |
+| `priority`          | Higher values appear earlier in search results                                                                     |
+| `hideFromDiscovery` | When `true`, skill is loadable but not listed in search                                                            |
+| `visibility`        | Where this skill is discoverable: `mcp`, `http`, or `both`                                                         |
+| `toolValidation`    | How to handle missing tool references: `strict`, `warn`, `ignore`                                                  |
+| `license`           | License name or reference (per Agent Skills spec)                                                                  |
+| `compatibility`     | Environment requirements (max 500 chars, per Agent Skills spec)                                                    |
+| `specMetadata`      | Arbitrary key-value metadata (maps to spec `metadata` field)                                                       |
+| `allowedTools`      | Space-delimited pre-approved tools (maps to spec `allowed-tools`)                                                  |
+| `resources`         | Bundled resource directories (`scripts/`, `references/`, `assets/`)                                                |
 | `availableWhen`     | Restrict discovery to specific environments. See [Environment Awareness](/frontmcp/features/environment-awareness) |
 
 ### Name Validation
@@ -829,14 +829,14 @@ Tools in `allowedTools` don't require additional user confirmation during skill
 
 The `frontmcp skills` command tree ships **six** subcommands:
 
-| Subcommand | Purpose                                                                                                                |
-| ---------- | ---------------------------------------------------------------------------------------------------------------------- |
-| `search`   | Semantic search over the catalog (description / tags / name)                                                           |
-| `list`     | List all skills, filterable by `--category`, `--tag`, `--bundle`                                                       |
-| `read`     | Print a skill's `SKILL.md`, a reference, or list `--refs/--examples`                                                   |
+| Subcommand | Purpose                                                                                                                                                          |
+| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `search`   | Semantic search over the catalog (description / tags / name)                                                                                                     |
+| `list`     | List all skills, filterable by `--category`, `--tag`, `--bundle`                                                                                                 |
+| `read`     | Print a skill's `SKILL.md`, a reference, or list `--refs/--examples`                                                                                             |
 | `install`  | Copy a catalog skill (or many, via `--all/--tag/--category`) into a provider dir; also installs **project `@Skill` entries** via `--from-entry`/`--from-package` |
-| `export`   | Convert a skill into a Cursor / Windsurf / Copilot rule file                                                           |
-| `publish`  | Publish a skill to the Smithery or Glama marketplace                                                                   |
+| `export`   | Convert a skill into a Cursor / Windsurf / Copilot rule file                                                                                                     |
+| `publish`  | Publish a skill to the Smithery or Glama marketplace                                                                                                             |
 
 `install` works on two sources:
 
diff --git a/docs/frontmcp/servers/tools.mdx b/docs/frontmcp/servers/tools.mdx
index 0df2103a8..0ebd6b00d 100644
--- a/docs/frontmcp/servers/tools.mdx
+++ b/docs/frontmcp/servers/tools.mdx
@@ -220,12 +220,12 @@ output?: {
 }
 ```
 
-| `schemaMode`    | Effect                                                                                           |
-| --------------- | ------------------------------------------------------------------------------------------------ |
-| `'definition'`  | **(Default)** Advertise the schema as the tool's `outputSchema` (JSON Schema).                   |
-| `'description'` | Fold a readable rendering of the schema into the tool `description`, and omit `outputSchema`.    |
-| `'both'`        | Advertise as `outputSchema` **and** fold it into the description.                                |
-| `'none'`        | Do not expose the output schema anywhere.                                                        |
+| `schemaMode`    | Effect                                                                                        |
+| --------------- | --------------------------------------------------------------------------------------------- |
+| `'definition'`  | **(Default)** Advertise the schema as the tool's `outputSchema` (JSON Schema).                |
+| `'description'` | Fold a readable rendering of the schema into the tool `description`, and omit `outputSchema`. |
+| `'both'`        | Advertise as `outputSchema` **and** fold it into the description.                             |
+| `'none'`        | Do not expose the output schema anywhere.                                                     |
 
 When the schema is folded into the description (`'description'` / `'both'`), `schemaDescriptionFormat` picks the rendering: `'summary'` (a compact human-readable property list) or `'jsonSchema'` (a fenced JSON Schema code block).
 
@@ -353,20 +353,20 @@ execute() {
 
 **Field descriptions:**
 
-| Field               | Description                                                  |
-| ------------------- | ------------------------------------------------------------ |
-| `name`              | Programmatic identifier used internally and in MCP responses |
-| `description`       | Helps the model understand when and how to use this tool     |
-| `inputSchema`       | Zod schema defining expected input parameters                |
-| `outputSchema`      | Zod schema for validating and documenting output             |
-| `title`             | Human-friendly name for UI display                           |
-| `icons`             | Array of icons for visual representation in clients          |
-| `tags`              | Categorization for organization and filtering                |
-| `annotations`       | MCP-defined hints about tool behavior                        |
-| `examples`          | Usage examples for discovery and LLM understanding           |
-| `ui`                | Visual widget configuration (template, display mode, etc.)   |
-| `output`            | Output-validation + output-schema exposure policy — see [Output schema exposure](#output-schema-exposure) |
-| `hideFromDiscovery` | When `true`, tool is callable but not listed in `tools/list` |
+| Field               | Description                                                                                                                      |
+| ------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| `name`              | Programmatic identifier used internally and in MCP responses                                                                     |
+| `description`       | Helps the model understand when and how to use this tool                                                                         |
+| `inputSchema`       | Zod schema defining expected input parameters                                                                                    |
+| `outputSchema`      | Zod schema for validating and documenting output                                                                                 |
+| `title`             | Human-friendly name for UI display                                                                                               |
+| `icons`             | Array of icons for visual representation in clients                                                                              |
+| `tags`              | Categorization for organization and filtering                                                                                    |
+| `annotations`       | MCP-defined hints about tool behavior                                                                                            |
+| `examples`          | Usage examples for discovery and LLM understanding                                                                               |
+| `ui`                | Visual widget configuration (template, display mode, etc.)                                                                       |
+| `output`            | Output-validation + output-schema exposure policy — see [Output schema exposure](#output-schema-exposure)                        |
+| `hideFromDiscovery` | When `true`, tool is callable but not listed in `tools/list`                                                                     |
 | `availableWhen`     | Restrict discovery and execution to specific environments. See [Environment Awareness](/frontmcp/features/environment-awareness) |
 
 ---
@@ -892,11 +892,12 @@ FrontMCP auto-detects your template type:
 import { type TemplateContext } from '@frontmcp/sdk';
 
 ui: {
-  // Annotate `ctx` explicitly under strict / noImplicitAny.
-  template: (ctx: TemplateContext) =>
-    `

${ctx.helpers.escapeHtml(ctx.output.message)}

`, +// Annotate `ctx` explicitly under strict / noImplicitAny. +template: (ctx: TemplateContext) => +`

${ctx.helpers.escapeHtml(ctx.output.message)}

`, } -``` + +```` ```tsx React Component import WeatherCard from './components/WeatherCard'; @@ -904,7 +905,7 @@ import WeatherCard from './components/WeatherCard'; ui: { template: WeatherCard, // React component } -``` +```` ```ts MDX Template ui: { @@ -944,6 +945,7 @@ ui: { **`.tsx`/`.jsx` widgets require `@frontmcp/ui` installed.** When `ui.template` points at a `.tsx`/`.jsx` file (the recommended pattern for non-trivial widgets), FrontMCP injects an auto-generated React mount that imports `McpBridgeProvider` from `@frontmcp/ui/react`. Install `@frontmcp/ui` in the consuming project at the same version as `@frontmcp/sdk` — without it, server-side bundling fails. In the default `resourceMode: 'cdn'` mode, `react` / `react-dom` stay external and load from the CDN at runtime, so only `@frontmcp/ui` needs to be present on disk. When the framework selects `resourceMode: 'inline'` — either explicitly or via host detection (Claude, #456) — `react` and `react-dom` must also be resolvable from the consuming project so esbuild can bundle them into the widget; install them as devDependencies if your project doesn't already pull them in. See [building-tool-ui](/frontmcp/guides/building-tool-ui#step-1-install-the-ui-package) for the install snippet and the per-host trade-offs. + @@ -957,6 +959,7 @@ ui: { template: { file: widgetPath } } ``` In a **CommonJS** project (`"type": "commonjs"`), `import.meta.url` is unavailable — anchor with `join(__dirname, 'weather.widget.tsx')` (from `node:path`) instead. Both forms are invariant to `process.cwd()`. + @@ -972,6 +975,7 @@ ui: { ``` Detection only applies to per-call rendering (inline / hybrid / lean serving modes). `servingMode: 'static'` widgets compile at server startup with no client context — set `resourceMode: 'inline'` explicitly when a static widget needs to render in Claude. + diff --git a/docs/frontmcp/servers/workflows.mdx b/docs/frontmcp/servers/workflows.mdx index 788abf1ab..b9130955a 100644 --- a/docs/frontmcp/servers/workflows.mdx +++ b/docs/frontmcp/servers/workflows.mdx @@ -344,13 +344,13 @@ steps: [ When workflows are enabled, the following MCP tools are automatically registered: -| Tool | Description | -| --------------------- | --------------------------------------------------------------------------------- | -| `list_workflows` | List registered workflows with optional filtering | -| `execute_workflow` | Execute a workflow (inline or background) | -| `get_workflow_status` | Get execution status with per-step results | -| `register_workflow` | Register a dynamic workflow at runtime (`hideFromDiscovery: true`) | -| `remove_workflow` | Remove a dynamic workflow (`hideFromDiscovery: true`) | +| Tool | Description | +| --------------------- | ------------------------------------------------------------------ | +| `list_workflows` | List registered workflows with optional filtering | +| `execute_workflow` | Execute a workflow (inline or background) | +| `get_workflow_status` | Get execution status with per-step results | +| `register_workflow` | Register a dynamic workflow at runtime (`hideFromDiscovery: true`) | +| `remove_workflow` | Remove a dynamic workflow (`hideFromDiscovery: true`) | Hyphen aliases (`list-workflows`, `execute-workflow`, …) still resolve with a deprecation log line for one release — agents and code that hardcoded the old form keep working. diff --git a/docs/frontmcp/testing/api-reference.mdx b/docs/frontmcp/testing/api-reference.mdx index 1a3e93e95..d74ebf6d9 100644 --- a/docs/frontmcp/testing/api-reference.mdx +++ b/docs/frontmcp/testing/api-reference.mdx @@ -82,9 +82,9 @@ Call a tool with arguments. mcp.tools.call(name: string, args?: Record): Promise ``` -| Parameter | Type | Description | -| --------- | ------------------------- | ------------------------ | -| `name` | `string` | Tool name | +| Parameter | Type | Description | +| --------- | ------------------------- | ------------------------- | +| `name` | `string` | Tool name | | `args` | `Record` | Tool arguments (optional) | **Returns:** `ToolResult` with methods: diff --git a/docs/frontmcp/testing/authentication.mdx b/docs/frontmcp/testing/authentication.mdx index 0fabfe74a..c4789f18d 100644 --- a/docs/frontmcp/testing/authentication.mdx +++ b/docs/frontmcp/testing/authentication.mdx @@ -92,11 +92,11 @@ test('using pre-built users', async ({ mcp, auth }) => { ### User Definitions -| User | `sub` | Scopes | -| ---------- | -------------- | -------------------------------------------- | -| `admin` | `admin-001` | `['admin:*', 'read', 'write', 'delete']` | -| `user` | `user-001` | `['read', 'write']` | -| `readOnly` | `readonly-001` | `['read']` | +| User | `sub` | Scopes | +| ---------- | -------------- | ---------------------------------------- | +| `admin` | `admin-001` | `['admin:*', 'read', 'write', 'delete']` | +| `user` | `user-001` | `['read', 'write']` | +| `readOnly` | `readonly-001` | `['read']` | --- diff --git a/docs/frontmcp/testing/matchers.mdx b/docs/frontmcp/testing/matchers.mdx index 71ce8bd86..c4d7d7f1a 100644 --- a/docs/frontmcp/testing/matchers.mdx +++ b/docs/frontmcp/testing/matchers.mdx @@ -252,23 +252,23 @@ expect(response).toHaveErrorCode(-32700); // Parse error These assert on the rendered UI metadata returned by tools that ship a UI template (see [Building tool UI](/frontmcp/guides/building-tool-ui)). They run against `result._meta` / `result.content[0].text` (the rendered HTML). -| Matcher | What it checks | -| --------------------------- | -------------------------------------------------------------------- | -| `toHaveRenderedHtml()` | Result includes a non-empty rendered HTML payload | -| `toContainHtmlElement(tag)` | Rendered HTML contains an element of `tag` (e.g. `'button'`) | -| `toContainBoundValue(v)` | Rendered HTML contains the bound input/output value | -| `toBeXssSafe()` | Rendered HTML has been sanitized (no ` diff --git a/libs/utils/README.md b/libs/utils/README.md index 6d76cceda..eeec091f8 100644 --- a/libs/utils/README.md +++ b/libs/utils/README.md @@ -31,7 +31,7 @@ npm install @frontmcp/utils ## Quick Example ```ts -import { matchUriTemplate, sha256Hex, fileExists } from '@frontmcp/utils'; +import { fileExists, matchUriTemplate, sha256Hex } from '@frontmcp/utils'; const params = matchUriTemplate('users/{id}/posts/{postId}', 'users/123/posts/456'); // { id: '123', postId: '456' } diff --git a/plugins/plugin-cache/README.md b/plugins/plugin-cache/README.md index 67851fd33..146ed094e 100644 --- a/plugins/plugin-cache/README.md +++ b/plugins/plugin-cache/README.md @@ -70,9 +70,10 @@ class MyApp {} ### Redis Client (Reuse Existing) ```typescript -import { CachePlugin } from '@frontmcp/plugin-cache'; import { Redis } from 'ioredis'; +import { CachePlugin } from '@frontmcp/plugin-cache'; + const redis = new Redis({ host: 'localhost', port: 6379 }); @App({ diff --git a/plugins/plugin-codecall/README.md b/plugins/plugin-codecall/README.md index 6680e5c27..cfe3bca62 100644 --- a/plugins/plugin-codecall/README.md +++ b/plugins/plugin-codecall/README.md @@ -13,8 +13,8 @@ npm install @frontmcp/plugin-codecall @frontmcp/plugin-cache ## Usage ```typescript -import { CodeCallPlugin } from '@frontmcp/plugin-codecall'; import { CachePlugin } from '@frontmcp/plugin-cache'; +import { CodeCallPlugin } from '@frontmcp/plugin-codecall'; import { FrontMcp } from '@frontmcp/sdk'; const app = new FrontMcp({ diff --git a/plugins/plugin-feature-flags/README.md b/plugins/plugin-feature-flags/README.md new file mode 100644 index 000000000..c2d8a427a --- /dev/null +++ b/plugins/plugin-feature-flags/README.md @@ -0,0 +1,105 @@ +# @frontmcp/plugin-feature-flags + +Gate MCP capabilities behind feature flags — hide tools, branch behaviour, and +roll out changes per user without redeploying. + +[![NPM](https://img.shields.io/npm/v/@frontmcp/plugin-feature-flags.svg)](https://www.npmjs.com/package/@frontmcp/plugin-feature-flags) + +## Install + +```bash +npm install @frontmcp/plugin-feature-flags +``` + +## Usage + +```ts +import { FeatureFlagPlugin } from '@frontmcp/plugin-feature-flags'; +import { FrontMcp } from '@frontmcp/sdk'; + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + plugins: [ + FeatureFlagPlugin.configure({ + provider: 'static', + flags: { 'new-search': true, 'beta-export': false }, + }), + ], +}) +class Server {} +``` + +Then read flags from any tool through `this.featureFlags`: + +```ts +@Tool({ name: 'search', inputSchema: { q: z.string() } }) +export default class SearchTool extends ToolContext { + async execute({ q }: { q: string }) { + if (await this.featureFlags.isEnabled('new-search')) { + return newSearch(q); + } + return legacySearch(q); + } +} +``` + +## Providers + +| Provider | `provider` | Notes | +| ------------ | ---------------- | ------------------------------------------------ | +| Static | `'static'` | Flags from config. Good for local dev and tests. | +| Split.io | `'splitio'` | Requires an SDK key. | +| LaunchDarkly | `'launchdarkly'` | Requires an SDK key. | +| Unleash | `'unleash'` | Requires a URL + API token. | +| Custom | `'custom'` | Supply your own `FeatureFlagAdapter`. | + +```ts +FeatureFlagPlugin.configure({ + provider: 'launchdarkly', + sdkKey: process.env.LD_SDK_KEY, + // Which identity the flag is evaluated for. Defaults to the session's user. + userIdResolver: (ctx) => ctx.authInfo?.clientId, + attributesResolver: (ctx) => ({ plan: ctx.authInfo?.extra?.plan }), +}); +``` + +### Custom adapter + +```ts +import { FeatureFlagPlugin, type FeatureFlagAdapter } from '@frontmcp/plugin-feature-flags'; + +const adapter: FeatureFlagAdapter = { + async isEnabled(key, ctx) { + return myBackend.check(key, ctx.userId); + }, +}; + +FeatureFlagPlugin.configure({ provider: 'custom', adapter }); +``` + +## API + +`this.featureFlags` (an injected `FeatureFlagAccessor`): + +| Method | Returns | Purpose | +| -------------------------- | ------------------------------- | --------------------------------------------- | +| `isEnabled(key, default?)` | `Promise` | Evaluate one boolean flag | +| `getVariant(key)` | `Promise` | Multivariate flag value | +| `evaluateFlags(keys)` | `Promise>` | Batch evaluation in one round trip | +| `resolveRef(ref)` | `Promise` | Resolve a flag reference (`{ flag, negate }`) | + +Outside a tool, use `getFeatureFlags()` / `tryGetFeatureFlags()`. + +## Failure behaviour + +If the provider is unreachable, `isEnabled` returns the `defaultValue` you pass +(or `false`). Always pass an explicit default for a flag that gates something +important, so an outage degrades the way you intend rather than silently +disabling a feature. + +Full guide: [Feature Flags](https://docs.agentfront.dev/frontmcp/plugins/feature-flags-plugin) + +## License + +Apache-2.0 diff --git a/plugins/plugin-skilled-openapi/README.md b/plugins/plugin-skilled-openapi/README.md index 81dbd4d7e..034120831 100644 --- a/plugins/plugin-skilled-openapi/README.md +++ b/plugins/plugin-skilled-openapi/README.md @@ -24,8 +24,9 @@ Register the plugin with `SkilledOpenApiPlugin.init(...)` and point it at a bund ```typescript import * as path from 'node:path'; -import { FrontMcp, LogLevel } from '@frontmcp/sdk'; + import SkilledOpenApiPlugin from '@frontmcp/plugin-skilled-openapi'; +import { FrontMcp, LogLevel } from '@frontmcp/sdk'; @FrontMcp({ info: { name: 'Skilled-OpenAPI Demo', version: '0.1.0' }, @@ -74,10 +75,10 @@ OpenAPI spec --(analyzer + optional signing)--> bundle (spec + overlay) ## Meta-Tools -| Tool | Purpose | -| --- | --- | -| `search_skill` | Semantic search over the loaded skills; returns matching `skillId`s with scores. The live skill catalog is injected into the tool description so the model can discover what's available. | -| `load_skill` | Returns a skill's markdown instructions plus its `actions[]` and their JSON Schemas (the `actionId`s a workflow calls). | +| Tool | Purpose | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `search_skill` | Semantic search over the loaded skills; returns matching `skillId`s with scores. The live skill catalog is injected into the tool description so the model can discover what's available. | +| `load_skill` | Returns a skill's markdown instructions plus its `actions[]` and their JSON Schemas (the `actionId`s a workflow calls). | | `run_workflow` | Runs an AgentScript `script` in the enclave sandbox. Each `await callTool(actionId, input)` invokes a loaded operation through the full authorize → validate → HTTPS → validate path; the script's `return` value is surfaced as the result. | ## Features @@ -93,28 +94,28 @@ OpenAPI spec --(analyzer + optional signing)--> bundle (spec + overlay) All options are validated by a strict Zod schema (`skilledOpenApiPluginOptionsSchema`). -| Option | Type | Default | Description | -| --- | --- | --- | --- | -| `source` | `static \| npm \| saas \| inline` | — (required) | Where bundles come from. `{ type: 'static', path, watch? }`, `{ type: 'npm', package }`, `{ type: 'saas', endpoint, ... }`, or `{ type: 'inline', ... }`. | -| `requireSignature` | `boolean` | `true` | Require a valid bundle signature (RS256/Ed25519 JWT-of-hashes). Opt out only with `dev: true`. | -| `trustedKeys` | `SignatureKey[]` | `[]` | Public keys trusted to sign bundles. | -| `dev` | `boolean` | `false` | Local-dev escape hatch: bypasses signing and widens `outbound` to allow `http://`. **Never enable in production.** | -| `outbound` | `OutboundOptions` | see below | SSRF / egress controls. | -| `unprotectedOps` | `'allow' \| 'deny'` | `'allow'` | Default-deny policy for operations that declare no required authorities. | -| `sourceConflictPolicy` | `'static-wins' \| 'last-wins' \| 'reject'` | `'static-wins'` | How to resolve two sources registering the same skill id. | -| `bundleCacheDir` | `string` | — | Last-good cache directory (only for `source.type === 'saas'`). | -| `credentials` | `Record` | — | In-memory credential map for dev / single-tenant. In production resolve via `@frontmcp/auth`'s vault. | -| `exposeOperationsAsInternalTools` | `boolean` | `true` | Keep operations reachable via `callTool` inside workflows. | +| Option | Type | Default | Description | +| --------------------------------- | ------------------------------------------ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `source` | `static \| npm \| saas \| inline` | — (required) | Where bundles come from. `{ type: 'static', path, watch? }`, `{ type: 'npm', package }`, `{ type: 'saas', endpoint, ... }`, or `{ type: 'inline', ... }`. | +| `requireSignature` | `boolean` | `true` | Require a valid bundle signature (RS256/Ed25519 JWT-of-hashes). Opt out only with `dev: true`. | +| `trustedKeys` | `SignatureKey[]` | `[]` | Public keys trusted to sign bundles. | +| `dev` | `boolean` | `false` | Local-dev escape hatch: bypasses signing and widens `outbound` to allow `http://`. **Never enable in production.** | +| `outbound` | `OutboundOptions` | see below | SSRF / egress controls. | +| `unprotectedOps` | `'allow' \| 'deny'` | `'allow'` | Default-deny policy for operations that declare no required authorities. | +| `sourceConflictPolicy` | `'static-wins' \| 'last-wins' \| 'reject'` | `'static-wins'` | How to resolve two sources registering the same skill id. | +| `bundleCacheDir` | `string` | — | Last-good cache directory (only for `source.type === 'saas'`). | +| `credentials` | `Record` | — | In-memory credential map for dev / single-tenant. In production resolve via `@frontmcp/auth`'s vault. | +| `exposeOperationsAsInternalTools` | `boolean` | `true` | Keep operations reachable via `callTool` inside workflows. | `outbound` (SSRF + egress): -| Field | Default | Description | -| --- | --- | --- | -| `allowPrivateNetworks` | `false` | Allow connections to private/loopback/link-local IPs. | -| `allowHttp` | `false` | Allow `http://` upstreams (auto-enabled by `dev: true`). | -| `maxConcurrencyPerHost` | `10` | Per-host concurrency cap. | -| `defaultTimeoutMs` | `30000` | Per-request timeout. | -| `defaultMaxResponseBytes` | `262144` | Per-response size cap. | +| Field | Default | Description | +| ------------------------- | -------- | -------------------------------------------------------- | +| `allowPrivateNetworks` | `false` | Allow connections to private/loopback/link-local IPs. | +| `allowHttp` | `false` | Allow `http://` upstreams (auto-enabled by `dev: true`). | +| `maxConcurrencyPerHost` | `10` | Per-host concurrency cap. | +| `defaultTimeoutMs` | `30000` | Per-request timeout. | +| `defaultMaxResponseBytes` | `262144` | Per-response size cap. | Full reference: [Configuration](https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi/configuration). @@ -136,7 +137,7 @@ Details: [Security](https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi ## Documentation -Full docs: **https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi** +Full docs: **https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi/overview** - [Overview](https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi/overview) · [Quickstart](https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi/quickstart) · [Sources](https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi/sources) · [Bundle format](https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi/bundle-format) - [Configuration](https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi/configuration) · [Meta-tools](https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi/meta-tools) · [Security](https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi/security) · [API reference](https://docs.agentfront.dev/frontmcp/plugins/skilled-openapi/api-reference)