Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/mcp-tool-name-resolver.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 57 additions & 28 deletions src/lib/server/auth-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,21 @@
* 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(
* verifyApiKey({ keys: { 'sk_live_abc': { principal: 'acct_42' } } }),
* 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,
* }),
* ),
* });
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
* }),
* });
* ```
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/lib/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export {
verifySignatureAsAuthenticator,
requireSignatureWhenPresent,
requireAuthenticatedOrSigned,
mcpToolNameResolver,
} from './auth-signature';
export type {
VerifySignatureAsAuthenticatorOptions,
Expand Down
54 changes: 54 additions & 0 deletions test/lib/mcp-tool-name-resolver.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading