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
77 changes: 73 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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}"
}
Expand All @@ -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
Expand Down
16 changes: 15 additions & 1 deletion dist/src/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
6 changes: 5 additions & 1 deletion dist/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 3 additions & 3 deletions dist/test/discovery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand Down Expand Up @@ -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' },
},
});
Expand Down
57 changes: 57 additions & 0 deletions dist/test/signed-envelope.test.js
Original file line number Diff line number Diff line change
@@ -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');
}
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 3 additions & 3 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -30,4 +30,4 @@
]
}
]
}
}
16 changes: 15 additions & 1 deletion src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
6 changes: 5 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 3 additions & 3 deletions test/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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')
Expand Down Expand Up @@ -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' },
},
})
Expand Down
65 changes: 65 additions & 0 deletions test/signed-envelope.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)['requestId'], 'req_1',
'passthrough must preserve fields the API may rely on')
}
})