@orangecheck/agent-mcp 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,7 +71,7 @@ The stamp's `content.hash` is a SHA-256 of the RFC-8785-canonicalized JSON, foll
71
71
 
72
72
  This shape is minimal by design — a verifier reconstructs it from the three fields without knowing anything MCP-server-specific.
73
73
 
74
- The action's `scope_exercised` defaults to `mcp:invoke(server=<server>,tool=<tool>)`, which is the tightest scope that exactly identifies the call. Callers who want a narrower or different scope can pass `scopeExercised` explicitly; `stampInvocation` pre-flights sub-scope containment against the delegation.
74
+ The action's `scope_exercised` defaults to `mcp:invoke(server="<server>",tool="<tool>")`, with both values quoted so they are read as written (a server URL with a query string stays whole, and the delegation's grant must name that same URL), which is the tightest scope that exactly identifies the call. Callers who want a narrower or different scope can pass `scopeExercised` explicitly; `stampInvocation` pre-flights sub-scope containment against the delegation.
75
75
 
76
76
  ## API
77
77
 
@@ -96,7 +96,7 @@ Stamps the invocation first, then calls `call(invocation, action)`, so the reque
96
96
  A server checks the action before it runs the tool, with `@orangecheck/agent-core` 2.7 or later:
97
97
 
98
98
  ```ts
99
- import { createReplayGuard, isSubScope, parseScope, verifyAction } from '@orangecheck/agent-core';
99
+ import { createReplayGuard, isSubScope, parseScope, verifyAction, type Scope } from '@orangecheck/agent-core';
100
100
  import { ACTION_META_KEY, canonicalizeInvocation } from '@orangecheck/agent-mcp';
101
101
  import { verifySignature } from '@orangecheck/bip322';
102
102
 
@@ -116,7 +116,15 @@ const r = await verifyAction({
116
116
  if (!r.ok) throw new Error(`refused: ${r.code}`);
117
117
 
118
118
  // The exercised scope must name this server and this tool, not just some grant.
119
- const here = parseScope(`mcp:invoke(server=${MY_ENDPOINT},tool=${params.name})`);
119
+ // The tool name is the caller's text: pin it as a value, never as scope syntax.
120
+ const here: Scope = {
121
+ product: 'mcp',
122
+ verb: 'invoke',
123
+ constraints: [
124
+ { key: 'server', op: '=', value: MY_ENDPOINT, quoted: true },
125
+ { key: 'tool', op: '=', value: params.name, quoted: true },
126
+ ],
127
+ };
120
128
  if (!isSubScope(parseScope(action.scope_exercised), here)) throw new Error('refused: E_SCOPE_DENIED');
121
129
 
122
130
  // One signed action runs once.
package/dist/index.js CHANGED
@@ -30,7 +30,14 @@ function invocationId(agentAddress, signedAt, delegationId, scopeExercised, inv)
30
30
  });
31
31
  }
32
32
  async function stampInvocation(input) {
33
- const scopeExercised = input.scopeExercised ?? `mcp:invoke(server=${input.invocation.server.trim()},tool=${input.invocation.tool})`;
33
+ const scopeExercised = input.scopeExercised ?? agentCore.canonicalizeScope({
34
+ product: "mcp",
35
+ verb: "invoke",
36
+ constraints: [
37
+ { key: "server", op: "=", value: input.invocation.server.trim(), quoted: true },
38
+ { key: "tool", op: "=", value: input.invocation.tool, quoted: true }
39
+ ]
40
+ });
34
41
  agentCore.assertScopeGranted(input.delegation, scopeExercised, "stampInvocation");
35
42
  const bytes = canonicalizeInvocation(input.invocation);
36
43
  return agentSigner.signAsAgent({
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["canonicalize","hexEncode","sha256","computeActionId","assertScopeGranted","signAsAgent"],"mappings":";;;;;;;AA+BO,IAAM,eAAA,GAAkB;AAkCxB,SAAS,uBAAuB,GAAA,EAAgC;AACnE,EAAA,MAAM,KAAA,GAAQ;AAAA,IACV,WAAW,GAAA,CAAI,SAAA;AAAA,IACf,MAAA,EAAQ,GAAA,CAAI,MAAA,CAAO,IAAA,EAAK;AAAA,IACxB,MAAM,GAAA,CAAI;AAAA,GACd;AACA,EAAA,MAAM,GAAA,GAAMA,uBAAa,KAAsD,CAAA;AAC/E,EAAA,OAAO,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,MAAM,IAAI,CAAA;AAC9C;AAEO,SAAS,eAAe,GAAA,EAA4B;AACvD,EAAA,OAAO,YAAYC,mBAAA,CAAUC,aAAA,CAAO,sBAAA,CAAuB,GAAG,CAAC,CAAC,CAAA;AACpE;AAEO,SAAS,YAAA,CACZ,YAAA,EACA,QAAA,EACA,YAAA,EACA,gBACA,GAAA,EACM;AACN,EAAA,OAAOC,yBAAA,CAAgB;AAAA,IACnB,OAAA,EAAS,YAAA;AAAA,IACT,YAAA,EAAc,eAAe,GAAG,CAAA;AAAA,IAChC,cAAA,EAAgB,sBAAA,CAAuB,GAAG,CAAA,CAAE,UAAA;AAAA,IAC5C,YAAA,EAAc,8CAAA;AAAA,IACd,SAAA,EAAW,QAAA;AAAA,IACX,aAAA,EAAe,YAAA;AAAA,IACf,eAAA,EAAiB;AAAA,GACpB,CAAA;AACL;AAoBA,eAAsB,gBAAgB,KAAA,EAAsD;AACxF,EAAA,MAAM,cAAA,GACF,KAAA,CAAM,cAAA,IACN,CAAA,kBAAA,EAAqB,KAAA,CAAM,UAAA,CAAW,MAAA,CAAO,IAAA,EAAM,CAAA,MAAA,EAAS,KAAA,CAAM,UAAA,CAAW,IAAI,CAAA,CAAA,CAAA;AAErF,EAAAC,4BAAA,CAAmB,KAAA,CAAM,UAAA,EAAY,cAAA,EAAgB,iBAAiB,CAAA;AAEtE,EAAA,MAAM,KAAA,GAAQ,sBAAA,CAAuB,KAAA,CAAM,UAAU,CAAA;AACrD,EAAA,OAAOC,uBAAA,CAAY;AAAA,IACf,OAAO,KAAA,CAAM,KAAA;AAAA,IACb,YAAY,KAAA,CAAM,UAAA;AAAA,IAClB,OAAA,EAAS,EAAE,IAAA,EAAM,cAAA,CAAe,MAAM,UAAU,CAAA,EAAG,MAAA,EAAQ,KAAA,CAAM,UAAA,EAAW;AAAA,IAC5E,IAAA,EAAM,8CAAA;AAAA,IACN,cAAA;AAAA,IACA,UAAU,KAAA,CAAM;AAAA,GACnB,CAAA;AACL;AA4BA,eAAsB,gBAAmB,KAAA,EAAmE;AACxG,EAAA,MAAM,MAAA,GAAS,MAAM,eAAA,CAAgB,KAAK,CAAA;AAC1C,EAAA,MAAM,SAAS,MAAM,KAAA,CAAM,IAAA,CAAK,KAAA,CAAM,YAAY,MAAM,CAAA;AACxD,EAAA,OAAO,EAAE,QAAQ,MAAA,EAAO;AAC5B","file":"index.js","sourcesContent":["// @orangecheck/agent-mcp — wrap MCP tool invocations in OC Agent envelopes.\n//\n// An MCP client calls tools on an MCP server: { server, name, arguments }.\n// This module stamps each invocation as an `agent-action` whose `content.hash`\n// is a SHA-256 of the canonicalized (server, name, arguments) tuple, scoped\n// as `mcp:invoke(server=<url>,tool=<name>)`. The server — or any counterparty\n// — verifies the action before trusting the call.\n//\n// The wrapper is transport-agnostic. Wire it into whatever MCP client you use\n// by calling `invokeWithStamp()` around the raw call, and send the action with\n// the request under `params._meta[ACTION_META_KEY]`.\n\nimport { sha256 } from '@noble/hashes/sha256';\nimport {\n assertScopeGranted,\n canonicalize,\n computeActionId,\n hexEncode,\n type DelegationEnvelope,\n} from '@orangecheck/agent-core';\nimport {\n signAsAgent,\n type ActionEnvelope,\n type SignerRef,\n} from '@orangecheck/agent-signer';\n\n/**\n * Where an MCP request carries its action: `params._meta[ACTION_META_KEY]` on\n * the `tools/call` request. MCP reserves `_meta` for metadata and allows\n * prefixed keys there, so a server that does not check actions ignores it.\n */\nexport const ACTION_META_KEY = 'io.ochk/agent-action';\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Canonicalization of an MCP invocation\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface McpInvocation {\n /**\n * The MCP server's endpoint URL, exactly as the delegation's\n * `mcp:invoke(server=…)` scope names it. Only surrounding whitespace is\n * trimmed: case, path and a trailing slash all count.\n */\n server: string;\n /** Tool name (method) being invoked. */\n tool: string;\n /** JSON-serializable arguments. */\n arguments: Record<string, unknown>;\n}\n\n/**\n * Produce the canonical bytes for an MCP invocation. The bytes are what the\n * action envelope's `content.hash` is a SHA-256 of: RFC 8785 JSON of the\n * object below, followed by one LF (0x0A).\n *\n * Shape:\n * {\n * \"arguments\": <canonical JSON of args>,\n * \"server\": <server url>,\n * \"tool\": <tool name>\n * }\n *\n * The shape is intentionally minimal so verifiers can reconstruct it without\n * knowing anything server- or tool-specific.\n */\nexport function canonicalizeInvocation(inv: McpInvocation): Uint8Array {\n const canon = {\n arguments: inv.arguments,\n server: inv.server.trim(),\n tool: inv.tool,\n };\n const str = canonicalize(canon as unknown as Parameters<typeof canonicalize>[0]);\n return new TextEncoder().encode(str + '\\n');\n}\n\nexport function invocationHash(inv: McpInvocation): string {\n return 'sha256:' + hexEncode(sha256(canonicalizeInvocation(inv)));\n}\n\nexport function invocationId(\n agentAddress: string,\n signedAt: string,\n delegationId: string,\n scopeExercised: string,\n inv: McpInvocation\n): string {\n return computeActionId({\n address: agentAddress,\n content_hash: invocationHash(inv),\n content_length: canonicalizeInvocation(inv).byteLength,\n content_mime: 'application/vnd.oc-agent.mcp-invocation+json',\n signed_at: signedAt,\n delegation_id: delegationId,\n scope_exercised: scopeExercised,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Stamped invocation\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface StampInvocationInput {\n agent: SignerRef;\n delegation: DelegationEnvelope;\n invocation: McpInvocation;\n /** Defaults to `mcp:invoke(server=<server>,tool=<tool>)` — the tightest admissible sub-scope. */\n scopeExercised?: string;\n signedAt?: Date;\n}\n\n/**\n * Stamp an MCP invocation without actually calling it. Useful when the caller\n * wants the signed envelope for out-of-band auditing before (or independent of)\n * running the tool.\n */\nexport async function stampInvocation(input: StampInvocationInput): Promise<ActionEnvelope> {\n const scopeExercised =\n input.scopeExercised ??\n `mcp:invoke(server=${input.invocation.server.trim()},tool=${input.invocation.tool})`;\n\n assertScopeGranted(input.delegation, scopeExercised, 'stampInvocation');\n\n const bytes = canonicalizeInvocation(input.invocation);\n return signAsAgent({\n agent: input.agent,\n delegation: input.delegation,\n content: { hash: invocationHash(input.invocation), length: bytes.byteLength },\n mime: 'application/vnd.oc-agent.mcp-invocation+json',\n scopeExercised,\n signedAt: input.signedAt,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// invokeWithStamp — call the tool AND produce the stamp around it\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface InvokeWithStampInput<T> extends StampInvocationInput {\n /**\n * The underlying transport call. It gets the signed action: send it with\n * the request, under `params._meta[ACTION_META_KEY]`, so the server can\n * verify it before it runs the tool.\n */\n call: (inv: McpInvocation, action: ActionEnvelope) => Promise<T>;\n}\n\nexport interface InvokeWithStampResult<T> {\n result: T;\n action: ActionEnvelope;\n}\n\n/**\n * Stamp the invocation *first*, then make the call with the action in hand,\n * so the request carries it and the server verifies before it acts. The\n * action is also returned with the result for the caller's own record.\n *\n * A server checks it live (agent-core `verifyAction`): the delegation in force\n * now, the action signed just now, and an id it has not seen before.\n */\nexport async function invokeWithStamp<T>(input: InvokeWithStampInput<T>): Promise<InvokeWithStampResult<T>> {\n const action = await stampInvocation(input);\n const result = await input.call(input.invocation, action);\n return { result, action };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["canonicalize","hexEncode","sha256","computeActionId","canonicalizeScope","assertScopeGranted","signAsAgent"],"mappings":";;;;;;;AAgCO,IAAM,eAAA,GAAkB;AAoCxB,SAAS,uBAAuB,GAAA,EAAgC;AACnE,EAAA,MAAM,KAAA,GAAQ;AAAA,IACV,WAAW,GAAA,CAAI,SAAA;AAAA,IACf,MAAA,EAAQ,GAAA,CAAI,MAAA,CAAO,IAAA,EAAK;AAAA,IACxB,MAAM,GAAA,CAAI;AAAA,GACd;AACA,EAAA,MAAM,GAAA,GAAMA,uBAAa,KAAsD,CAAA;AAC/E,EAAA,OAAO,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,MAAM,IAAI,CAAA;AAC9C;AAEO,SAAS,eAAe,GAAA,EAA4B;AACvD,EAAA,OAAO,YAAYC,mBAAA,CAAUC,aAAA,CAAO,sBAAA,CAAuB,GAAG,CAAC,CAAC,CAAA;AACpE;AAEO,SAAS,YAAA,CACZ,YAAA,EACA,QAAA,EACA,YAAA,EACA,gBACA,GAAA,EACM;AACN,EAAA,OAAOC,yBAAA,CAAgB;AAAA,IACnB,OAAA,EAAS,YAAA;AAAA,IACT,YAAA,EAAc,eAAe,GAAG,CAAA;AAAA,IAChC,cAAA,EAAgB,sBAAA,CAAuB,GAAG,CAAA,CAAE,UAAA;AAAA,IAC5C,YAAA,EAAc,8CAAA;AAAA,IACd,SAAA,EAAW,QAAA;AAAA,IACX,aAAA,EAAe,YAAA;AAAA,IACf,eAAA,EAAiB;AAAA,GACpB,CAAA;AACL;AAoBA,eAAsB,gBAAgB,KAAA,EAAsD;AAExF,EAAA,MAAM,cAAA,GACF,KAAA,CAAM,cAAA,IACNC,2BAAA,CAAkB;AAAA,IACd,OAAA,EAAS,KAAA;AAAA,IACT,IAAA,EAAM,QAAA;AAAA,IACN,WAAA,EAAa;AAAA,MACT,EAAE,GAAA,EAAK,QAAA,EAAU,EAAA,EAAI,GAAA,EAAK,KAAA,EAAO,KAAA,CAAM,UAAA,CAAW,MAAA,CAAO,IAAA,EAAK,EAAG,MAAA,EAAQ,IAAA,EAAK;AAAA,MAC9E,EAAE,GAAA,EAAK,MAAA,EAAQ,EAAA,EAAI,GAAA,EAAK,OAAO,KAAA,CAAM,UAAA,CAAW,IAAA,EAAM,MAAA,EAAQ,IAAA;AAAK;AACvE,GACH,CAAA;AAEL,EAAAC,4BAAA,CAAmB,KAAA,CAAM,UAAA,EAAY,cAAA,EAAgB,iBAAiB,CAAA;AAEtE,EAAA,MAAM,KAAA,GAAQ,sBAAA,CAAuB,KAAA,CAAM,UAAU,CAAA;AACrD,EAAA,OAAOC,uBAAA,CAAY;AAAA,IACf,OAAO,KAAA,CAAM,KAAA;AAAA,IACb,YAAY,KAAA,CAAM,UAAA;AAAA,IAClB,OAAA,EAAS,EAAE,IAAA,EAAM,cAAA,CAAe,MAAM,UAAU,CAAA,EAAG,MAAA,EAAQ,KAAA,CAAM,UAAA,EAAW;AAAA,IAC5E,IAAA,EAAM,8CAAA;AAAA,IACN,cAAA;AAAA,IACA,UAAU,KAAA,CAAM;AAAA,GACnB,CAAA;AACL;AA4BA,eAAsB,gBAAmB,KAAA,EAAmE;AACxG,EAAA,MAAM,MAAA,GAAS,MAAM,eAAA,CAAgB,KAAK,CAAA;AAC1C,EAAA,MAAM,SAAS,MAAM,KAAA,CAAM,IAAA,CAAK,KAAA,CAAM,YAAY,MAAM,CAAA;AACxD,EAAA,OAAO,EAAE,QAAQ,MAAA,EAAO;AAC5B","file":"index.js","sourcesContent":["// @orangecheck/agent-mcp — wrap MCP tool invocations in OC Agent envelopes.\n//\n// An MCP client calls tools on an MCP server: { server, name, arguments }.\n// This module stamps each invocation as an `agent-action` whose `content.hash`\n// is a SHA-256 of the canonicalized (server, name, arguments) tuple, scoped\n// as `mcp:invoke(server=\"<url>\",tool=\"<name>\")`. The server — or any counterparty\n// — verifies the action before trusting the call.\n//\n// The wrapper is transport-agnostic. Wire it into whatever MCP client you use\n// by calling `invokeWithStamp()` around the raw call, and send the action with\n// the request under `params._meta[ACTION_META_KEY]`.\n\nimport { sha256 } from '@noble/hashes/sha256';\nimport {\n assertScopeGranted,\n canonicalize,\n canonicalizeScope,\n computeActionId,\n hexEncode,\n type DelegationEnvelope,\n} from '@orangecheck/agent-core';\nimport {\n signAsAgent,\n type ActionEnvelope,\n type SignerRef,\n} from '@orangecheck/agent-signer';\n\n/**\n * Where an MCP request carries its action: `params._meta[ACTION_META_KEY]` on\n * the `tools/call` request. MCP reserves `_meta` for metadata and allows\n * prefixed keys there, so a server that does not check actions ignores it.\n */\nexport const ACTION_META_KEY = 'io.ochk/agent-action';\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Canonicalization of an MCP invocation\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface McpInvocation {\n /**\n * The MCP server's endpoint URL, exactly as the delegation's\n * `mcp:invoke(server=…)` scope names it. Only surrounding whitespace is\n * trimmed. A scope holds the scheme and authority in lower case (OC Agent\n * §7.2 rule 4); the path, a query and a trailing slash count as written. The\n * default exercised scope quotes it, so a `?` or `=` stays part of it.\n */\n server: string;\n /** Tool name (method) being invoked. */\n tool: string;\n /** JSON-serializable arguments. */\n arguments: Record<string, unknown>;\n}\n\n/**\n * Produce the canonical bytes for an MCP invocation. The bytes are what the\n * action envelope's `content.hash` is a SHA-256 of: RFC 8785 JSON of the\n * object below, followed by one LF (0x0A).\n *\n * Shape:\n * {\n * \"arguments\": <canonical JSON of args>,\n * \"server\": <server url>,\n * \"tool\": <tool name>\n * }\n *\n * The shape is intentionally minimal so verifiers can reconstruct it without\n * knowing anything server- or tool-specific.\n */\nexport function canonicalizeInvocation(inv: McpInvocation): Uint8Array {\n const canon = {\n arguments: inv.arguments,\n server: inv.server.trim(),\n tool: inv.tool,\n };\n const str = canonicalize(canon as unknown as Parameters<typeof canonicalize>[0]);\n return new TextEncoder().encode(str + '\\n');\n}\n\nexport function invocationHash(inv: McpInvocation): string {\n return 'sha256:' + hexEncode(sha256(canonicalizeInvocation(inv)));\n}\n\nexport function invocationId(\n agentAddress: string,\n signedAt: string,\n delegationId: string,\n scopeExercised: string,\n inv: McpInvocation\n): string {\n return computeActionId({\n address: agentAddress,\n content_hash: invocationHash(inv),\n content_length: canonicalizeInvocation(inv).byteLength,\n content_mime: 'application/vnd.oc-agent.mcp-invocation+json',\n signed_at: signedAt,\n delegation_id: delegationId,\n scope_exercised: scopeExercised,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Stamped invocation\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface StampInvocationInput {\n agent: SignerRef;\n delegation: DelegationEnvelope;\n invocation: McpInvocation;\n /** Defaults to `mcp:invoke(server=\"<server>\",tool=\"<tool>\")` — the tightest admissible sub-scope. */\n scopeExercised?: string;\n signedAt?: Date;\n}\n\n/**\n * Stamp an MCP invocation without actually calling it. Useful when the caller\n * wants the signed envelope for out-of-band auditing before (or independent of)\n * running the tool.\n */\nexport async function stampInvocation(input: StampInvocationInput): Promise<ActionEnvelope> {\n // Both are quoted values, read as written: a tool called \"*\" is that tool, and a server URL's ? or = stays in it.\n const scopeExercised =\n input.scopeExercised ??\n canonicalizeScope({\n product: 'mcp',\n verb: 'invoke',\n constraints: [\n { key: 'server', op: '=', value: input.invocation.server.trim(), quoted: true },\n { key: 'tool', op: '=', value: input.invocation.tool, quoted: true },\n ],\n });\n\n assertScopeGranted(input.delegation, scopeExercised, 'stampInvocation');\n\n const bytes = canonicalizeInvocation(input.invocation);\n return signAsAgent({\n agent: input.agent,\n delegation: input.delegation,\n content: { hash: invocationHash(input.invocation), length: bytes.byteLength },\n mime: 'application/vnd.oc-agent.mcp-invocation+json',\n scopeExercised,\n signedAt: input.signedAt,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// invokeWithStamp — call the tool AND produce the stamp around it\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface InvokeWithStampInput<T> extends StampInvocationInput {\n /**\n * The underlying transport call. It gets the signed action: send it with\n * the request, under `params._meta[ACTION_META_KEY]`, so the server can\n * verify it before it runs the tool.\n */\n call: (inv: McpInvocation, action: ActionEnvelope) => Promise<T>;\n}\n\nexport interface InvokeWithStampResult<T> {\n result: T;\n action: ActionEnvelope;\n}\n\n/**\n * Stamp the invocation *first*, then make the call with the action in hand,\n * so the request carries it and the server verifies before it acts. The\n * action is also returned with the result for the caller's own record.\n *\n * A server checks it live (agent-core `verifyAction`): the delegation in force\n * now, the action signed just now, and an id it has not seen before.\n */\nexport async function invokeWithStamp<T>(input: InvokeWithStampInput<T>): Promise<InvokeWithStampResult<T>> {\n const action = await stampInvocation(input);\n const result = await input.call(input.invocation, action);\n return { result, action };\n}\n"]}
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { sha256 } from '@noble/hashes/sha256';
2
- import { canonicalize, hexEncode, computeActionId, assertScopeGranted } from '@orangecheck/agent-core';
2
+ import { canonicalize, hexEncode, computeActionId, canonicalizeScope, assertScopeGranted } from '@orangecheck/agent-core';
3
3
  import { signAsAgent } from '@orangecheck/agent-signer';
