@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 +11 -3
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/index.test.ts +57 -1
- package/src/index.ts +15 -4
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
|
|
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
|
-
|
|
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 ??
|
|
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":";;;;;;;
|
|
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 ??
|
|
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({
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;;;
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|