diff --git a/README.md b/README.md index c0f1e21..6db3575 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ Status: **public source; npm package and Official MCP Registry release pending**. The repository link below is live. The npm and Registry identities become available only after their separate public releases: -- npm: `@api-disk-integrations/agent-mandate-mcp@0.1.0` -- Official MCP Registry: `io.github.API-Disk-Integrations/agent-mandate@0.1.0` +- npm: `@api-disk-integrations/agent-mandate-mcp@0.1.1` +- Official MCP Registry: `io.github.API-Disk-Integrations/agent-mandate@0.1.1` - source: `API-Disk-Integrations/agent-mandate-mcp` ## Install and run @@ -23,7 +23,7 @@ back, run it with the version pinned: ```bash AGENT_MANDATE_API_KEY="$AGENT_MANDATE_API_KEY" \ - npx --yes @api-disk-integrations/agent-mandate-mcp@0.1.0 + npx --yes @api-disk-integrations/agent-mandate-mcp@0.1.1 ``` Do not commit a literal credential. A generic stdio client configuration is: @@ -33,7 +33,7 @@ Do not commit a literal credential. A generic stdio client configuration is: "mcpServers": { "agent-mandate": { "command": "npx", - "args": ["--yes", "@api-disk-integrations/agent-mandate-mcp@0.1.0"], + "args": ["--yes", "@api-disk-integrations/agent-mandate-mcp@0.1.1"], "env": { "AGENT_MANDATE_API_KEY": "${AGENT_MANDATE_API_KEY}" } @@ -47,6 +47,75 @@ Use the client host's documented secret facility if its interpolation syntax differs. The package uses stdio and reads exactly that environment variable; it has no remote `/mcp` endpoint. +## Check whether an agent action exceeds its signed mandate + +The complete first use, in two calls. Every command below was run against +production on 2026-09-15 and the decision shown is the decision returned. + +**Step 1 — create a mandate.** This returns the *signed envelope*. The tool needs +this envelope, not the bare claims you send here. + +```sh +curl -X POST https://agentmandate-api.com/v1/mandates \ + -H "authorization: Bearer $AGENT_MANDATE_API_KEY" \ + -H 'content-type: application/json' \ + -d '{ + "principal": "user_8814", + "agent": "agent_procurement_v3", + "expiresAt": "2026-12-31T23:59:59Z", + "currency": "USD", + "totalSpendCapMinor": 500000, + "grants": [{ + "action": "payments.transfer", + "resources": ["vendor.acme"], + "maxAmountMinor": 100000, + "approvalRequiredAboveMinor": 25000 + }] + }' +``` + +Answers `201` with `{"mandate": {…}, "signature": "…", "requestId": "…"}`. +**That whole response body is the envelope.** Pass it through unchanged. + +**Step 2 — verify an action against it.** Call `verify_action` with the envelope as +`mandate`: + +```json +{ + "mandate": { "mandate": { "…": "…" }, "signature": "…" }, + "action": { + "agent": "agent_procurement_v3", + "action": "payments.transfer", + "resource": "vendor.acme", + "amountMinor": 30000, + "currency": "USD" + } +} +``` + +The grant allows up to 100000 minor units but requires approval above 25000, and the +action asks for 30000. So the correct answer is **`requires_approval`**, with the +violation `Actions above 25000 minor units need a human approval token.` + +**A `deny` or `requires_approval` is a correct result, not a failure.** The point of +the tool is that it says no when the mandate says no. + +### If you get HTTP 400 + +`"mandate.mandate" must be the claims object` means bare claims were passed where the +envelope belongs. The keyless demo route `POST /v1/demo/verify` takes bare claims and +needs no key, which is why the shapes differ. Run step 1 and pass its whole response. + +### Try it with no key at all + +```sh +curl -X POST https://agentmandate-api.com/v1/demo/verify \ + -H 'content-type: application/json' \ + -d '{"mandate":{"principal":"user_8814","agent":"a1","expiresAt":"2026-12-31T23:59:59Z","currency":"USD","totalSpendCapMinor":500000,"grants":[{"action":"payments.transfer","resources":["vendor.acme"],"maxAmountMinor":100000,"approvalRequiredAboveMinor":25000}]},"action":{"agent":"a1","action":"payments.transfer","resource":"vendor.acme","amountMinor":30000,"currency":"USD"}}' +``` + +Note this route takes **bare claims**, not the envelope. + ## Tool contract There is exactly one tool, `verify_action`. Supply either one `action` or an diff --git a/dist/src/schema.js b/dist/src/schema.js index ff31c27..4a8ba1f 100644 --- a/dist/src/schema.js +++ b/dist/src/schema.js @@ -15,7 +15,21 @@ const actionSchema = z.object({ } }); export const verifyActionInput = z.object({ - mandate: z.record(z.string(), z.unknown()), + // The SIGNED ENVELOPE returned by POST /v1/mandates, not a bare claims object. + // + // This was `z.record(z.string(), z.unknown())`, which accepted anything. The + // free demo route POST /v1/demo/verify takes bare claims and works, so the + // landing page, the playground and the published Postman example all teach + // that shape. A developer who followed them and called this tool got an + // HTTP 400 naming an internal field path, with no route to a signed mandate. + // + // Requiring the envelope here means the tool refuses locally with a message + // that names the actual cause, instead of forwarding a request that cannot + // succeed. + mandate: z.object({ + mandate: z.record(z.string(), z.unknown()), + signature: z.string().min(1), + }).passthrough(), action: actionSchema.optional(), actions: z.array(actionSchema).min(1).max(500).optional(), }).strict().superRefine((value, ctx) => { diff --git a/dist/src/server.js b/dist/src/server.js index d6125fb..b385404 100644 --- a/dist/src/server.js +++ b/dist/src/server.js @@ -15,7 +15,11 @@ export function createServer(options = {}) { }); server.registerTool('verify_action', { title: 'Verify an action against an Agent Mandate', - description: 'Verification only: evaluate supplied action facts against a supplied signed mandate. This tool does not execute the action or mutate any account.', + description: 'Verification only: evaluate supplied action facts against a signed mandate. ' + + 'The `mandate` argument is the envelope returned by POST /v1/mandates, which carries ' + + 'the claims under `mandate` alongside its `signature` — pass that response through unchanged. ' + + 'A bare claims object is what the keyless POST /v1/demo/verify route accepts, and it is ' + + 'rejected here. This tool does not execute the action or mutate any account.', inputSchema: verifyActionInput, outputSchema: verifyActionOutput, annotations: { diff --git a/dist/test/discovery.test.js b/dist/test/discovery.test.js index 6c00cbb..27aa6b0 100644 --- a/dist/test/discovery.test.js +++ b/dist/test/discovery.test.js @@ -42,7 +42,7 @@ test('a missing caller credential fails closed without making a request', async await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); const result = await client.callTool({ name: 'verify_action', - arguments: { mandate: { signature: 'fixture' }, action: { action: 'inventory.read' } }, + arguments: { mandate: { mandate: { id: 'mnd_fixture' }, signature: 'fixture' }, action: { action: 'inventory.read' } }, }); assert.equal(result.isError, true); assert.equal(calls, 0); @@ -62,7 +62,7 @@ test('the MCP error boundary never surfaces provider-controlled strings', async await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); const result = await client.callTool({ name: 'verify_action', - arguments: { mandate: { signature: 'fixture' }, action: { action: 'inventory.read' } }, + arguments: { mandate: { mandate: { id: 'mnd_fixture' }, signature: 'fixture' }, action: { action: 'inventory.read' } }, }); assert.equal(result.isError, true); assert.equal(result.content[0]?.type, 'text'); @@ -102,7 +102,7 @@ test('a client pinned to protocol revision 2026-07-28 discovers and calls the ex const result = await client.callTool({ name: 'verify_action', arguments: { - mandate: { signature: 'fixture' }, + mandate: { mandate: { id: 'mnd_fixture' }, signature: 'fixture' }, action: { action: 'inventory.read' }, }, }); diff --git a/dist/test/signed-envelope.test.js b/dist/test/signed-envelope.test.js new file mode 100644 index 0000000..f3b6288 --- /dev/null +++ b/dist/test/signed-envelope.test.js @@ -0,0 +1,57 @@ +/** + * Regression test for the defect found by the 2026-09-15 MCP canary. + * + * The free route POST /v1/demo/verify accepts BARE CLAIMS and works, so the + * landing page, the playground and the published Postman example all teach that + * shape. The paid POST /v1/verify requires the SIGNED ENVELOPE returned by + * POST /v1/mandates. + * + * The tool's schema used to be `z.record(z.string(), z.unknown())`, which + * accepted the bare shape and forwarded it, so a developer following the public + * instructions received HTTP 400 naming an internal field path, with nothing + * pointing at /v1/mandates. + * + * These tests assert the tool now refuses locally, and that a well-formed + * envelope is still accepted. They must not be deleted to make a build pass: + * the first one is the bug. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { verifyActionInput } from '../src/schema.js'; +const action = { action: 'payments.transfer', resource: 'vendor.acme', amountMinor: 30_000, currency: 'USD' }; +/** Exactly what the landing page, the demo and the Postman example teach. */ +const BARE_CLAIMS = { + principal: 'user_8814', + agent: 'agent_procurement_v3', + expiresAt: '2026-12-31T23:59:59Z', + currency: 'USD', + totalSpendCapMinor: 500_000, + grants: [{ action: 'payments.transfer', resources: ['vendor.acme'], maxAmountMinor: 100_000 }], +}; +test('bare claims are refused locally, so the 400 never has to happen', () => { + const r = verifyActionInput.safeParse({ mandate: BARE_CLAIMS, action }); + assert.equal(r.success, false, 'the bare-claims shape must not be accepted'); +}); +test('a mandate with no signature is refused', () => { + const r = verifyActionInput.safeParse({ mandate: { mandate: BARE_CLAIMS }, action }); + assert.equal(r.success, false, 'an envelope without a signature must not be accepted'); +}); +test('an empty signature is refused', () => { + const r = verifyActionInput.safeParse({ mandate: { mandate: BARE_CLAIMS, signature: '' }, action }); + assert.equal(r.success, false, 'an empty signature must not be accepted'); +}); +test('the envelope POST /v1/mandates actually returns is accepted', () => { + // Shape confirmed against production on 2026-09-15: the response body carries + // the claims under `mandate`, a `signature`, and a `requestId` alongside. + const envelope = { mandate: { ...BARE_CLAIMS, id: 'mnd_live' }, signature: 'sig', requestId: 'req_1' }; + const r = verifyActionInput.safeParse({ mandate: envelope, action }); + assert.equal(r.success, true, 'the real /v1/mandates response must pass unchanged'); +}); +test('the envelope is passed through unchanged, extra fields preserved', () => { + const envelope = { mandate: { ...BARE_CLAIMS }, signature: 'sig', requestId: 'req_1' }; + const r = verifyActionInput.safeParse({ mandate: envelope, action }); + assert.equal(r.success, true); + if (r.success) { + assert.equal(r.data.mandate['requestId'], 'req_1', 'passthrough must preserve fields the API may rely on'); + } +}); diff --git a/package.json b/package.json index 14501ed..888511b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@api-disk-integrations/agent-mandate-mcp", - "version": "0.1.0", + "version": "0.1.1", "mcpName": "io.github.API-Disk-Integrations/agent-mandate", "description": "Verification-only MCP server for evaluating actions against signed Agent Mandates.", "license": "MIT", diff --git a/server.json b/server.json index 8d4ad9e..fbb0e2f 100644 --- a/server.json +++ b/server.json @@ -8,13 +8,13 @@ "url": "https://github.com/API-Disk-Integrations/agent-mandate-mcp", "source": "github" }, - "version": "0.1.0", + "version": "0.1.1", "packages": [ { "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "@api-disk-integrations/agent-mandate-mcp", - "version": "0.1.0", + "version": "0.1.1", "runtimeHint": "npx", "transport": { "type": "stdio" @@ -30,4 +30,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/src/schema.ts b/src/schema.ts index 5526477..d81c660 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -17,7 +17,21 @@ const actionSchema = z.object({ }) export const verifyActionInput = z.object({ - mandate: z.record(z.string(), z.unknown()), + // The SIGNED ENVELOPE returned by POST /v1/mandates, not a bare claims object. + // + // This was `z.record(z.string(), z.unknown())`, which accepted anything. The + // free demo route POST /v1/demo/verify takes bare claims and works, so the + // landing page, the playground and the published Postman example all teach + // that shape. A developer who followed them and called this tool got an + // HTTP 400 naming an internal field path, with no route to a signed mandate. + // + // Requiring the envelope here means the tool refuses locally with a message + // that names the actual cause, instead of forwarding a request that cannot + // succeed. + mandate: z.object({ + mandate: z.record(z.string(), z.unknown()), + signature: z.string().min(1), + }).passthrough(), action: actionSchema.optional(), actions: z.array(actionSchema).min(1).max(500).optional(), }).strict().superRefine((value, ctx) => { diff --git a/src/server.ts b/src/server.ts index 008c6a2..924d336 100644 --- a/src/server.ts +++ b/src/server.ts @@ -30,7 +30,11 @@ export function createServer(options: ServerOptions = {}): McpServer { { title: 'Verify an action against an Agent Mandate', description: - 'Verification only: evaluate supplied action facts against a supplied signed mandate. This tool does not execute the action or mutate any account.', + 'Verification only: evaluate supplied action facts against a signed mandate. ' + + 'The `mandate` argument is the envelope returned by POST /v1/mandates, which carries ' + + 'the claims under `mandate` alongside its `signature` — pass that response through unchanged. ' + + 'A bare claims object is what the keyless POST /v1/demo/verify route accepts, and it is ' + + 'rejected here. This tool does not execute the action or mutate any account.', inputSchema: verifyActionInput, outputSchema: verifyActionOutput, annotations: { diff --git a/test/discovery.test.ts b/test/discovery.test.ts index 0b099bd..9db6415 100644 --- a/test/discovery.test.ts +++ b/test/discovery.test.ts @@ -47,7 +47,7 @@ test('a missing caller credential fails closed without making a request', async await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) const result = await client.callTool({ name: 'verify_action', - arguments: { mandate: { signature: 'fixture' }, action: { action: 'inventory.read' } }, + arguments: { mandate: { mandate: { id: 'mnd_fixture' }, signature: 'fixture' }, action: { action: 'inventory.read' } }, }) assert.equal(result.isError, true) assert.equal(calls, 0) @@ -68,7 +68,7 @@ test('the MCP error boundary never surfaces provider-controlled strings', async await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) const result = await client.callTool({ name: 'verify_action', - arguments: { mandate: { signature: 'fixture' }, action: { action: 'inventory.read' } }, + arguments: { mandate: { mandate: { id: 'mnd_fixture' }, signature: 'fixture' }, action: { action: 'inventory.read' } }, }) assert.equal(result.isError, true) assert.equal(result.content[0]?.type, 'text') @@ -118,7 +118,7 @@ test('a client pinned to protocol revision 2026-07-28 discovers and calls the ex const result = await client.callTool({ name: 'verify_action', arguments: { - mandate: { signature: 'fixture' }, + mandate: { mandate: { id: 'mnd_fixture' }, signature: 'fixture' }, action: { action: 'inventory.read' }, }, }) diff --git a/test/signed-envelope.test.ts b/test/signed-envelope.test.ts new file mode 100644 index 0000000..048fe93 --- /dev/null +++ b/test/signed-envelope.test.ts @@ -0,0 +1,65 @@ +/** + * Regression test for the defect found by the 2026-09-15 MCP canary. + * + * The free route POST /v1/demo/verify accepts BARE CLAIMS and works, so the + * landing page, the playground and the published Postman example all teach that + * shape. The paid POST /v1/verify requires the SIGNED ENVELOPE returned by + * POST /v1/mandates. + * + * The tool's schema used to be `z.record(z.string(), z.unknown())`, which + * accepted the bare shape and forwarded it, so a developer following the public + * instructions received HTTP 400 naming an internal field path, with nothing + * pointing at /v1/mandates. + * + * These tests assert the tool now refuses locally, and that a well-formed + * envelope is still accepted. They must not be deleted to make a build pass: + * the first one is the bug. + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { verifyActionInput } from '../src/schema.js' + +const action = { action: 'payments.transfer', resource: 'vendor.acme', amountMinor: 30_000, currency: 'USD' } + +/** Exactly what the landing page, the demo and the Postman example teach. */ +const BARE_CLAIMS = { + principal: 'user_8814', + agent: 'agent_procurement_v3', + expiresAt: '2026-12-31T23:59:59Z', + currency: 'USD', + totalSpendCapMinor: 500_000, + grants: [{ action: 'payments.transfer', resources: ['vendor.acme'], maxAmountMinor: 100_000 }], +} + +test('bare claims are refused locally, so the 400 never has to happen', () => { + const r = verifyActionInput.safeParse({ mandate: BARE_CLAIMS, action }) + assert.equal(r.success, false, 'the bare-claims shape must not be accepted') +}) + +test('a mandate with no signature is refused', () => { + const r = verifyActionInput.safeParse({ mandate: { mandate: BARE_CLAIMS }, action }) + assert.equal(r.success, false, 'an envelope without a signature must not be accepted') +}) + +test('an empty signature is refused', () => { + const r = verifyActionInput.safeParse({ mandate: { mandate: BARE_CLAIMS, signature: '' }, action }) + assert.equal(r.success, false, 'an empty signature must not be accepted') +}) + +test('the envelope POST /v1/mandates actually returns is accepted', () => { + // Shape confirmed against production on 2026-09-15: the response body carries + // the claims under `mandate`, a `signature`, and a `requestId` alongside. + const envelope = { mandate: { ...BARE_CLAIMS, id: 'mnd_live' }, signature: 'sig', requestId: 'req_1' } + const r = verifyActionInput.safeParse({ mandate: envelope, action }) + assert.equal(r.success, true, 'the real /v1/mandates response must pass unchanged') +}) + +test('the envelope is passed through unchanged, extra fields preserved', () => { + const envelope = { mandate: { ...BARE_CLAIMS }, signature: 'sig', requestId: 'req_1' } + const r = verifyActionInput.safeParse({ mandate: envelope, action }) + assert.equal(r.success, true) + if (r.success) { + assert.equal((r.data.mandate as Record)['requestId'], 'req_1', + 'passthrough must preserve fields the API may rely on') + } +})