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/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-20260728/e2e/backward-compat.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/backward-compat.e2e.spec.ts new file mode 100644 index 000000000..134752eb9 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-stateless-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-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', + 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-20260728/e2e/cacheable-results.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/cacheable-results.e2e.spec.ts new file mode 100644 index 000000000..c7c176a82 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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 { mcpStatelessFetch } from './helpers/mcp-stateless-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-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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 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-20260728/e2e/client.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/client.e2e.spec.ts new file mode 100644 index 000000000..f6964f3b4 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/client.e2e.spec.ts @@ -0,0 +1,216 @@ +/** + * `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 { + McpStatelessClient, + McpStatelessClientAdapter, + McpStatelessError, + negotiateRemoteProtocol, +} from '@frontmcp/sdk'; +import { expect, test } from '@frontmcp/testing'; + +import type { ListedTool } from './helpers/mcp-stateless-client'; + +test.describe('protocol 2026-07-28 — McpStatelessClient', () => { + test.use({ + 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 McpStatelessClient({ 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 McpStatelessError with its JSON-RPC code', async ({ server }) => { + const mcp = client(server); + + await expect(mcp.readResource('proto://missing')).rejects.toMatchObject({ + name: 'McpStatelessError', + 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 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 McpStatelessError(-32022, 'Unsupported protocol version: 2099-01-01', { supported: [] }); + expect(error.code).toBe(-32022); + expect(error.name).toBe('McpStatelessError'); + }); +}); + +test.describe('protocol 2026-07-28 — remote-proxy adapter', () => { + test.use({ + server: 'apps/e2e/demo-e2e-protocol-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', + 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 McpStatelessClientAdapter({ url: server.info.baseUrl }); + await adapter.connect(); + + expect(adapter.getServerCapabilities()).toBeDefined(); + + const { tools } = await adapter.listTools(); + 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'); + + 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-20260728/e2e/discover.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/discover.e2e.spec.ts new file mode 100644 index 000000000..9d41a6a5d --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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 { 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-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 mcpStatelessFetch(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 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_20260728); + }); + + test('still advertises the legacy versions it supports', async ({ server }) => { + 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 + // 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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 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 + // 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-20260728/e2e/errors-and-removals.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/errors-and-removals.e2e.spec.ts new file mode 100644 index 000000000..d335e89b6 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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, mcpStatelessFetch, METHOD_NOT_FOUND, PROTOCOL_20260728 } from './helpers/mcp-stateless-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-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 mcpStatelessFetch(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 mcpStatelessFetch(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_20260728, + }, + }); + + 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_20260728, + 'mcp-session-id': 'anything', + }, + }); + + expect(res.status).toBe(405); + }); + + test('an unknown method still returns 404 + -32601', async ({ server }) => { + 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); + }); + + 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_20260728, + '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 mcpStatelessFetch(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-20260728/e2e/helpers/mcp-stateless-client.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/helpers/mcp-stateless-client.ts new file mode 100644 index 000000000..93987c16e --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/helpers/mcp-stateless-client.ts @@ -0,0 +1,294 @@ +/** + * 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_20260728 = '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')}?=`; +} + +/** 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; + method: string; + params?: Record; +} + +export interface McpStatelessCallOptions { + /** 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 McpStatelessResponse { + 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 buildMcpStatelessRequest(opts: McpStatelessCallOptions): { + body: JsonRpcRequestBody; + headers: Record; +} { + const protocolVersion = opts.protocolVersion ?? PROTOCOL_20260728; + + 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 mcpStatelessFetch(baseUrl: string, opts: McpStatelessCallOptions): Promise { + const { body, headers } = buildMcpStatelessRequest(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 openMcpStatelessStream(baseUrl: string, opts: McpStatelessCallOptions): Promise { + const { body, headers } = buildMcpStatelessRequest(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-20260728/e2e/mrtr-sampling-roots.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/mrtr-sampling-roots.e2e.spec.ts new file mode 100644 index 000000000..e891953f8 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/mrtr-sampling-roots.e2e.spec.ts @@ -0,0 +1,204 @@ +/** + * 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 { + mcpStatelessFetch, + MISSING_REQUIRED_CLIENT_CAPABILITY, + type InputRequest, +} from './helpers/mcp-stateless-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-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 mcpStatelessFetch(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, InputRequest]; + 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 mcpStatelessFetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 2 }); + const { result: interim } = first.json(); + const [key] = Object.keys(interim.inputRequests); + + const second = await mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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, InputRequest]; + expect(request.method).toBe('roots/list'); + }); + + test('completes the call when the client supplies its roots', async ({ server }) => { + 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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(server.info.baseUrl, { ...SAMPLING_CALL, id: 11 }); + const { result: interim } = first.json(); + expect(interim.resultType).toBe('input_required'); + + // 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 mcpStatelessFetch(server.info.baseUrl, { + ...SAMPLING_CALL, + id: 12, + params: { + name: 'summarize', + arguments: { text: 'a DIFFERENT document' }, + requestState: interim.requestState, + }, + }); + + const { result } = res.json(); + expect(result.resultType).toBe('input_required'); + }); + + test('accepts a legitimately signed requestState', async ({ server }) => { + 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 mcpStatelessFetch(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-20260728/e2e/mrtr.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/mrtr.e2e.spec.ts new file mode 100644 index 000000000..dcd06942f --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/mrtr.e2e.spec.ts @@ -0,0 +1,163 @@ +/** + * 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 { + mcpStatelessFetch, + MISSING_REQUIRED_CLIENT_CAPABILITY, + type InputRequest, +} from './helpers/mcp-stateless-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-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 mcpStatelessFetch(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 mcpStatelessFetch(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, InputRequest]; + 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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 5 }); + const { result: interim } = first.json(); + + const [key] = Object.keys(interim.inputRequests); + + const second = await mcpStatelessFetch(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 mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 7 }); + const { result: interim } = first.json(); + const [key] = Object.keys(interim.inputRequests); + + const second = await mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(server.info.baseUrl, { ...ELICITING_CALL, id: 11 }); + const { result } = res.json(); + 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-20260728/e2e/request-headers.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/request-headers.e2e.spec.ts new file mode 100644 index 000000000..40de41266 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/request-headers.e2e.spec.ts @@ -0,0 +1,231 @@ +/** + * 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, + mcpStatelessFetch, + META_PROTOCOL_VERSION, + PROTOCOL_20260728, + UNSUPPORTED_PROTOCOL_VERSION, + type ListedTool, +} from './helpers/mcp-stateless-client'; + +test.describe('protocol 2026-07-28 — request metadata headers', () => { + test.use({ + 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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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_20260728); + }); + + test('accepts a matching Mcp-Param-* header from x-mcp-header', async ({ server }) => { + const res = await mcpStatelessFetch(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 mcpStatelessFetch(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 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 mcpStatelessFetch(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_20260728, + '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-20260728/e2e/request-notifications.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/request-notifications.e2e.spec.ts new file mode 100644 index 000000000..9b9cf330b --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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 { mcpStatelessFetch, META_SERVER_INFO, parseSseEvents, type ListedTool } from './helpers/mcp-stateless-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-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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 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 mcpStatelessFetch(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-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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', + publicMode: true, + }); + + test('returns tools sorted by name', async ({ server }) => { + 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()); + }); + + 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 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-20260728/e2e/stateless-requests.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/stateless-requests.e2e.spec.ts new file mode 100644 index 000000000..33b0e93af --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/stateless-requests.e2e.spec.ts @@ -0,0 +1,155 @@ +/** + * 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 { + mcpStatelessFetch, + META_SERVER_INFO, + MISSING_REQUIRED_CLIENT_CAPABILITY, + type ListedTool, + type McpStatelessResponse, +} from './helpers/mcp-stateless-client'; + +test.describe('protocol 2026-07-28 — stateless requests', () => { + test.use({ + 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 mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 1 }); + + expect(res.status).toBe(200); + const { result, error } = res.json(); + expect(error).toBeUndefined(); + 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 }) => { + 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. + expect(res.headers.get('mcp-session-id')).toBeNull(); + }); + + test('ignores an Mcp-Session-Id sent by a confused client', async ({ server }) => { + const res = await mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(server.info.baseUrl, { + method: 'tools/list', + id: 6, + clientCapabilities: { elicitation: { form: {} } }, + }); + + const res = await mcpStatelessFetch(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 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 mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 8 }); + const second = await mcpStatelessFetch(server.info.baseUrl, { method: 'tools/list', id: 9 }); + + 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-20260728/e2e/subscriptions-listen.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/subscriptions-listen.e2e.spec.ts new file mode 100644 index 000000000..a032cd643 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/subscriptions-listen.e2e.spec.ts @@ -0,0 +1,200 @@ +/** + * `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 { + 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-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', + publicMode: true, + }); + + test('opens an SSE response stream', async ({ server }) => { + const stream = await openMcpStatelessStream(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 openMcpStatelessStream(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 openMcpStatelessStream(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 openMcpStatelessStream(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 openMcpStatelessStream(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 openMcpStatelessStream(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 openMcpStatelessStream(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 openMcpStatelessStream(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(); + } + }); +}); + +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-20260728/e2e/tasks-anonymous.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/tasks-anonymous.e2e.spec.ts new file mode 100644 index 000000000..ad519f9a2 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/tasks-anonymous.e2e.spec.ts @@ -0,0 +1,49 @@ +/** + * 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 { 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-20260728/src/main.ts', + project: 'demo-e2e-protocol-20260728', + publicMode: true, + }); + + test('refuses tasks/get for an anonymous caller', async ({ server }) => { + const res = await mcpStatelessFetch(server.info.baseUrl, { + method: 'tasks/get', + id: 1, + params: { taskId: 'anything' }, + clientCapabilities: TASKS_EXT, + }); + + expect(res.json().error.code).toBe(INVALID_PARAMS); + expect(res.json().error.message).toContain('authenticated caller'); + }); + + test('refuses tasks/update for an anonymous caller', async ({ server }) => { + const res = await mcpStatelessFetch(server.info.baseUrl, { + method: 'tasks/update', + id: 2, + params: { taskId: 'anything', inputResponses: {} }, + 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-20260728/e2e/tasks-extension.e2e.spec.ts b/apps/e2e/demo-e2e-protocol-20260728/e2e/tasks-extension.e2e.spec.ts new file mode 100644 index 000000000..da75faf2e --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/e2e/tasks-extension.e2e.spec.ts @@ -0,0 +1,284 @@ +/** + * `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 { + mcpStatelessFetch, + METHOD_NOT_FOUND, + MISSING_REQUIRED_CLIENT_CAPABILITY, + type InputRequest, + type TaskWire, +} from './helpers/mcp-stateless-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: TaskWire) => boolean, + timeoutMs = 15_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: TaskWire | undefined; + let id = 9000; + while (Date.now() < deadline) { + const res = await mcpStatelessFetch(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-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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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, InputRequest]; + 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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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 mcpStatelessFetch(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-20260728/jest.e2e.config.ts b/apps/e2e/demo-e2e-protocol-20260728/jest.e2e.config.ts new file mode 100644 index 000000000..f9d1106dd --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728', + 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-20260728', + ...e2eCoveragePreset, +}; + +export default config; diff --git a/apps/e2e/demo-e2e-protocol-20260728/project.json b/apps/e2e/demo-e2e-protocol-20260728/project.json new file mode 100644 index 000000000..da4e8f6ae --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/project.json @@ -0,0 +1,45 @@ +{ + "name": "demo-e2e-protocol-20260728", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/e2e/demo-e2e-protocol-20260728/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-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": { + "development": {}, + "production": { + "optimization": true + } + } + }, + "serve": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "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-20260728"], + "options": { + "jestConfig": "apps/e2e/demo-e2e-protocol-20260728/jest.e2e.config.ts", + "passWithNoTests": true + } + } + } +} diff --git a/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/index.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/index.ts new file mode 100644 index 000000000..280636daf --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/index.ts @@ -0,0 +1,27 @@ +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. + * + * 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, SummarizeTool, ListWorkspacesTool, ChattyTool], + resources: [ConfigResource], + prompts: [GreetingPrompt], +}) +export class ProtoApp {} diff --git a/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/prompts/greeting.prompt.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/prompts/greeting.prompt.ts new file mode 100644 index 000000000..02b5cec6f --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/src/apps/proto/resources/config.resource.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/resources/config.resource.ts new file mode 100644 index 000000000..a5898d75f --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/src/apps/proto/tools/chatty.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/chatty.tool.ts new file mode 100644 index 000000000..6313196b7 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/src/apps/proto/tools/confirm.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/confirm.tool.ts new file mode 100644 index 000000000..78e60e630 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/src/apps/proto/tools/echo.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/echo.tool.ts new file mode 100644 index 000000000..cbc17727a --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/src/apps/proto/tools/list-workspaces.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/list-workspaces.tool.ts new file mode 100644 index 000000000..2cbfb48cd --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/src/apps/proto/tools/region-query.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/region-query.tool.ts new file mode 100644 index 000000000..595d7d466 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/src/apps/proto/tools/summarize.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/proto/tools/summarize.tool.ts new file mode 100644 index 000000000..c2470e6e3 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/src/apps/tasks/index.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/tasks/index.ts new file mode 100644 index 000000000..22b92f5b6 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/src/apps/tasks/tools/approve-job.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/tasks/tools/approve-job.tool.ts new file mode 100644 index 000000000..8adb99217 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/src/apps/tasks/tools/slow-job.tool.ts b/apps/e2e/demo-e2e-protocol-20260728/src/apps/tasks/tools/slow-job.tool.ts new file mode 100644 index 000000000..83787532c --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/src/main-tasks.ts b/apps/e2e/demo-e2e-protocol-20260728/src/main-tasks.ts new file mode 100644 index 000000000..f108834d4 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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/apps/e2e/demo-e2e-protocol-20260728/src/main.ts b/apps/e2e/demo-e2e-protocol-20260728/src/main.ts new file mode 100644 index 000000000..19437c0c0 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/tsconfig.app.json b/apps/e2e/demo-e2e-protocol-20260728/tsconfig.app.json new file mode 100644 index 000000000..3fdc8911e --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/tsconfig.e2e.json b/apps/e2e/demo-e2e-protocol-20260728/tsconfig.e2e.json new file mode 100644 index 000000000..2e879f3d2 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/tsconfig.json b/apps/e2e/demo-e2e-protocol-20260728/tsconfig.json new file mode 100644 index 000000000..a72bf4f01 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728/webpack.config.js b/apps/e2e/demo-e2e-protocol-20260728/webpack.config.js new file mode 100644 index 000000000..7c2b400b5 --- /dev/null +++ b/apps/e2e/demo-e2e-protocol-20260728/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-20260728'), + ...(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/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/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/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/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/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/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-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/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-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/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-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/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) 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"