4
4
 
5
5
  // src/index.ts
@@ -28,7 +28,14 @@ function invocationId(agentAddress, signedAt, delegationId, scopeExercised, inv)
28
28
  });
29
29
  }
30
30
  async function stampInvocation(input) {
31
- const scopeExercised = input.scopeExercised ?? `mcp:invoke(server=${input.invocation.server.trim()},tool=${input.invocation.tool})`;
31
+ const scopeExercised = input.scopeExercised ?? canonicalizeScope({
32
+ product: "mcp",
33
+ verb: "invoke",
34
+ constraints: [
35
+ { key: "server", op: "=", value: input.invocation.server.trim(), quoted: true },
36
+ { key: "tool", op: "=", value: input.invocation.tool, quoted: true }
37
+ ]
38
+ });
32
39
  assertScopeGranted(input.delegation, scopeExercised, "stampInvocation");
33
40
  const bytes = canonicalizeInvocation(input.invocation);
34
41
  return signAsAgent({
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;;;AA+BO,IAAM,eAAA,GAAkB;AAkCxB,SAAS,uBAAuB,GAAA,EAAgC;AACnE,EAAA,MAAM,KAAA,GAAQ;AAAA,IACV,WAAW,GAAA,CAAI,SAAA;AAAA,IACf,MAAA,EAAQ,GAAA,CAAI,MAAA,CAAO,IAAA,EAAK;AAAA,IACxB,MAAM,GAAA,CAAI;AAAA,GACd;AACA,EAAA,MAAM,GAAA,GAAM,aAAa,KAAsD,CAAA;AAC/E,EAAA,OAAO,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,MAAM,IAAI,CAAA;AAC9C;AAEO,SAAS,eAAe,GAAA,EAA4B;AACvD,EAAA,OAAO,YAAY,SAAA,CAAU,MAAA,CAAO,sBAAA,CAAuB,GAAG,CAAC,CAAC,CAAA;AACpE;AAEO,SAAS,YAAA,CACZ,YAAA,EACA,QAAA,EACA,YAAA,EACA,gBACA,GAAA,EACM;AACN,EAAA,OAAO,eAAA,CAAgB;AAAA,IACnB,OAAA,EAAS,YAAA;AAAA,IACT,YAAA,EAAc,eAAe,GAAG,CAAA;AAAA,IAChC,cAAA,EAAgB,sBAAA,CAAuB,GAAG,CAAA,CAAE,UAAA;AAAA,IAC5C,YAAA,EAAc,8CAAA;AAAA,IACd,SAAA,EAAW,QAAA;AAAA,IACX,aAAA,EAAe,YAAA;AAAA,IACf,eAAA,EAAiB;AAAA,GACpB,CAAA;AACL;AAoBA,eAAsB,gBAAgB,KAAA,EAAsD;AACxF,EAAA,MAAM,cAAA,GACF,KAAA,CAAM,cAAA,IACN,CAAA,kBAAA,EAAqB,KAAA,CAAM,UAAA,CAAW,MAAA,CAAO,IAAA,EAAM,CAAA,MAAA,EAAS,KAAA,CAAM,UAAA,CAAW,IAAI,CAAA,CAAA,CAAA;AAErF,EAAA,kBAAA,CAAmB,KAAA,CAAM,UAAA,EAAY,cAAA,EAAgB,iBAAiB,CAAA;AAEtE,EAAA,MAAM,KAAA,GAAQ,sBAAA,CAAuB,KAAA,CAAM,UAAU,CAAA;AACrD,EAAA,OAAO,WAAA,CAAY;AAAA,IACf,OAAO,KAAA,CAAM,KAAA;AAAA,IACb,YAAY,KAAA,CAAM,UAAA;AAAA,IAClB,OAAA,EAAS,EAAE,IAAA,EAAM,cAAA,CAAe,MAAM,UAAU,CAAA,EAAG,MAAA,EAAQ,KAAA,CAAM,UAAA,EAAW;AAAA,IAC5E,IAAA,EAAM,8CAAA;AAAA,IACN,cAAA;AAAA,IACA,UAAU,KAAA,CAAM;AAAA,GACnB,CAAA;AACL;AA4BA,eAAsB,gBAAmB,KAAA,EAAmE;AACxG,EAAA,MAAM,MAAA,GAAS,MAAM,eAAA,CAAgB,KAAK,CAAA;AAC1C,EAAA,MAAM,SAAS,MAAM,KAAA,CAAM,IAAA,CAAK,KAAA,CAAM,YAAY,MAAM,CAAA;AACxD,EAAA,OAAO,EAAE,QAAQ,MAAA,EAAO;AAC5B","file":"index.mjs","sourcesContent":["// @orangecheck/agent-mcp — wrap MCP tool invocations in OC Agent envelopes.\n//\n// An MCP client calls tools on an MCP server: { server, name, arguments }.\n// This module stamps each invocation as an `agent-action` whose `content.hash`\n// is a SHA-256 of the canonicalized (server, name, arguments) tuple, scoped\n// as `mcp:invoke(server=<url>,tool=<name>)`. The server — or any counterparty\n// — verifies the action before trusting the call.\n//\n// The wrapper is transport-agnostic. Wire it into whatever MCP client you use\n// by calling `invokeWithStamp()` around the raw call, and send the action with\n// the request under `params._meta[ACTION_META_KEY]`.\n\nimport { sha256 } from '@noble/hashes/sha256';\nimport {\n assertScopeGranted,\n canonicalize,\n computeActionId,\n hexEncode,\n type DelegationEnvelope,\n} from '@orangecheck/agent-core';\nimport {\n signAsAgent,\n type ActionEnvelope,\n type SignerRef,\n} from '@orangecheck/agent-signer';\n\n/**\n * Where an MCP request carries its action: `params._meta[ACTION_META_KEY]` on\n * the `tools/call` request. MCP reserves `_meta` for metadata and allows\n * prefixed keys there, so a server that does not check actions ignores it.\n */\nexport const ACTION_META_KEY = 'io.ochk/agent-action';\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Canonicalization of an MCP invocation\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface McpInvocation {\n /**\n * The MCP server's endpoint URL, exactly as the delegation's\n * `mcp:invoke(server=…)` scope names it. Only surrounding whitespace is\n * trimmed: case, path and a trailing slash all count.\n */\n server: string;\n /** Tool name (method) being invoked. */\n tool: string;\n /** JSON-serializable arguments. */\n arguments: Record<string, unknown>;\n}\n\n/**\n * Produce the canonical bytes for an MCP invocation. The bytes are what the\n * action envelope's `content.hash` is a SHA-256 of: RFC 8785 JSON of the\n * object below, followed by one LF (0x0A).\n *\n * Shape:\n * {\n * \"arguments\": <canonical JSON of args>,\n * \"server\": <server url>,\n * \"tool\": <tool name>\n * }\n *\n * The shape is intentionally minimal so verifiers can reconstruct it without\n * knowing anything server- or tool-specific.\n */\nexport function canonicalizeInvocation(inv: McpInvocation): Uint8Array {\n const canon = {\n arguments: inv.arguments,\n server: inv.server.trim(),\n tool: inv.tool,\n };\n const str = canonicalize(canon as unknown as Parameters<typeof canonicalize>[0]);\n return new TextEncoder().encode(str + '\\n');\n}\n\nexport function invocationHash(inv: McpInvocation): string {\n return 'sha256:' + hexEncode(sha256(canonicalizeInvocation(inv)));\n}\n\nexport function invocationId(\n agentAddress: string,\n signedAt: string,\n delegationId: string,\n scopeExercised: string,\n inv: McpInvocation\n): string {\n return computeActionId({\n address: agentAddress,\n content_hash: invocationHash(inv),\n content_length: canonicalizeInvocation(inv).byteLength,\n content_mime: 'application/vnd.oc-agent.mcp-invocation+json',\n signed_at: signedAt,\n delegation_id: delegationId,\n scope_exercised: scopeExercised,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Stamped invocation\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface StampInvocationInput {\n agent: SignerRef;\n delegation: DelegationEnvelope;\n invocation: McpInvocation;\n /** Defaults to `mcp:invoke(server=<server>,tool=<tool>)` — the tightest admissible sub-scope. */\n scopeExercised?: string;\n signedAt?: Date;\n}\n\n/**\n * Stamp an MCP invocation without actually calling it. Useful when the caller\n * wants the signed envelope for out-of-band auditing before (or independent of)\n * running the tool.\n */\nexport async function stampInvocation(input: StampInvocationInput): Promise<ActionEnvelope> {\n const scopeExercised =\n input.scopeExercised ??\n `mcp:invoke(server=${input.invocation.server.trim()},tool=${input.invocation.tool})`;\n\n assertScopeGranted(input.delegation, scopeExercised, 'stampInvocation');\n\n const bytes = canonicalizeInvocation(input.invocation);\n return signAsAgent({\n agent: input.agent,\n delegation: input.delegation,\n content: { hash: invocationHash(input.invocation), length: bytes.byteLength },\n mime: 'application/vnd.oc-agent.mcp-invocation+json',\n scopeExercised,\n signedAt: input.signedAt,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// invokeWithStamp — call the tool AND produce the stamp around it\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface InvokeWithStampInput<T> extends StampInvocationInput {\n /**\n * The underlying transport call. It gets the signed action: send it with\n * the request, under `params._meta[ACTION_META_KEY]`, so the server can\n * verify it before it runs the tool.\n */\n call: (inv: McpInvocation, action: ActionEnvelope) => Promise<T>;\n}\n\nexport interface InvokeWithStampResult<T> {\n result: T;\n action: ActionEnvelope;\n}\n\n/**\n * Stamp the invocation *first*, then make the call with the action in hand,\n * so the request carries it and the server verifies before it acts. The\n * action is also returned with the result for the caller's own record.\n *\n * A server checks it live (agent-core `verifyAction`): the delegation in force\n * now, the action signed just now, and an id it has not seen before.\n */\nexport async function invokeWithStamp<T>(input: InvokeWithStampInput<T>): Promise<InvokeWithStampResult<T>> {\n const action = await stampInvocation(input);\n const result = await input.call(input.invocation, action);\n return { result, action };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAgCO,IAAM,eAAA,GAAkB;AAoCxB,SAAS,uBAAuB,GAAA,EAAgC;AACnE,EAAA,MAAM,KAAA,GAAQ;AAAA,IACV,WAAW,GAAA,CAAI,SAAA;AAAA,IACf,MAAA,EAAQ,GAAA,CAAI,MAAA,CAAO,IAAA,EAAK;AAAA,IACxB,MAAM,GAAA,CAAI;AAAA,GACd;AACA,EAAA,MAAM,GAAA,GAAM,aAAa,KAAsD,CAAA;AAC/E,EAAA,OAAO,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,MAAM,IAAI,CAAA;AAC9C;AAEO,SAAS,eAAe,GAAA,EAA4B;AACvD,EAAA,OAAO,YAAY,SAAA,CAAU,MAAA,CAAO,sBAAA,CAAuB,GAAG,CAAC,CAAC,CAAA;AACpE;AAEO,SAAS,YAAA,CACZ,YAAA,EACA,QAAA,EACA,YAAA,EACA,gBACA,GAAA,EACM;AACN,EAAA,OAAO,eAAA,CAAgB;AAAA,IACnB,OAAA,EAAS,YAAA;AAAA,IACT,YAAA,EAAc,eAAe,GAAG,CAAA;AAAA,IAChC,cAAA,EAAgB,sBAAA,CAAuB,GAAG,CAAA,CAAE,UAAA;AAAA,IAC5C,YAAA,EAAc,8CAAA;AAAA,IACd,SAAA,EAAW,QAAA;AAAA,IACX,aAAA,EAAe,YAAA;AAAA,IACf,eAAA,EAAiB;AAAA,GACpB,CAAA;AACL;AAoBA,eAAsB,gBAAgB,KAAA,EAAsD;AAExF,EAAA,MAAM,cAAA,GACF,KAAA,CAAM,cAAA,IACN,iBAAA,CAAkB;AAAA,IACd,OAAA,EAAS,KAAA;AAAA,IACT,IAAA,EAAM,QAAA;AAAA,IACN,WAAA,EAAa;AAAA,MACT,EAAE,GAAA,EAAK,QAAA,EAAU,EAAA,EAAI,GAAA,EAAK,KAAA,EAAO,KAAA,CAAM,UAAA,CAAW,MAAA,CAAO,IAAA,EAAK,EAAG,MAAA,EAAQ,IAAA,EAAK;AAAA,MAC9E,EAAE,GAAA,EAAK,MAAA,EAAQ,EAAA,EAAI,GAAA,EAAK,OAAO,KAAA,CAAM,UAAA,CAAW,IAAA,EAAM,MAAA,EAAQ,IAAA;AAAK;AACvE,GACH,CAAA;AAEL,EAAA,kBAAA,CAAmB,KAAA,CAAM,UAAA,EAAY,cAAA,EAAgB,iBAAiB,CAAA;AAEtE,EAAA,MAAM,KAAA,GAAQ,sBAAA,CAAuB,KAAA,CAAM,UAAU,CAAA;AACrD,EAAA,OAAO,WAAA,CAAY;AAAA,IACf,OAAO,KAAA,CAAM,KAAA;AAAA,IACb,YAAY,KAAA,CAAM,UAAA;AAAA,IAClB,OAAA,EAAS,EAAE,IAAA,EAAM,cAAA,CAAe,MAAM,UAAU,CAAA,EAAG,MAAA,EAAQ,KAAA,CAAM,UAAA,EAAW;AAAA,IAC5E,IAAA,EAAM,8CAAA;AAAA,IACN,cAAA;AAAA,IACA,UAAU,KAAA,CAAM;AAAA,GACnB,CAAA;AACL;AA4BA,eAAsB,gBAAmB,KAAA,EAAmE;AACxG,EAAA,MAAM,MAAA,GAAS,MAAM,eAAA,CAAgB,KAAK,CAAA;AAC1C,EAAA,MAAM,SAAS,MAAM,KAAA,CAAM,IAAA,CAAK,KAAA,CAAM,YAAY,MAAM,CAAA;AACxD,EAAA,OAAO,EAAE,QAAQ,MAAA,EAAO;AAC5B","file":"index.mjs","sourcesContent":["// @orangecheck/agent-mcp — wrap MCP tool invocations in OC Agent envelopes.\n//\n// An MCP client calls tools on an MCP server: { server, name, arguments }.\n// This module stamps each invocation as an `agent-action` whose `content.hash`\n// is a SHA-256 of the canonicalized (server, name, arguments) tuple, scoped\n// as `mcp:invoke(server=\"<url>\",tool=\"<name>\")`. The server — or any counterparty\n// — verifies the action before trusting the call.\n//\n// The wrapper is transport-agnostic. Wire it into whatever MCP client you use\n// by calling `invokeWithStamp()` around the raw call, and send the action with\n// the request under `params._meta[ACTION_META_KEY]`.\n\nimport { sha256 } from '@noble/hashes/sha256';\nimport {\n assertScopeGranted,\n canonicalize,\n canonicalizeScope,\n computeActionId,\n hexEncode,\n type DelegationEnvelope,\n} from '@orangecheck/agent-core';\nimport {\n signAsAgent,\n type ActionEnvelope,\n type SignerRef,\n} from '@orangecheck/agent-signer';\n\n/**\n * Where an MCP request carries its action: `params._meta[ACTION_META_KEY]` on\n * the `tools/call` request. MCP reserves `_meta` for metadata and allows\n * prefixed keys there, so a server that does not check actions ignores it.\n */\nexport const ACTION_META_KEY = 'io.ochk/agent-action';\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Canonicalization of an MCP invocation\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface McpInvocation {\n /**\n * The MCP server's endpoint URL, exactly as the delegation's\n * `mcp:invoke(server=…)` scope names it. Only surrounding whitespace is\n * trimmed. A scope holds the scheme and authority in lower case (OC Agent\n * §7.2 rule 4); the path, a query and a trailing slash count as written. The\n * default exercised scope quotes it, so a `?` or `=` stays part of it.\n */\n server: string;\n /** Tool name (method) being invoked. */\n tool: string;\n /** JSON-serializable arguments. */\n arguments: Record<string, unknown>;\n}\n\n/**\n * Produce the canonical bytes for an MCP invocation. The bytes are what the\n * action envelope's `content.hash` is a SHA-256 of: RFC 8785 JSON of the\n * object below, followed by one LF (0x0A).\n *\n * Shape:\n * {\n * \"arguments\": <canonical JSON of args>,\n * \"server\": <server url>,\n * \"tool\": <tool name>\n * }\n *\n * The shape is intentionally minimal so verifiers can reconstruct it without\n * knowing anything server- or tool-specific.\n */\nexport function canonicalizeInvocation(inv: McpInvocation): Uint8Array {\n const canon = {\n arguments: inv.arguments,\n server: inv.server.trim(),\n tool: inv.tool,\n };\n const str = canonicalize(canon as unknown as Parameters<typeof canonicalize>[0]);\n return new TextEncoder().encode(str + '\\n');\n}\n\nexport function invocationHash(inv: McpInvocation): string {\n return 'sha256:' + hexEncode(sha256(canonicalizeInvocation(inv)));\n}\n\nexport function invocationId(\n agentAddress: string,\n signedAt: string,\n delegationId: string,\n scopeExercised: string,\n inv: McpInvocation\n): string {\n return computeActionId({\n address: agentAddress,\n content_hash: invocationHash(inv),\n content_length: canonicalizeInvocation(inv).byteLength,\n content_mime: 'application/vnd.oc-agent.mcp-invocation+json',\n signed_at: signedAt,\n delegation_id: delegationId,\n scope_exercised: scopeExercised,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Stamped invocation\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface StampInvocationInput {\n agent: SignerRef;\n delegation: DelegationEnvelope;\n invocation: McpInvocation;\n /** Defaults to `mcp:invoke(server=\"<server>\",tool=\"<tool>\")` — the tightest admissible sub-scope. */\n scopeExercised?: string;\n signedAt?: Date;\n}\n\n/**\n * Stamp an MCP invocation without actually calling it. Useful when the caller\n * wants the signed envelope for out-of-band auditing before (or independent of)\n * running the tool.\n */\nexport async function stampInvocation(input: StampInvocationInput): Promise<ActionEnvelope> {\n // Both are quoted values, read as written: a tool called \"*\" is that tool, and a server URL's ? or = stays in it.\n const scopeExercised =\n input.scopeExercised ??\n canonicalizeScope({\n product: 'mcp',\n verb: 'invoke',\n constraints: [\n { key: 'server', op: '=', value: input.invocation.server.trim(), quoted: true },\n { key: 'tool', op: '=', value: input.invocation.tool, quoted: true },\n ],\n });\n\n assertScopeGranted(input.delegation, scopeExercised, 'stampInvocation');\n\n const bytes = canonicalizeInvocation(input.invocation);\n return signAsAgent({\n agent: input.agent,\n delegation: input.delegation,\n content: { hash: invocationHash(input.invocation), length: bytes.byteLength },\n mime: 'application/vnd.oc-agent.mcp-invocation+json',\n scopeExercised,\n signedAt: input.signedAt,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// invokeWithStamp — call the tool AND produce the stamp around it\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface InvokeWithStampInput<T> extends StampInvocationInput {\n /**\n * The underlying transport call. It gets the signed action: send it with\n * the request, under `params._meta[ACTION_META_KEY]`, so the server can\n * verify it before it runs the tool.\n */\n call: (inv: McpInvocation, action: ActionEnvelope) => Promise<T>;\n}\n\nexport interface InvokeWithStampResult<T> {\n result: T;\n action: ActionEnvelope;\n}\n\n/**\n * Stamp the invocation *first*, then make the call with the action in hand,\n * so the request carries it and the server verifies before it acts. The\n * action is also returned with the result for the caller's own record.\n *\n * A server checks it live (agent-core `verifyAction`): the delegation in force\n * now, the action signed just now, and an id it has not seen before.\n */\nexport async function invokeWithStamp<T>(input: InvokeWithStampInput<T>): Promise<InvokeWithStampResult<T>> {\n const action = await stampInvocation(input);\n const result = await input.call(input.invocation, action);\n return { result, action };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangecheck/agent-mcp",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Wrap Model Context Protocol tool invocations in OC Agent envelopes — every call produces a signed, scope-constrained, verifiable agent-action stamp.",
5
5
  "keywords": [
6
6
  "bitcoin",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@noble/hashes": "^1.5.0",
53
- "@orangecheck/agent-core": "^2.9.0",
53
+ "@orangecheck/agent-core": "^2.10.0",
54
54
  "@orangecheck/agent-signer": "^0.5.2"
55
55
  },
56
56
  "devDependencies": {
package/src/index.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { verifyAction } from '@orangecheck/agent-core';
3
+ import { parseScope, verifyAction } from '@orangecheck/agent-core';
4
4
  import { createDelegation } from '@orangecheck/agent-signer';
5
5
 
6
6
  import {
@@ -138,3 +138,59 @@ describe('invokeWithStamp', () => {
138
138
  ]);
139
139
  });
140
140
  });
141
+
142
+ // The default exercised scope names the tool as a quoted value, so the name is
143
+ // read as written: a tool called "*" is that tool, not every tool.
144
+ describe('the default exercised scope', () => {
145
+ const SERVER = 'https://mcp.example.com';
146
+ const grant = (scope: string) =>
147
+ createDelegation({
148
+ principal: { address: PRINCIPAL, signMessage: fakeSign(PRINCIPAL) },
149
+ agentAddress: AGENT,
150
+ scopes: [scope],
151
+ ttlMs: 60 * 60 * 1000,
152
+ });
153
+ const stamp = async (scope: string, tool: string) => {
154
+ const delegation = await grant(scope);
155
+ const action = await stampInvocation({
156
+ agent: { address: AGENT, signMessage: fakeSign(AGENT) },
157
+ delegation,
158
+ invocation: { server: SERVER, tool, arguments: {} },
159
+ });
160
+ const r = await verifyAction({ action, delegation, verifyBip322: fakeVerify, skipRevocationCheck: true });
161
+ return { action, ok: r.ok };
162
+ };
163
+
164
+ it('quotes the server and the tool name', async () => {
165
+ const { action, ok } = await stamp(`mcp:invoke(server=${SERVER},tool=search)`, 'search');
166
+ expect(action.scope_exercised).toBe(`mcp:invoke(server="${SERVER}",tool="search")`);
167
+ expect(ok).toBe(true);
168
+ });
169
+
170
+ it('stamps a tool called "*" as that tool, not every tool', async () => {
171
+ const { action, ok } = await stamp(`mcp:invoke(server=${SERVER},tool="*")`, '*');
172
+ expect(action.scope_exercised).toBe(`mcp:invoke(server="${SERVER}",tool="*")`);
173
+ expect(ok).toBe(true);
174
+ });
175
+
176
+ it('keeps scope punctuation in a tool name as part of the name', async () => {
177
+ const { action, ok } = await stamp(`mcp:invoke(server=${SERVER},tool="a,b")`, 'a,b');
178
+ const tool = parseScope(action.scope_exercised).constraints.find((c) => c.key === 'tool');
179
+ expect(tool).toEqual({ key: 'tool', op: '=', value: 'a,b', quoted: true });
180
+ expect(ok).toBe(true);
181
+ });
182
+
183
+ it('keeps a server URL with a query whole, under a grant that names it', async () => {
184
+ const server = 'https://mcp.example.com/sse?key=x';
185
+ const delegation = await grant(`mcp:invoke(server="${server}",tool=search)`);
186
+ const action = await stampInvocation({
187
+ agent: { address: AGENT, signMessage: fakeSign(AGENT) },
188
+ delegation,
189
+ invocation: { server, tool: 'search', arguments: {} },
190
+ });
191
+ const s = parseScope(action.scope_exercised).constraints.find((c) => c.key === 'server');
192
+ expect(s?.value).toBe(server);
193
+ const r = await verifyAction({ action, delegation, verifyBip322: fakeVerify, skipRevocationCheck: true });
194
+ expect(r.ok).toBe(true);
195
+ });
196
+ });
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // An MCP client calls tools on an MCP server: { server, name, arguments }.
4
4
  // This module stamps each invocation as an `agent-action` whose `content.hash`
5
5
  // is a SHA-256 of the canonicalized (server, name, arguments) tuple, scoped
6
- // as `mcp:invoke(server=<url>,tool=<name>)`. The server — or any counterparty
6
+ // as `mcp:invoke(server="<url>",tool="<name>")`. The server — or any counterparty
7
7
  // — verifies the action before trusting the call.
8
8
  //
9
9
  // The wrapper is transport-agnostic. Wire it into whatever MCP client you use
@@ -14,6 +14,7 @@ import { sha256 } from '@noble/hashes/sha256';
14
14
  import {
15
15
  assertScopeGranted,
16
16
  canonicalize,
17
+ canonicalizeScope,
17
18
  computeActionId,
18
19
  hexEncode,
19
20
  type DelegationEnvelope,
@@ -39,7 +40,9 @@ export interface McpInvocation {
39
40
  /**
40
41
  * The MCP server's endpoint URL, exactly as the delegation's
41
42
  * `mcp:invoke(server=…)` scope names it. Only surrounding whitespace is
42
- * trimmed: case, path and a trailing slash all count.
43
+ * trimmed. A scope holds the scheme and authority in lower case (OC Agent
44
+ * §7.2 rule 4); the path, a query and a trailing slash count as written. The
45
+ * default exercised scope quotes it, so a `?` or `=` stays part of it.
43
46
  */
44
47
  server: string;
45
48
  /** Tool name (method) being invoked. */
@@ -103,7 +106,7 @@ export interface StampInvocationInput {
103
106
  agent: SignerRef;
104
107
  delegation: DelegationEnvelope;
105
108
  invocation: McpInvocation;
106
- /** Defaults to `mcp:invoke(server=<server>,tool=<tool>)` — the tightest admissible sub-scope. */
109
+ /** Defaults to `mcp:invoke(server="<server>",tool="<tool>")` — the tightest admissible sub-scope. */
107
110
  scopeExercised?: string;
108
111
  signedAt?: Date;
109
112
  }
@@ -114,9 +117,17 @@ export interface StampInvocationInput {
114
117
  * running the tool.
115
118
  */
116
119
  export async function stampInvocation(input: StampInvocationInput): Promise<ActionEnvelope> {
120
+ // Both are quoted values, read as written: a tool called "*" is that tool, and a server URL's ? or = stays in it.
117
121
  const scopeExercised =
118
122
  input.scopeExercised ??
119
- `mcp:invoke(server=${input.invocation.server.trim()},tool=${input.invocation.tool})`;
123
+ canonicalizeScope({
124
+ product: 'mcp',
125
+ verb: 'invoke',
126
+ constraints: [
127
+ { key: 'server', op: '=', value: input.invocation.server.trim(), quoted: true },
128
+ { key: 'tool', op: '=', value: input.invocation.tool, quoted: true },
129
+ ],
130
+ });
120
131
 
121
132
  assertScopeGranted(input.delegation, scopeExercised, 'stampInvocation');
122
133