From 54d5319dc9c7c81392ea925795d6237a2011fd33 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 16:58:40 -0400 Subject: [PATCH] feat(server): add mcpToolNameResolver helper for signature auth wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RFC 9421 signing composition helpers (`verifySignatureAsAuthenticator`, `requireSignatureWhenPresent`, `requireAuthenticatedOrSigned`) all take a `resolveOperation` callback that, for MCP agents, parses `req.rawBody` as JSON-RPC and returns `params.name` when `method === 'tools/call'`. Every adapter ships the same 6-line parser; the SDK JSDoc inlines it four separate times. Export `mcpToolNameResolver` so adapter authors pass `resolveOperation: mcpToolNameResolver` instead of copy-pasting the parser. A2A agents use a different envelope — bespoke resolvers still apply there. JSDoc examples in `auth-signature.ts` are rewritten to use the helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/mcp-tool-name-resolver.md | 7 ++ src/lib/server/auth-signature.ts | 85 +++++++++++++++++-------- src/lib/server/index.ts | 1 + test/lib/mcp-tool-name-resolver.test.js | 54 ++++++++++++++++ 4 files changed, 119 insertions(+), 28 deletions(-) create mode 100644 .changeset/mcp-tool-name-resolver.md create mode 100644 test/lib/mcp-tool-name-resolver.test.js diff --git a/.changeset/mcp-tool-name-resolver.md b/.changeset/mcp-tool-name-resolver.md new file mode 100644 index 000000000..dbf54b695 --- /dev/null +++ b/.changeset/mcp-tool-name-resolver.md @@ -0,0 +1,7 @@ +--- +"@adcp/client": minor +--- + +Add `mcpToolNameResolver` export on `@adcp/client/server` — a default `resolveOperation` for MCP agents wiring RFC 9421 signature auth. Parses the buffered JSON-RPC body on `req.rawBody` and returns `params.name` when `method === 'tools/call'`; returns `undefined` otherwise so the downstream handler produces a precise error instead of the pre-check rejecting every unsigned call. + +Use directly as `resolveOperation` on `verifySignatureAsAuthenticator`, `requireSignatureWhenPresent`, or `requireAuthenticatedOrSigned` instead of hand-rolling the same JSON-RPC parser in every seller agent. diff --git a/src/lib/server/auth-signature.ts b/src/lib/server/auth-signature.ts index 8299ab891..0ec00eda4 100644 --- a/src/lib/server/auth-signature.ts +++ b/src/lib/server/auth-signature.ts @@ -14,7 +14,13 @@ * rejected" rather than falling through. * * ```ts - * import { serve, verifyApiKey, anyOf, verifySignatureAsAuthenticator } from '@adcp/client/server'; + * import { + * serve, + * verifyApiKey, + * anyOf, + * verifySignatureAsAuthenticator, + * mcpToolNameResolver, + * } from '@adcp/client/server'; * * serve(createAgent, { * authenticate: anyOf( @@ -22,13 +28,7 @@ * verifySignatureAsAuthenticator({ * jwks, replayStore, revocationStore, * capability: { supported: true, required_for: [], covers_content_digest: 'either' }, - * resolveOperation: req => { - * try { - * const body = JSON.parse(req.rawBody ?? ''); - * if (body.method === 'tools/call') return body.params?.name; - * } catch {} - * return undefined; - * }, + * resolveOperation: mcpToolNameResolver, * }), * ), * }); @@ -276,18 +276,9 @@ export interface RequireSignatureWhenPresentOptions { * Extract the AdCP operation name (or any identifier that can be * matched against `requiredFor`) from the incoming request. * - * For MCP agents, this usually parses `req.rawBody` as JSON-RPC and - * pulls `params.name` when `method === 'tools/call'`: - * - * ```ts - * resolveOperation: (req) => { - * try { - * const body = JSON.parse(req.rawBody ?? ''); - * if (body?.method === 'tools/call') return body.params?.name; - * } catch {} - * return undefined; - * } - * ``` + * For MCP agents, pass the exported {@link mcpToolNameResolver} — it + * implements the standard `tools/call` → `params.name` parse. For A2A + * agents (or other non-JSON-RPC envelopes), supply a bespoke resolver. * * When `requiredFor` is set but `resolveOperation` is omitted OR * returns `undefined`, the pre-check is skipped — better to let the @@ -433,21 +424,19 @@ export interface RequireAuthenticatedOrSignedOptions { * anyOf, * verifySignatureAsAuthenticator, * requireAuthenticatedOrSigned, + * mcpToolNameResolver, * MUTATING_TASKS, * } from '@adcp/client/server'; * * serve(createAgent, { * authenticate: requireAuthenticatedOrSigned({ - * signature: verifySignatureAsAuthenticator({ jwks, replayStore, revocationStore, capability, resolveOperation }), + * signature: verifySignatureAsAuthenticator({ + * jwks, replayStore, revocationStore, capability, + * resolveOperation: mcpToolNameResolver, + * }), * fallback: anyOf(verifyApiKey({ keys }), verifyBearer({ jwksUri, issuer, audience })), * requiredFor: [...MUTATING_TASKS], - * resolveOperation: req => { - * try { - * const body = JSON.parse(req.rawBody ?? ''); - * if (body?.method === 'tools/call') return body.params?.name; - * } catch {} - * return undefined; - * }, + * resolveOperation: mcpToolNameResolver, * }), * }); * ``` @@ -459,6 +448,46 @@ export function requireAuthenticatedOrSigned(options: RequireAuthenticatedOrSign }); } +/** + * Default `resolveOperation` for MCP agents. Parses the buffered JSON-RPC + * body on `req.rawBody` and returns `params.name` when `method === 'tools/call'`. + * Returns `undefined` for non-`tools/call` methods, a missing body, or + * malformed JSON — the same "skip the pre-check and let the handler produce + * a precise error" semantics every other `resolveOperation` in the SDK + * uses. + * + * Pass directly as `resolveOperation` on {@link verifySignatureAsAuthenticator}, + * {@link requireSignatureWhenPresent}, or {@link requireAuthenticatedOrSigned}: + * + * ```ts + * serve(createAgent, { + * authenticate: requireAuthenticatedOrSigned({ + * signature: verifySignatureAsAuthenticator({ + * jwks, replayStore, revocationStore, capability, + * resolveOperation: mcpToolNameResolver, + * }), + * fallback: anyOf(verifyApiKey({ keys }), verifyBearer({ jwksUri, issuer, audience })), + * requiredFor: [...MUTATING_TASKS], + * resolveOperation: mcpToolNameResolver, + * }), + * }); + * ``` + * + * A2A agents use a different envelope — write a bespoke resolver there. + */ +export function mcpToolNameResolver(req: IncomingMessage & { rawBody?: string }): string | undefined { + const raw = req.rawBody; + if (!raw) return undefined; + try { + const body = JSON.parse(raw) as { method?: unknown; params?: unknown }; + if (body?.method !== 'tools/call') return undefined; + const params = body.params as { name?: unknown } | undefined; + return typeof params?.name === 'string' ? params.name : undefined; + } catch { + return undefined; + } +} + const SAFE_KEYID = /^[A-Za-z0-9._-]{1,256}$/; function hasSignatureHeader(req: IncomingMessage): boolean { diff --git a/src/lib/server/index.ts b/src/lib/server/index.ts index b11a26842..b9d7545f0 100644 --- a/src/lib/server/index.ts +++ b/src/lib/server/index.ts @@ -142,6 +142,7 @@ export { verifySignatureAsAuthenticator, requireSignatureWhenPresent, requireAuthenticatedOrSigned, + mcpToolNameResolver, } from './auth-signature'; export type { VerifySignatureAsAuthenticatorOptions, diff --git a/test/lib/mcp-tool-name-resolver.test.js b/test/lib/mcp-tool-name-resolver.test.js new file mode 100644 index 000000000..c11e4bac4 --- /dev/null +++ b/test/lib/mcp-tool-name-resolver.test.js @@ -0,0 +1,54 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +const { mcpToolNameResolver } = require('../../dist/lib/server/index.js'); + +function makeReq(rawBody) { + return { + method: 'POST', + url: '/mcp', + headers: { 'content-type': 'application/json' }, + rawBody, + }; +} + +describe('mcpToolNameResolver', () => { + it('returns the tool name for a tools/call JSON-RPC request', () => { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'create_media_buy', arguments: { plan_id: 'p_1' } }, + }); + assert.strictEqual(mcpToolNameResolver(makeReq(body)), 'create_media_buy'); + }); + + it('returns undefined for non-tools/call methods', () => { + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + assert.strictEqual(mcpToolNameResolver(makeReq(body)), undefined); + }); + + it('returns undefined when params.name is missing', () => { + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: {} }); + assert.strictEqual(mcpToolNameResolver(makeReq(body)), undefined); + }); + + it('returns undefined when params.name is not a string', () => { + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 42 } }); + assert.strictEqual(mcpToolNameResolver(makeReq(body)), undefined); + }); + + it('returns undefined for malformed JSON', () => { + assert.strictEqual(mcpToolNameResolver(makeReq('{ not json')), undefined); + }); + + it('returns undefined when rawBody is missing or empty', () => { + assert.strictEqual(mcpToolNameResolver(makeReq(undefined)), undefined); + assert.strictEqual(mcpToolNameResolver(makeReq('')), undefined); + }); + + it('returns undefined when params is absent on a tools/call request', () => { + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call' }); + assert.strictEqual(mcpToolNameResolver(makeReq(body)), undefined); + }); +});