diff --git a/README.md b/README.md index 6db3575..da82c8c 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,93 @@ -# Agent Mandate MCP +# Verify a signed agent mandate through MCP -Verification-only MCP server exposing one tool: `verify_action`. +**Check a signed authorization envelope before trusting an agent's claimed authority.** -The tool sends caller-supplied mandate and action facts to Agent Mandate's -`POST /v1/verify` endpoint and returns its `allow`, `deny`, or -`requires_approval` receipts. It does not issue or revoke mandates, execute an -action, change an account, or call billing. It makes at most one API request per -tool call and never retries automatically. +Agent Mandate's MCP tool accepts the complete `{ mandate, signature }` envelope +returned by `POST /v1/mandates`. **Raw claims are not a signed envelope.** -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: +**The walkthrough:** create an envelope, verify an action through MCP, then change one +signed field and verify again. You see the real verification result for each request, +including the one that gets refused. -- 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` +**Requirements:** Node.js 20 or newer, and an Agent Mandate API key. + +**Getting a key:** the free tier includes **500 verified actions per month** and needs +no card. Issuing mandates is free and never consumes the allowance; one unit is one +action *verified*. Paid plans start at $299/month for 10,000 verified actions. This +walkthrough consumes at most **two** of your free units. + +**Just exploring?** `POST /v1/demo/verify` takes raw claims, needs no key at all, and +is shown at the end. It does not replace the signed-envelope walkthrough, because it +does not check a signature. + +--- + +> **Installation notice.** The npm registry currently serves **0.1.0, which has a known +> first-use defect**: its input schema accepted any object for `mandate`, so a call built +> from the keyless demo's shape returned `HTTP 400 invalid_request`. Use the tested +> GitHub Release below until `0.1.1` is on npm. This notice is removed once the registry +> artifact is validated. + +## Run the walkthrough + +```bash +git clone https://github.com/API-Disk-Integrations/agent-mandate-mcp.git +cd agent-mandate-mcp +git checkout v0.1.1 +npm ci +AGENT_MANDATE_API_KEY=your_key node examples/verify-mandate.mjs +``` + +The example runner comes from this repository; the **MCP server it starts is the +released 0.1.1 artifact**, downloaded from the release below, not your working tree. +Omit `AGENT_MANDATE_API_KEY` and it prompts without echoing. + +### What it prints + +``` +=== 1. Create a mandate === +POST /v1/mandates -> 200 +envelope keys: mandate, signature, requestId +signature: v1:b63eacaa7… (67 chars) + +=== 3. Verify the action against the signed envelope === +action: payments.transfer 30000 USD, approval required above 25000 +decision: requires_approval + violation: Actions above 25000 minor units need a human approval token. + digest: c90fd0bc104dce63… + +=== 4. Tamper with one signed field, keep the signature === +raising approvalRequiredAboveMinor 25000 -> 999999, which would turn this into an allow +rejected: Agent Mandate error 400/invalid_request. +The tampered mandate did not buy an allow. The signature is doing its job. +``` + +That is a real run against production, not illustrative output. The grant allows up to +100,000 minor units but requires approval above 25,000, and the action asks for 30,000, +so `requires_approval` is the **correct** answer. A `deny` or `requires_approval` is a +correct result, not a failure. + +**Verification reports a result; your application remains responsible for enforcing it.** -## Install and run +## Install the server -After the exact package version is publicly published and independently read -back, run it with the version pinned: +Until `0.1.1` is on npm, install from the release tarball: ```bash -AGENT_MANDATE_API_KEY="$AGENT_MANDATE_API_KEY" \ - npx --yes @api-disk-integrations/agent-mandate-mcp@0.1.1 +curl -fsSLO https://github.com/API-Disk-Integrations/agent-mandate-mcp/releases/download/v0.1.1/api-disk-integrations-agent-mandate-mcp-0.1.1.tgz +shasum -a 256 api-disk-integrations-agent-mandate-mcp-0.1.1.tgz +npm install -g ./api-disk-integrations-agent-mandate-mcp-0.1.1.tgz ``` -Do not commit a literal credential. A generic stdio client configuration is: +Compare the checksum against the one published on the release page before installing. + +A generic stdio client configuration: ```json { "mcpServers": { "agent-mandate": { - "command": "npx", - "args": ["--yes", "@api-disk-integrations/agent-mandate-mcp@0.1.1"], + "command": "agent-mandate-mcp", "env": { "AGENT_MANDATE_API_KEY": "${AGENT_MANDATE_API_KEY}" } @@ -42,18 +96,29 @@ Do not commit a literal credential. A generic stdio client configuration is: } ``` -`${AGENT_MANDATE_API_KEY}` denotes the host's secret/environment reference. -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. +`${AGENT_MANDATE_API_KEY}` denotes the host's secret reference; use your client's +documented secret facility if its syntax differs. The package uses stdio and reads +exactly that environment variable. It has no remote `/mcp` endpoint. + +Once `0.1.1` is published, the command becomes +`npx --yes @api-disk-integrations/agent-mandate-mcp@0.1.1`. + +## The two shapes, which is the thing that trips people up + +| Endpoint | Key | Takes | +| --- | --- | --- | +| `POST /v1/demo/verify` | none | `{mandate: {…claims…}, action: {…}}` — **raw claims** | +| `POST /v1/verify` | yes | `{mandate: {mandate: {…claims…}, signature: "…"}, action: {…}}` — the **envelope** | -## Check whether an agent action exceeds its signed mandate +The envelope is the entire response body of `POST /v1/mandates`: +`{mandate, signature, requestId}`. Pass it through unchanged. -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. +`"mandate.mandate" must be the claims object` means bare claims were passed where the +envelope belongs. Run `POST /v1/mandates` first and pass its whole response. + +## The two calls by hand -**Step 1 — create a mandate.** This returns the *signed envelope*. The tool needs -this envelope, not the bare claims you send here. +**Step 1 — create a mandate.** Issuing is free and does not consume your allowance. ```sh curl -X POST https://agentmandate-api.com/v1/mandates \ @@ -74,8 +139,8 @@ curl -X POST https://agentmandate-api.com/v1/mandates \ }' ``` -Answers `201` with `{"mandate": {…}, "signature": "…", "requestId": "…"}`. -**That whole response body is the envelope.** Pass it through unchanged. +Answers `200` with `{"mandate": {…}, "signature": "…", "requestId": "…"}`. +**That whole body is the envelope.** **Step 2 — verify an action against it.** Call `verify_action` with the envelope as `mandate`: @@ -93,20 +158,7 @@ Answers `201` with `{"mandate": {…}, "signature": "…", "requestId": "…"}`. } ``` -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 +### With no key at all ```sh curl -X POST https://agentmandate-api.com/v1/demo/verify \ @@ -114,7 +166,17 @@ curl -X POST https://agentmandate-api.com/v1/demo/verify \ -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. +This route takes **bare claims**, not the envelope, and does not check a signature. + +## What this server does not do + +It does not issue or revoke mandates, execute an action, change an account, or call +billing. It makes at most one API request per tool call and never retries +automatically. + +- npm: `@api-disk-integrations/agent-mandate-mcp` +- Official MCP Registry: `io.github.API-Disk-Integrations/agent-mandate` +- source: `API-Disk-Integrations/agent-mandate-mcp` ## Tool contract diff --git a/dist/src/server.js b/dist/src/server.js index b385404..96c0adc 100644 --- a/dist/src/server.js +++ b/dist/src/server.js @@ -1,8 +1,15 @@ import { McpServer } from '@modelcontextprotocol/server'; import { verifyActionInput, verifyActionOutput } from './schema.js'; import { SafeApiError, verifyAction } from './client.js'; +/** + * Kept in step with package.json by a test, because a server that misreports its + * own version is the kind of small dishonesty that makes a developer stop + * trusting the rest of the output. 0.1.0 shipped claiming 0.1.0 while the + * package said 0.1.1. + */ +export const SERVER_VERSION = '0.1.1'; export function createServer(options = {}) { - const server = new McpServer({ name: 'agent-mandate-verifier', version: '0.1.0' }, { + const server = new McpServer({ name: 'agent-mandate-verifier', version: SERVER_VERSION }, { supportedProtocolVersions: [ '2026-07-28', '2025-11-25', diff --git a/dist/test/server-version.test.js b/dist/test/server-version.test.js new file mode 100644 index 0000000..b75c85f --- /dev/null +++ b/dist/test/server-version.test.js @@ -0,0 +1,10 @@ +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { SERVER_VERSION } from '../src/server.js'; +test('the version the server reports is the version the package ships', () => { + const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + assert.equal(SERVER_VERSION, pkg.version, 'createServer advertises its version in the MCP initialize handshake. If it ' + + 'disagrees with package.json, a developer who checks finds the artifact ' + + 'misreporting itself, which is exactly the trust this product sells.'); +}); diff --git a/examples/verify-mandate.mjs b/examples/verify-mandate.mjs new file mode 100755 index 0000000..840ea22 --- /dev/null +++ b/examples/verify-mandate.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/** + * The signed-envelope walkthrough. + * + * node examples/verify-mandate.mjs + * + * Creates a mandate, verifies an action against it through the MCP tool, then + * tampers with one signed field and verifies again. It prints the real decision + * for each and exits non-zero unless BOTH the expected accept and the expected + * rejection occur. + * + * WHY THE SECOND CALL MATTERS + * A walkthrough that only shows the happy path cannot tell you whether the + * signature is checked at all. Raising `approvalRequiredAboveMinor` from 25,000 + * to 999,999 while keeping the original signature is exactly what an attacker + * would try: it turns a `requires_approval` into an `allow`. If that second call + * succeeds, the guarantee is worthless and this script fails loudly. + * + * The key is read from the environment or prompted for without echo. It is never + * printed, never written to disk, and never passed on a command line. The + * signature is printed only as a short prefix. + */ +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const API = process.env.AGENT_MANDATE_API_URL ?? 'https://agentmandate-api.com'; + +/** + * Defaults to the GitHub Release tarball because npm currently serves 0.1.0, + * which has the first-use defect this version fixes. Override to test another + * artifact. Once 0.1.1 is on npm this becomes + * '@api-disk-integrations/agent-mandate-mcp@0.1.1'. + */ +const SPEC = process.env.AGENT_MANDATE_MCP_SPEC ?? + 'https://github.com/API-Disk-Integrations/agent-mandate-mcp/releases/download/v0.1.1/api-disk-integrations-agent-mandate-mcp-0.1.1.tgz'; + +/** The grant allows up to 100,000 minor units but requires approval above 25,000. */ +const CLAIMS = { + 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, + }], +}; + +/** 30,000 is inside the grant's ceiling but above the approval threshold. */ +const ACTION = { + agent: 'agent_procurement_v3', + action: 'payments.transfer', + resource: 'vendor.acme', + amountMinor: 30000, + currency: 'USD', +}; + +async function readKey() { + if (process.env.AGENT_MANDATE_API_KEY) return process.env.AGENT_MANDATE_API_KEY; + if (!process.stdin.isTTY) { + console.error('Set AGENT_MANDATE_API_KEY, or run this in a terminal to be prompted.'); + process.exit(2); + } + const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const hide = (s) => { rl.output.write(s); rl.input.on('data', () => {}); }; + return new Promise((resolve) => { + rl.question('Agent Mandate API key (not echoed): ', (a) => { rl.close(); process.stdout.write('\n'); resolve(a.trim()); }); + rl._writeToOutput = function (s) { if (s.includes('key')) rl.output.write(s); }; + void hide; + }); +} + +async function createMandate(key) { + const res = await fetch(`${API}/v1/mandates`, { + method: 'POST', + headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, + body: JSON.stringify(CLAIMS), + }); + if (!res.ok) throw new Error(`POST /v1/mandates returned ${res.status}. Check the key has mandate-creation access.`); + const body = await res.json(); + if (!body?.mandate || !body?.signature) throw new Error('Response was not an envelope: expected {mandate, signature}.'); + return body; +} + +/** A minimal stdio MCP client. Keeps the walkthrough dependency-free. */ +function startServer(key, cache) { + const child = spawn('npx', ['--yes', SPEC], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, AGENT_MANDATE_API_KEY: key, npm_config_cache: cache, CI: '1' }, + }); + const pending = new Map(); + let id = 0, buf = '', stderr = ''; + child.stderr.on('data', (d) => { stderr += d.toString(); }); + child.stdout.on('data', (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); + if (!line) continue; + let msg; try { msg = JSON.parse(line); } catch { continue; } + const p = pending.get(msg.id); + if (p) { pending.delete(msg.id); msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result); } + } + }); + const call = (method, params) => new Promise((resolve, reject) => { + const n = ++id; + pending.set(n, { resolve, reject }); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: n, method, params }) + '\n'); + setTimeout(() => { if (pending.has(n)) { pending.delete(n); reject(new Error(`${method} timed out. stderr: ${stderr.slice(0, 200)}`)); } }, 60000); + }); + const notify = (method) => child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method }) + '\n'); + return { child, call, notify, stderr: () => stderr }; +} + +function describe(result) { + if (result?.isError === true) { + return { ok: false, text: (result.content ?? []).map((c) => c.text).join(' ').trim() }; + } + const receipt = result?.structuredContent?.receipts?.[0]; + if (!receipt) return { ok: false, text: 'no receipts in structuredContent' }; + return { ok: true, decision: receipt.decision, violations: receipt.violations ?? [], receipt }; +} + +const cache = mkdtempSync(join(tmpdir(), 'agent-mandate-walkthrough-')); +const cleanup = () => { try { rmSync(cache, { recursive: true, force: true }); } catch {} }; + +let server; +try { + const key = await readKey(); + if (!key) { console.error('No key supplied.'); process.exit(2); } + + console.log('\n=== 1. Create a mandate ==='); + const envelope = await createMandate(key); + console.log(`POST /v1/mandates -> 200`); + console.log(`envelope keys: ${Object.keys(envelope).join(', ')}`); + console.log(`signature: ${String(envelope.signature).slice(0, 12)}… (${String(envelope.signature).length} chars)`); + console.log('That whole body is the envelope. Pass it through unchanged.'); + + console.log(`\n=== 2. Start the MCP server ===`); + console.log(`spec: ${SPEC}`); + server = startServer(key, cache); + const info = await server.call('initialize', { + protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'verify-mandate-walkthrough', version: '1.0.0' }, + }); + server.notify('notifications/initialized'); + console.log(`server: ${info?.serverInfo?.name ?? 'unnamed'} ${info?.serverInfo?.version ?? ''}`); + const { tools } = await server.call('tools/list', {}); + console.log(`tools: ${tools.map((t) => t.name).join(', ')}`); + const tool = tools.find((t) => /verify/i.test(t.name))?.name ?? tools[0].name; + + console.log(`\n=== 3. Verify the action against the signed envelope ===`); + console.log(`action: ${ACTION.action} ${ACTION.amountMinor} ${ACTION.currency}, approval required above 25000`); + const good = describe(await server.call('tools/call', { + name: tool, arguments: { mandate: { mandate: envelope.mandate, signature: envelope.signature }, action: ACTION }, + })); + if (!good.ok) { console.log(`REJECTED: ${good.text}`); } + else { + console.log(`decision: ${good.decision}`); + for (const v of good.violations) console.log(` violation: ${v.detail ?? JSON.stringify(v)}`); + if (good.receipt.digest) console.log(` digest: ${String(good.receipt.digest).slice(0, 16)}…`); + } + const positiveOk = good.ok && good.decision === 'requires_approval'; + console.log(positiveOk + ? 'As expected. A requires_approval is a correct answer, not a failure.' + : `UNEXPECTED: wanted decision "requires_approval".`); + + console.log(`\n=== 4. Tamper with one signed field, keep the signature ===`); + console.log('raising approvalRequiredAboveMinor 25000 -> 999999, which would turn this into an allow'); + const tampered = structuredClone(envelope.mandate); + const grant = (tampered.grants ?? tampered.claims?.grants)?.[0]; + if (grant) grant.approvalRequiredAboveMinor = 999999; + const bad = describe(await server.call('tools/call', { + name: tool, arguments: { mandate: { mandate: tampered, signature: envelope.signature }, action: ACTION }, + })); + let negativeOk; + if (!bad.ok) { console.log(`rejected: ${bad.text.slice(0, 300)}`); negativeOk = true; } + else { + console.log(`decision: ${bad.decision}`); + for (const v of bad.violations) console.log(` violation: ${v.detail ?? JSON.stringify(v)}`); + negativeOk = bad.decision !== 'allow'; + } + console.log(negativeOk + ? 'The tampered mandate did not buy an allow. The signature is doing its job.' + : 'FAILURE: a tampered mandate was accepted. Do not rely on this verification.'); + + console.log(`\n=== Result ===`); + console.log(`untampered -> requires_approval : ${positiveOk ? 'PASS' : 'FAIL'}`); + console.log(`tampered -> not allowed : ${negativeOk ? 'PASS' : 'FAIL'}`); + const pass = positiveOk && negativeOk; + console.log(pass ? '\nWalkthrough PASSED.' : '\nWalkthrough FAILED.'); + server.child.kill(); cleanup(); + process.exit(pass ? 0 : 1); +} catch (err) { + console.error(`\nWalkthrough error: ${err.message}`); + try { server?.child.kill(); } catch {} + cleanup(); + process.exit(1); +} diff --git a/src/server.ts b/src/server.ts index 924d336..1f08a07 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,14 @@ import { McpServer } from '@modelcontextprotocol/server' import { verifyActionInput, verifyActionOutput } from './schema.js' import { SafeApiError, verifyAction } from './client.js' +/** + * Kept in step with package.json by a test, because a server that misreports its + * own version is the kind of small dishonesty that makes a developer stop + * trusting the rest of the output. 0.1.0 shipped claiming 0.1.0 while the + * package said 0.1.1. + */ +export const SERVER_VERSION = '0.1.1' + export interface ServerOptions { apiKey?: string timeoutMs?: number @@ -10,7 +18,7 @@ export interface ServerOptions { export function createServer(options: ServerOptions = {}): McpServer { const server = new McpServer( - { name: 'agent-mandate-verifier', version: '0.1.0' }, + { name: 'agent-mandate-verifier', version: SERVER_VERSION }, { supportedProtocolVersions: [ '2026-07-28', diff --git a/test/server-version.test.ts b/test/server-version.test.ts new file mode 100644 index 0000000..81f5735 --- /dev/null +++ b/test/server-version.test.ts @@ -0,0 +1,15 @@ +import { strict as assert } from 'node:assert' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { SERVER_VERSION } from '../src/server.js' + +test('the version the server reports is the version the package ships', () => { + const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) + assert.equal( + SERVER_VERSION, + pkg.version, + 'createServer advertises its version in the MCP initialize handshake. If it ' + + 'disagrees with package.json, a developer who checks finds the artifact ' + + 'misreporting itself, which is exactly the trust this product sells.', + ) +})