@orangecheck/agent-mcp 0.4.2 → 0.5.0
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 +43 -34
- package/dist/index.d.mts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/index.test.ts +29 -1
- package/src/index.ts +28 -12
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @orangecheck/agent-mcp
|
|
2
2
|
|
|
3
|
-
> **Full reference:** [
|
|
3
|
+
> **Full reference:** [ochk.io/docs/sdk/agent-mcp](https://ochk.io/docs/sdk/agent-mcp) — auto-generated from the TypeScript source on every release.
|
|
4
4
|
> Hand-written prose below is the high-level overview; the docs site is the source of truth for every export, type, and signature.
|
|
5
5
|
|
|
6
6
|
|
|
@@ -28,31 +28,38 @@ npm i @orangecheck/agent-core @orangecheck/agent-signer
|
|
|
28
28
|
## Quickstart
|
|
29
29
|
|
|
30
30
|
```ts
|
|
31
|
-
import { invokeWithStamp } from '@orangecheck/agent-mcp';
|
|
31
|
+
import { ACTION_META_KEY, invokeWithStamp } from '@orangecheck/agent-mcp';
|
|
32
32
|
|
|
33
33
|
// You already have an agent SignerRef and a DelegationEnvelope from
|
|
34
34
|
// @orangecheck/agent-signer. The delegation grants something like
|
|
35
|
-
// mcp:invoke(server=https://mcp.example.com,tool=search)
|
|
35
|
+
// mcp:invoke(server=https://mcp.example.com/mcp,tool=search)
|
|
36
36
|
|
|
37
37
|
const { result, action } = await invokeWithStamp({
|
|
38
38
|
agent,
|
|
39
39
|
delegation,
|
|
40
40
|
invocation: {
|
|
41
|
-
server: 'https://mcp.example.com',
|
|
41
|
+
server: 'https://mcp.example.com/mcp',
|
|
42
42
|
tool: 'search',
|
|
43
43
|
arguments: { query: 'bitcoin identity', limit: 10 },
|
|
44
44
|
},
|
|
45
|
-
|
|
45
|
+
// The action rides in the request, so the server verifies it first.
|
|
46
|
+
call: async (inv, action) =>
|
|
47
|
+
myMcpClient.request('tools/call', {
|
|
48
|
+
name: inv.tool,
|
|
49
|
+
arguments: inv.arguments,
|
|
50
|
+
_meta: { [ACTION_META_KEY]: action },
|
|
51
|
+
}),
|
|
46
52
|
});
|
|
47
|
-
|
|
48
|
-
// `result` is whatever the MCP server returned.
|
|
49
|
-
// `action` is the signed envelope; ship it alongside the result, publish to
|
|
50
|
-
// Nostr kind-30084, anchor to OpenTimestamps, etc.
|
|
51
53
|
```
|
|
52
54
|
|
|
55
|
+
`ACTION_META_KEY` is `io.ochk/agent-action`. MCP reserves `_meta` for metadata
|
|
56
|
+
and allows prefixed keys, so a server that does not check actions ignores it.
|
|
57
|
+
`server` is the endpoint URL exactly as the delegation's scope names it: case,
|
|
58
|
+
path and a trailing slash all count.
|
|
59
|
+
|
|
53
60
|
## What gets stamped
|
|
54
61
|
|
|
55
|
-
The stamp's `content.hash` is a SHA-256 of the RFC-8785-canonicalized JSON:
|
|
62
|
+
The stamp's `content.hash` is a SHA-256 of the RFC-8785-canonicalized JSON, followed by one LF (0x0A):
|
|
56
63
|
|
|
57
64
|
```json
|
|
58
65
|
{
|
|
@@ -82,62 +89,64 @@ Produces a signed `agent-action` without calling the tool. Useful for pre-author
|
|
|
82
89
|
|
|
83
90
|
### `invokeWithStamp(input) -> Promise<{ result, action }>`
|
|
84
91
|
|
|
85
|
-
Stamps the invocation first, then calls the
|
|
86
|
-
|
|
87
|
-
Rationale for stamping-before-calling: if the downstream call hangs or fails, there is still an on-record commitment that this agent attempted this specific invocation. The server-side verifier sees a stamped intent plus the server's own transcript.
|
|
92
|
+
Stamps the invocation first, then calls `call(invocation, action)`, so the request can carry the action. The action is also returned with the result for your own record.
|
|
88
93
|
|
|
89
94
|
## Server-side verification
|
|
90
95
|
|
|
91
|
-
A server
|
|
96
|
+
A server checks the action before it runs the tool, with `@orangecheck/agent-core` 2.7 or later:
|
|
92
97
|
|
|
93
98
|
```ts
|
|
94
|
-
import { verifyAction } from '@orangecheck/agent-core';
|
|
99
|
+
import { createReplayGuard, isSubScope, parseScope, verifyAction } from '@orangecheck/agent-core';
|
|
100
|
+
import { ACTION_META_KEY, canonicalizeInvocation } from '@orangecheck/agent-mcp';
|
|
95
101
|
import { verifySignature } from '@orangecheck/bip322';
|
|
96
102
|
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
const seen = createReplayGuard(); // one process; across instances, the same record in a shared store
|
|
104
|
+
|
|
105
|
+
const action = params._meta?.[ACTION_META_KEY];
|
|
106
|
+
// `revocations`: the kind-30085 envelopes you fetched with "#t": [delegation.id].
|
|
107
|
+
// If no relay answered, you have not checked: refuse the call.
|
|
100
108
|
const r = await verifyAction({
|
|
101
109
|
action,
|
|
102
110
|
delegation,
|
|
103
111
|
revocations,
|
|
104
112
|
verifyBip322: async (m, s, a) => verifySignature(a, m, s),
|
|
113
|
+
// Binds the action to this exact request.
|
|
114
|
+
content: canonicalizeInvocation({ server: MY_ENDPOINT, tool: params.name, arguments: params.arguments }),
|
|
105
115
|
});
|
|
106
|
-
if (!r.ok) throw new Error(`
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
arguments: req.arguments,
|
|
115
|
-
});
|
|
116
|
-
if (expectedHash !== action.content.hash) {
|
|
117
|
-
throw new Error('stamp does not cover this request');
|
|
118
|
-
}
|
|
116
|
+
if (!r.ok) throw new Error(`refused: ${r.code}`);
|
|
117
|
+
|
|
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})`);
|
|
120
|
+
if (!isSubScope(parseScope(action.scope_exercised), here)) throw new Error('refused: E_SCOPE_DENIED');
|
|
121
|
+
|
|
122
|
+
// One signed action runs once.
|
|
123
|
+
if (!seen.firstUse(action)) throw new Error('refused: already used');
|
|
119
124
|
```
|
|
120
125
|
|
|
126
|
+
`verifyAction` checks as of now: the delegation in force now, any revocation already in effect, and `signed_at` within 120 s of your clock (`E_STALE` otherwise).
|
|
127
|
+
|
|
121
128
|
## Composing with OpenTimestamps
|
|
122
129
|
|
|
123
130
|
For invocations where priority-against-revocation matters:
|
|
124
131
|
|
|
125
132
|
```ts
|
|
126
|
-
import {
|
|
133
|
+
import { submitToCalendars, toStampOts } from '@orangecheck/stamp-ots';
|
|
127
134
|
|
|
128
135
|
const { action } = await invokeWithStamp({ /* … */ });
|
|
129
|
-
|
|
136
|
+
// Pending until a Bitcoin block confirms it.
|
|
137
|
+
action.ots = toStampOts(await submitToCalendars(action.id));
|
|
130
138
|
```
|
|
131
139
|
|
|
132
140
|
## Composing with Nostr
|
|
133
141
|
|
|
134
|
-
Publish the action for public auditability
|
|
142
|
+
Publish the action for public auditability on kind 30084, OC Agent's action kind. The `t` tag carries the delegation id, so anyone can list a delegation's actions with `{ kinds: [30084], '#t': [delegation.id] }` (oc-agent-protocol SPEC §10.3). Tags sit on the Nostr event, outside the signed envelope, so they change no id:
|
|
135
143
|
|
|
136
144
|
```ts
|
|
137
145
|
await nostr.publish({
|
|
138
146
|
kind: 30084,
|
|
139
147
|
tags: [
|
|
140
148
|
['d', 'oc-agent-act:' + action.id],
|
|
149
|
+
['t', action.delegation_id],
|
|
141
150
|
['kind', 'agent-action'],
|
|
142
151
|
['delegation', action.delegation_id],
|
|
143
152
|
['agent', action.signer.address],
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DelegationEnvelope } from '@orangecheck/agent-core';
|
|
2
2
|
import { SignerRef, ActionEnvelope } from '@orangecheck/agent-signer';
|
|
3
3
|
|
|
4
|
+
declare const ACTION_META_KEY = "io.ochk/agent-action";
|
|
4
5
|
interface McpInvocation {
|
|
5
6
|
server: string;
|
|
6
7
|
tool: string;
|
|
@@ -18,7 +19,7 @@ interface StampInvocationInput {
|
|
|
18
19
|
}
|
|
19
20
|
declare function stampInvocation(input: StampInvocationInput): Promise<ActionEnvelope>;
|
|
20
21
|
interface InvokeWithStampInput<T> extends StampInvocationInput {
|
|
21
|
-
call: (inv: McpInvocation) => Promise<T>;
|
|
22
|
+
call: (inv: McpInvocation, action: ActionEnvelope) => Promise<T>;
|
|
22
23
|
}
|
|
23
24
|
interface InvokeWithStampResult<T> {
|
|
24
25
|
result: T;
|
|
@@ -26,4 +27,4 @@ interface InvokeWithStampResult<T> {
|
|
|
26
27
|
}
|
|
27
28
|
declare function invokeWithStamp<T>(input: InvokeWithStampInput<T>): Promise<InvokeWithStampResult<T>>;
|
|
28
29
|
|
|
29
|
-
export { type InvokeWithStampInput, type InvokeWithStampResult, type McpInvocation, type StampInvocationInput, canonicalizeInvocation, invocationHash, invocationId, invokeWithStamp, stampInvocation };
|
|
30
|
+
export { ACTION_META_KEY, type InvokeWithStampInput, type InvokeWithStampResult, type McpInvocation, type StampInvocationInput, canonicalizeInvocation, invocationHash, invocationId, invokeWithStamp, stampInvocation };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DelegationEnvelope } from '@orangecheck/agent-core';
|
|
2
2
|
import { SignerRef, ActionEnvelope } from '@orangecheck/agent-signer';
|
|
3
3
|
|
|
4
|
+
declare const ACTION_META_KEY = "io.ochk/agent-action";
|
|
4
5
|
interface McpInvocation {
|
|
5
6
|
server: string;
|
|
6
7
|
tool: string;
|
|
@@ -18,7 +19,7 @@ interface StampInvocationInput {
|
|
|
18
19
|
}
|
|
19
20
|
declare function stampInvocation(input: StampInvocationInput): Promise<ActionEnvelope>;
|
|
20
21
|
interface InvokeWithStampInput<T> extends StampInvocationInput {
|
|
21
|
-
call: (inv: McpInvocation) => Promise<T>;
|
|
22
|
+
call: (inv: McpInvocation, action: ActionEnvelope) => Promise<T>;
|
|
22
23
|
}
|
|
23
24
|
interface InvokeWithStampResult<T> {
|
|
24
25
|
result: T;
|
|
@@ -26,4 +27,4 @@ interface InvokeWithStampResult<T> {
|
|
|
26
27
|
}
|
|
27
28
|
declare function invokeWithStamp<T>(input: InvokeWithStampInput<T>): Promise<InvokeWithStampResult<T>>;
|
|
28
29
|
|
|
29
|
-
export { type InvokeWithStampInput, type InvokeWithStampResult, type McpInvocation, type StampInvocationInput, canonicalizeInvocation, invocationHash, invocationId, invokeWithStamp, stampInvocation };
|
|
30
|
+
export { ACTION_META_KEY, type InvokeWithStampInput, type InvokeWithStampResult, type McpInvocation, type StampInvocationInput, canonicalizeInvocation, invocationHash, invocationId, invokeWithStamp, stampInvocation };
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ var agentCore = require('@orangecheck/agent-core');
|
|
|
5
5
|
var agentSigner = require('@orangecheck/agent-signer');
|
|
6
6
|
|
|
7
7
|
// src/index.ts
|
|
8
|
+
var ACTION_META_KEY = "io.ochk/agent-action";
|
|
8
9
|
function canonicalizeInvocation(inv) {
|
|
9
10
|
const canon = {
|
|
10
11
|
arguments: inv.arguments,
|
|
@@ -43,10 +44,11 @@ async function stampInvocation(input) {
|
|
|
43
44
|
}
|
|
44
45
|
async function invokeWithStamp(input) {
|
|
45
46
|
const action = await stampInvocation(input);
|
|
46
|
-
const result = await input.call(input.invocation);
|
|
47
|
+
const result = await input.call(input.invocation, action);
|
|
47
48
|
return { result, action };
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
exports.ACTION_META_KEY = ACTION_META_KEY;
|
|
50
52
|
exports.canonicalizeInvocation = canonicalizeInvocation;
|
|
51
53
|
exports.invocationHash = invocationHash;
|
|
52
54
|
exports.invocationId = invocationId;
|
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","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"]}
|
package/dist/index.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { canonicalize, hexEncode, computeActionId, assertScopeGranted } from '@o
|
|
|
3
3
|
import { signAsAgent } from '@orangecheck/agent-signer';
|
|
4
4
|
|
|
5
5
|
// src/index.ts
|
|
6
|
+
var ACTION_META_KEY = "io.ochk/agent-action";
|
|
6
7
|
function canonicalizeInvocation(inv) {
|
|
7
8
|
const canon = {
|
|
8
9
|
arguments: inv.arguments,
|
|
@@ -41,10 +42,10 @@ async function stampInvocation(input) {
|
|
|
41
42
|
}
|
|
42
43
|
async function invokeWithStamp(input) {
|
|
43
44
|
const action = await stampInvocation(input);
|
|
44
|
-
const result = await input.call(input.invocation);
|
|
45
|
+
const result = await input.call(input.invocation, action);
|
|
45
46
|
return { result, action };
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
export { canonicalizeInvocation, invocationHash, invocationId, invokeWithStamp, stampInvocation };
|
|
49
|
+
export { ACTION_META_KEY, canonicalizeInvocation, invocationHash, invocationId, invokeWithStamp, stampInvocation };
|
|
49
50
|
//# sourceMappingURL=index.mjs.map
|
|
50
51
|
//# sourceMappingURL=index.mjs.map
|
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":";;;;;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"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangecheck/agent-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
package/src/index.test.ts
CHANGED
|
@@ -3,7 +3,13 @@ import { describe, expect, it } from 'vitest';
|
|
|
3
3
|
import { verifyAction } from '@orangecheck/agent-core';
|
|
4
4
|
import { createDelegation } from '@orangecheck/agent-signer';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
ACTION_META_KEY,
|
|
8
|
+
canonicalizeInvocation,
|
|
9
|
+
invocationHash,
|
|
10
|
+
invokeWithStamp,
|
|
11
|
+
stampInvocation,
|
|
12
|
+
} from './index.js';
|
|
7
13
|
|
|
8
14
|
const PRINCIPAL = 'bc1qprincipal000000000000000000000000000000';
|
|
9
15
|
const AGENT = 'bc1qagent0000000000000000000000000000000000';
|
|
@@ -109,4 +115,26 @@ describe('invokeWithStamp', () => {
|
|
|
109
115
|
expect(action.kind).toBe('agent-action');
|
|
110
116
|
expect(action.delegation_id).toBe(delegation.id);
|
|
111
117
|
});
|
|
118
|
+
|
|
119
|
+
it('hands the action to the call, so the request can carry it', async () => {
|
|
120
|
+
const delegation = await createDelegation({
|
|
121
|
+
principal: { address: PRINCIPAL, signMessage: fakeSign(PRINCIPAL) },
|
|
122
|
+
agentAddress: AGENT,
|
|
123
|
+
scopes: ['mcp:invoke(server=https://mcp.example.com,tool=search)'],
|
|
124
|
+
ttlMs: 60 * 60 * 1000,
|
|
125
|
+
});
|
|
126
|
+
const sent: unknown[] = [];
|
|
127
|
+
const { action } = await invokeWithStamp({
|
|
128
|
+
agent: { address: AGENT, signMessage: fakeSign(AGENT) },
|
|
129
|
+
delegation,
|
|
130
|
+
invocation: { server: 'https://mcp.example.com', tool: 'search', arguments: { q: 'bitcoin' } },
|
|
131
|
+
call: async (inv, signed) => {
|
|
132
|
+
sent.push({ name: inv.tool, arguments: inv.arguments, _meta: { [ACTION_META_KEY]: signed } });
|
|
133
|
+
return null;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
expect(sent).toEqual([
|
|
137
|
+
{ name: 'search', arguments: { q: 'bitcoin' }, _meta: { 'io.ochk/agent-action': action } },
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
112
140
|
});
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
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
|
|
10
|
-
// by calling `invokeWithStamp()` around the raw call
|
|
10
|
+
// by calling `invokeWithStamp()` around the raw call, and send the action with
|
|
11
|
+
// the request under `params._meta[ACTION_META_KEY]`.
|
|
11
12
|
|
|
12
13
|
import { sha256 } from '@noble/hashes/sha256';
|
|
13
14
|
import {
|
|
@@ -23,12 +24,23 @@ import {
|
|
|
23
24
|
type SignerRef,
|
|
24
25
|
} from '@orangecheck/agent-signer';
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Where an MCP request carries its action: `params._meta[ACTION_META_KEY]` on
|
|
29
|
+
* the `tools/call` request. MCP reserves `_meta` for metadata and allows
|
|
30
|
+
* prefixed keys there, so a server that does not check actions ignores it.
|
|
31
|
+
*/
|
|
32
|
+
export const ACTION_META_KEY = 'io.ochk/agent-action';
|
|
33
|
+
|
|
26
34
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
35
|
// Canonicalization of an MCP invocation
|
|
28
36
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
37
|
|
|
30
38
|
export interface McpInvocation {
|
|
31
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* The MCP server's endpoint URL, exactly as the delegation's
|
|
41
|
+
* `mcp:invoke(server=…)` scope names it. Only surrounding whitespace is
|
|
42
|
+
* trimmed: case, path and a trailing slash all count.
|
|
43
|
+
*/
|
|
32
44
|
server: string;
|
|
33
45
|
/** Tool name (method) being invoked. */
|
|
34
46
|
tool: string;
|
|
@@ -38,7 +50,8 @@ export interface McpInvocation {
|
|
|
38
50
|
|
|
39
51
|
/**
|
|
40
52
|
* Produce the canonical bytes for an MCP invocation. The bytes are what the
|
|
41
|
-
* action envelope's `content.hash` is a SHA-256 of
|
|
53
|
+
* action envelope's `content.hash` is a SHA-256 of: RFC 8785 JSON of the
|
|
54
|
+
* object below, followed by one LF (0x0A).
|
|
42
55
|
*
|
|
43
56
|
* Shape:
|
|
44
57
|
* {
|
|
@@ -123,8 +136,12 @@ export async function stampInvocation(input: StampInvocationInput): Promise<Acti
|
|
|
123
136
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
124
137
|
|
|
125
138
|
export interface InvokeWithStampInput<T> extends StampInvocationInput {
|
|
126
|
-
/**
|
|
127
|
-
|
|
139
|
+
/**
|
|
140
|
+
* The underlying transport call. It gets the signed action: send it with
|
|
141
|
+
* the request, under `params._meta[ACTION_META_KEY]`, so the server can
|
|
142
|
+
* verify it before it runs the tool.
|
|
143
|
+
*/
|
|
144
|
+
call: (inv: McpInvocation, action: ActionEnvelope) => Promise<T>;
|
|
128
145
|
}
|
|
129
146
|
|
|
130
147
|
export interface InvokeWithStampResult<T> {
|
|
@@ -133,16 +150,15 @@ export interface InvokeWithStampResult<T> {
|
|
|
133
150
|
}
|
|
134
151
|
|
|
135
152
|
/**
|
|
136
|
-
* Stamp the invocation *first*, then
|
|
137
|
-
*
|
|
153
|
+
* Stamp the invocation *first*, then make the call with the action in hand,
|
|
154
|
+
* so the request carries it and the server verifies before it acts. The
|
|
155
|
+
* action is also returned with the result for the caller's own record.
|
|
138
156
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* invocation. The server-side verifier sees a stamped intent plus the server's
|
|
142
|
-
* own transcript.
|
|
157
|
+
* A server checks it live (agent-core `verifyAction`): the delegation in force
|
|
158
|
+
* now, the action signed just now, and an id it has not seen before.
|
|
143
159
|
*/
|
|
144
160
|
export async function invokeWithStamp<T>(input: InvokeWithStampInput<T>): Promise<InvokeWithStampResult<T>> {
|
|
145
161
|
const action = await stampInvocation(input);
|
|
146
|
-
const result = await input.call(input.invocation);
|
|
162
|
+
const result = await input.call(input.invocation, action);
|
|
147
163
|
return { result, action };
|
|
148
164
|
}
|