@ic402/mcp 2.5.2
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/LICENSE +190 -0
- package/README.md +326 -0
- package/dist/guards.d.ts +79 -0
- package/dist/guards.js +233 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1216 -0
- package/dist/index.js.map +1 -0
- package/dist/security.d.ts +71 -0
- package/dist/security.js +190 -0
- package/dist/security.js.map +1 -0
- package/package.json +38 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { Actor, HttpAgent } from '@icp-sdk/core/agent';
|
|
5
|
+
import { Secp256k1KeyIdentity } from '@icp-sdk/core/identity/secp256k1';
|
|
6
|
+
import { Ed25519KeyIdentity } from '@icp-sdk/core/identity';
|
|
7
|
+
import { Ic402Client, Ic402Error, probeX402, applyVerbatimAccepted, exampleIdlFactory, } from '@ic402/client';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { validateFetchUrl, safeFetch, assertResolvedHostIsPublic } from './security.js';
|
|
11
|
+
import { parseAtomicAmount, checkSpend, resolveSecurityConfig, isToolAllowed, isCallMethodAllowed, resolveOperatorConfig, } from './guards.js';
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// State
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
let client = null;
|
|
16
|
+
let agent = null;
|
|
17
|
+
let defaultCanisterId = null;
|
|
18
|
+
const activeSessions = new Map();
|
|
19
|
+
// Conservative defaults: a tiny per-call cap and a small cumulative cap. These
|
|
20
|
+
// force the LLM to either stay within a trivial budget or have the human raise
|
|
21
|
+
// the caps explicitly via the `configure` tool. USDC has 6 decimals, so
|
|
22
|
+
// 1_000_000 atomic = 1.00 USDC.
|
|
23
|
+
const DEFAULT_PER_CALL_MAX_ATOMIC = 1000000n; // 1.00 USDC
|
|
24
|
+
const DEFAULT_SESSION_MAX_ATOMIC = 5000000n; // 5.00 USDC
|
|
25
|
+
// S8/S1/S9: operator-only security posture. Resolved at STARTUP in main() from an optional
|
|
26
|
+
// config file + env vars (both out-of-band — the LLM cannot influence them). The `configure`
|
|
27
|
+
// tool cannot loosen these unless the operator set allowSecurityChanges, and the dangerous
|
|
28
|
+
// signing/destructive tools are off unless allowDangerousTools. Defaults are conservative.
|
|
29
|
+
let allowSecurityChanges = false;
|
|
30
|
+
let allowDangerousTools = false;
|
|
31
|
+
// SEC-3: state-changing admin tools (register/enable service, claim/submit job, upload content)
|
|
32
|
+
// are off unless the operator opts in at startup.
|
|
33
|
+
let allowAdminTools = false;
|
|
34
|
+
const securityConfig = {
|
|
35
|
+
localDev: false,
|
|
36
|
+
perCallMaxAtomic: DEFAULT_PER_CALL_MAX_ATOMIC,
|
|
37
|
+
sessionMaxAtomic: DEFAULT_SESSION_MAX_ATOMIC,
|
|
38
|
+
autoPayment: false,
|
|
39
|
+
};
|
|
40
|
+
/** Running total of atomic units spent (signed/approved) this server session. */
|
|
41
|
+
let sessionSpentAtomic = 0n;
|
|
42
|
+
/**
|
|
43
|
+
* Enforce the per-call and cumulative session spend caps against the running total. With
|
|
44
|
+
* commit:true it ALSO reserves the amount synchronously. SEC-0: reservations are made at confirm
|
|
45
|
+
* time (see requireConfirmation) BEFORE any await, so a second pipelined/batched tool call sees the
|
|
46
|
+
* reservation immediately. Without this, the stdio transport's concurrent dispatch let N calls all
|
|
47
|
+
* pass the same stale cumulative cap (a check-then-commit TOCTOU). Throws on violation; release a
|
|
48
|
+
* reservation with refundSpend() if the action ultimately fails.
|
|
49
|
+
*/
|
|
50
|
+
function spendGuard(amountAtomic, opts = {}) {
|
|
51
|
+
checkSpend(amountAtomic, securityConfig, sessionSpentAtomic);
|
|
52
|
+
if (opts.commit)
|
|
53
|
+
sessionSpentAtomic += amountAtomic;
|
|
54
|
+
}
|
|
55
|
+
/** Release a reservation made by spendGuard(commit:true) when the action then failed. */
|
|
56
|
+
function refundSpend(amountAtomic) {
|
|
57
|
+
sessionSpentAtomic -= amountAtomic;
|
|
58
|
+
if (sessionSpentAtomic < 0n)
|
|
59
|
+
sessionSpentAtomic = 0n;
|
|
60
|
+
}
|
|
61
|
+
/** Standard MCP text result helper. */
|
|
62
|
+
function textResult(obj) {
|
|
63
|
+
return { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Confirmation gate for money-moving / signing actions. When `confirm` is
|
|
67
|
+
* false, returns a structured prompt describing the proposed action (amount,
|
|
68
|
+
* recipient, asset, chain) and instructs the caller to re-invoke with
|
|
69
|
+
* confirm:true. Returns `null` once confirmed (caller proceeds). Throws if the
|
|
70
|
+
* spend caps would be violated, so an over-cap action is refused even before
|
|
71
|
+
* confirmation.
|
|
72
|
+
*/
|
|
73
|
+
function requireConfirmation(args) {
|
|
74
|
+
// Enforce caps first (only meaningful when an amount is known). SEC-0: on the CONFIRMED
|
|
75
|
+
// invocation, RESERVE the amount synchronously (commit:true) so a concurrent/pipelined tool call
|
|
76
|
+
// can't pass the same stale cumulative cap. The post-await commitSpend() at the call sites is
|
|
77
|
+
// removed; failures release the reservation via refundSpend().
|
|
78
|
+
if (args.amountAtomic !== undefined) {
|
|
79
|
+
spendGuard(args.amountAtomic, { commit: args.confirm });
|
|
80
|
+
}
|
|
81
|
+
if (args.confirm)
|
|
82
|
+
return null;
|
|
83
|
+
return textResult({
|
|
84
|
+
status: 'confirmation_required',
|
|
85
|
+
action: args.action,
|
|
86
|
+
proposal: {
|
|
87
|
+
amount: args.amountAtomic !== undefined ? args.amountAtomic.toString() : undefined,
|
|
88
|
+
recipient: args.recipient,
|
|
89
|
+
asset: args.asset,
|
|
90
|
+
chain: args.chain,
|
|
91
|
+
note: args.note,
|
|
92
|
+
},
|
|
93
|
+
caps: {
|
|
94
|
+
perCallMaxAtomic: securityConfig.perCallMaxAtomic.toString(),
|
|
95
|
+
sessionMaxAtomic: securityConfig.sessionMaxAtomic.toString(),
|
|
96
|
+
sessionSpentAtomic: sessionSpentAtomic.toString(),
|
|
97
|
+
},
|
|
98
|
+
instruction: `This action signs/moves value. Review the amount, recipient, asset, and chain above. ` +
|
|
99
|
+
`If correct, re-invoke "${args.action}" with confirm:true. Do NOT confirm automatically.`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// SSRF guard — used by every outbound fetch path
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// The SSRF helpers (ipv4ToInt / isPrivateIpv4 / isPrivateIpv6 / validateFetchUrl) and the
|
|
106
|
+
// redirect-safe `safeFetch` now live in ./security.ts so they can be unit-tested in
|
|
107
|
+
// isolation (see test/mcp-security.test.ts). validateFetchUrl/safeFetch take the localDev
|
|
108
|
+
// flag explicitly rather than reading the module global.
|
|
109
|
+
/** Per-request SSRF options derived from the (mutable) server security config. */
|
|
110
|
+
function ssrfOpts() {
|
|
111
|
+
return { localDev: securityConfig.localDev };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Validate an ICP canister principal text. Principal text is groups of
|
|
115
|
+
* lowercase base32 [a-z0-9] separated by single dashes (e.g.
|
|
116
|
+
* "rrkah-fqaaa-aaaaa-aaaaq-cai"). Reject anything with path/scheme/host
|
|
117
|
+
* separators so it can't be smuggled into a URL.
|
|
118
|
+
*/
|
|
119
|
+
function validateCanisterId(id) {
|
|
120
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id)) {
|
|
121
|
+
throw new Error(`Invalid canister id format: ${JSON.stringify(id)}`);
|
|
122
|
+
}
|
|
123
|
+
if (/[/@:.\\]/.test(id)) {
|
|
124
|
+
throw new Error(`Canister id contains illegal characters: ${JSON.stringify(id)}`);
|
|
125
|
+
}
|
|
126
|
+
return id;
|
|
127
|
+
}
|
|
128
|
+
function requireClient() {
|
|
129
|
+
if (!client)
|
|
130
|
+
throw new Error('Not configured. Call the "configure" tool first.');
|
|
131
|
+
return client;
|
|
132
|
+
}
|
|
133
|
+
function requireAgent() {
|
|
134
|
+
if (!agent)
|
|
135
|
+
throw new Error('Not configured. Call the "configure" tool first.');
|
|
136
|
+
return agent;
|
|
137
|
+
}
|
|
138
|
+
function actorFactory(canisterId) {
|
|
139
|
+
return Actor.createActor(exampleIdlFactory, {
|
|
140
|
+
agent: requireAgent(),
|
|
141
|
+
canisterId,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// Minimal ICRC-2 ledger IDL for auto-payment (approve + transfer_from)
|
|
145
|
+
import { IDL } from '@icp-sdk/core/candid';
|
|
146
|
+
const icrc2LedgerIdl = () => {
|
|
147
|
+
const Account = IDL.Record({ owner: IDL.Principal, subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)) });
|
|
148
|
+
return IDL.Service({
|
|
149
|
+
icrc2_approve: IDL.Func([
|
|
150
|
+
IDL.Record({
|
|
151
|
+
spender: Account,
|
|
152
|
+
amount: IDL.Nat,
|
|
153
|
+
fee: IDL.Opt(IDL.Nat),
|
|
154
|
+
memo: IDL.Opt(IDL.Vec(IDL.Nat8)),
|
|
155
|
+
from_subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)),
|
|
156
|
+
created_at_time: IDL.Opt(IDL.Nat64),
|
|
157
|
+
expected_allowance: IDL.Opt(IDL.Nat),
|
|
158
|
+
expires_at: IDL.Opt(IDL.Nat64),
|
|
159
|
+
}),
|
|
160
|
+
], [IDL.Variant({ Ok: IDL.Nat, Err: IDL.Text })], []),
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
function ledgerActorFactory(ledgerCanisterId) {
|
|
164
|
+
return Actor.createActor(icrc2LedgerIdl, {
|
|
165
|
+
agent: requireAgent(),
|
|
166
|
+
canisterId: ledgerCanisterId,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** Serialize a value for JSON, handling bigint, Uint8Array, and Error instances. */
|
|
170
|
+
function serialize(value) {
|
|
171
|
+
if (typeof value === 'bigint')
|
|
172
|
+
return value.toString();
|
|
173
|
+
if (value instanceof Uint8Array)
|
|
174
|
+
return Buffer.from(value).toString('hex');
|
|
175
|
+
if (value instanceof Ic402Error) {
|
|
176
|
+
return { kind: value.kind, message: value.message, retryable: value.retryable };
|
|
177
|
+
}
|
|
178
|
+
if (value instanceof Error) {
|
|
179
|
+
return { message: value.message };
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(value))
|
|
182
|
+
return value.map(serialize);
|
|
183
|
+
if (value && typeof value === 'object') {
|
|
184
|
+
const out = {};
|
|
185
|
+
for (const [k, v] of Object.entries(value)) {
|
|
186
|
+
out[k] = serialize(v);
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Server
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
const server = new McpServer({
|
|
196
|
+
name: 'ic402',
|
|
197
|
+
version: '2.5.2',
|
|
198
|
+
});
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Tool: configure
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
server.tool('configure', 'Connect to an ic402-enabled ICP canister. Must be called before any other tool.', {
|
|
203
|
+
canisterId: z.string().describe('Principal of the canister to interact with'),
|
|
204
|
+
host: z.string().default('http://localhost:4944').describe('ICP replica URL'),
|
|
205
|
+
network: z.string().default('icp:1').describe('CAIP-2 network identifier'),
|
|
206
|
+
identityPem: z
|
|
207
|
+
.string()
|
|
208
|
+
.optional()
|
|
209
|
+
.describe('Path to a secp256k1 PEM file for signing (e.g. identity.pem)'),
|
|
210
|
+
ledger: z
|
|
211
|
+
.string()
|
|
212
|
+
.optional()
|
|
213
|
+
.describe('ICRC-2 ledger canister ID for auto-payment (e.g. ckUSDC)'),
|
|
214
|
+
autoPayment: z
|
|
215
|
+
.boolean()
|
|
216
|
+
.default(false)
|
|
217
|
+
.describe('Allow paid endpoints to auto-approve/pay (opt-in). Even when true, spends are capped and confirmation-gated. Default false.'),
|
|
218
|
+
localDev: z
|
|
219
|
+
.boolean()
|
|
220
|
+
.default(false)
|
|
221
|
+
.describe('Allow http://localhost and private/loopback fetch targets (local development only). Default false.'),
|
|
222
|
+
perCallMaxAtomic: z
|
|
223
|
+
.string()
|
|
224
|
+
.optional()
|
|
225
|
+
.describe('Per-call max spend in atomic token units (caps a single signed transfer). Defaults to a small value.'),
|
|
226
|
+
sessionMaxAtomic: z
|
|
227
|
+
.string()
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('Cumulative session max spend in atomic token units across all signed transfers. Defaults to a small value.'),
|
|
230
|
+
}, async ({ canisterId, host, network, identityPem, ledger, autoPayment, localDev, perCallMaxAtomic, sessionMaxAtomic, }) => {
|
|
231
|
+
// Load identity from PEM if provided, otherwise check env, otherwise anonymous.
|
|
232
|
+
// icp identity export outputs PKCS#8 ("BEGIN PRIVATE KEY"), but
|
|
233
|
+
// Secp256k1KeyIdentity.fromPem expects SEC1 ("BEGIN EC PRIVATE KEY").
|
|
234
|
+
// We handle both by extracting the raw 32-byte secret key from PKCS#8.
|
|
235
|
+
let identity = null;
|
|
236
|
+
const pemPath = identityPem || process.env.ICP_IDENTITY_PEM;
|
|
237
|
+
if (pemPath) {
|
|
238
|
+
try {
|
|
239
|
+
const pem = readFileSync(pemPath, 'utf-8');
|
|
240
|
+
if (pem.includes('BEGIN EC PRIVATE KEY')) {
|
|
241
|
+
identity = Secp256k1KeyIdentity.fromPem(pem);
|
|
242
|
+
}
|
|
243
|
+
else if (pem.includes('BEGIN PRIVATE KEY')) {
|
|
244
|
+
// H-5: PKCS#8 DER — validate structure before extracting secp256k1 secret key.
|
|
245
|
+
const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, '');
|
|
246
|
+
const der = Buffer.from(b64, 'base64');
|
|
247
|
+
// Validate minimum length for secp256k1 PKCS#8 (header + 32-byte key)
|
|
248
|
+
if (der.length < 65) {
|
|
249
|
+
throw new Error('PKCS#8 DER too short: expected at least 65 bytes');
|
|
250
|
+
}
|
|
251
|
+
// Validate secp256k1 OID (1.3.132.0.10) is present in the DER
|
|
252
|
+
const secp256k1Oid = Buffer.from([0x2b, 0x81, 0x04, 0x00, 0x0a]);
|
|
253
|
+
if (!der.includes(secp256k1Oid)) {
|
|
254
|
+
throw new Error('PKCS#8 key does not contain secp256k1 OID — expected secp256k1 identity');
|
|
255
|
+
}
|
|
256
|
+
const secretKey = der.slice(33, 65);
|
|
257
|
+
if (secretKey.length !== 32) {
|
|
258
|
+
throw new Error(`Expected 32-byte secret key, got ${secretKey.length}`);
|
|
259
|
+
}
|
|
260
|
+
identity = Secp256k1KeyIdentity.fromSecretKey(new Uint8Array(secretKey));
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
throw new Error('Unsupported PEM format: expected EC PRIVATE KEY or PRIVATE KEY');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
// Surface error clearly — do NOT silently fall back to anonymous for PEM files
|
|
268
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
269
|
+
console.error('Identity load failed:', msg);
|
|
270
|
+
throw new Error(`Failed to load identity from ${pemPath}: ${msg}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
agent = await HttpAgent.create({
|
|
274
|
+
host,
|
|
275
|
+
shouldFetchRootKey: host.includes('localhost'),
|
|
276
|
+
identity: identity ?? undefined,
|
|
277
|
+
});
|
|
278
|
+
defaultCanisterId = canisterId;
|
|
279
|
+
// S8: Apply the security knobs ONLY if the operator allowed LLM-driven changes;
|
|
280
|
+
// otherwise the request's localDev/autoPayment/caps are ignored and the
|
|
281
|
+
// operator/default config stands. This stops a prompt-injected model from raising
|
|
282
|
+
// its own caps or enabling localDev/autoPayment via the configure tool.
|
|
283
|
+
const resolved = resolveSecurityConfig(securityConfig, { localDev, autoPayment, perCallMaxAtomic, sessionMaxAtomic }, allowSecurityChanges);
|
|
284
|
+
securityConfig.localDev = resolved.config.localDev;
|
|
285
|
+
securityConfig.autoPayment = resolved.config.autoPayment;
|
|
286
|
+
securityConfig.perCallMaxAtomic = resolved.config.perCallMaxAtomic;
|
|
287
|
+
securityConfig.sessionMaxAtomic = resolved.config.sessionMaxAtomic;
|
|
288
|
+
const ignoredNote = resolved.ignored.length > 0
|
|
289
|
+
? ` IGNORED security params ${JSON.stringify(resolved.ignored)} — operator did not enable ` +
|
|
290
|
+
`LLM security changes (set IC402_MCP_ALLOW_SECURITY_CHANGES=1 to allow).`
|
|
291
|
+
: '';
|
|
292
|
+
client = new Ic402Client({
|
|
293
|
+
canisterId,
|
|
294
|
+
actorFactory,
|
|
295
|
+
identity,
|
|
296
|
+
network,
|
|
297
|
+
// Auto-payment is opt-in. Even when enabled, the MCP tools enforce
|
|
298
|
+
// per-call/cumulative caps and a confirmation gate around it.
|
|
299
|
+
autoPayment: securityConfig.autoPayment,
|
|
300
|
+
ledger: ledger ?? undefined,
|
|
301
|
+
ledgerActorFactory: ledger ? ledgerActorFactory : undefined,
|
|
302
|
+
});
|
|
303
|
+
return {
|
|
304
|
+
content: [
|
|
305
|
+
{
|
|
306
|
+
type: 'text',
|
|
307
|
+
text: `Connected to ${canisterId} at ${host} (network: ${network}, identity: ${identity ? identity.getPrincipal().toText() : 'anonymous'}). ` +
|
|
308
|
+
`autoPayment=${securityConfig.autoPayment}, localDev=${securityConfig.localDev}, ` +
|
|
309
|
+
`perCallMaxAtomic=${securityConfig.perCallMaxAtomic}, sessionMaxAtomic=${securityConfig.sessionMaxAtomic}.` +
|
|
310
|
+
ignoredNote,
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Tool: search
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
server.tool('search', 'Call the search endpoint on an ic402 canister (x402 charge flow). Returns results or a payment requirement.', {
|
|
319
|
+
query: z.string().describe('Search query text'),
|
|
320
|
+
canisterId: z
|
|
321
|
+
.string()
|
|
322
|
+
.optional()
|
|
323
|
+
.describe('Canister to call (defaults to configured canister)'),
|
|
324
|
+
}, async ({ query, canisterId }) => {
|
|
325
|
+
const cid = canisterId ?? defaultCanisterId;
|
|
326
|
+
if (!cid)
|
|
327
|
+
throw new Error('No canister ID. Configure first or pass canisterId.');
|
|
328
|
+
requireAgent();
|
|
329
|
+
const actor = actorFactory(cid);
|
|
330
|
+
const result = (await actor.search(query, []));
|
|
331
|
+
if ('paymentRequired' in result) {
|
|
332
|
+
const requirements = result.paymentRequired;
|
|
333
|
+
return {
|
|
334
|
+
content: [
|
|
335
|
+
{
|
|
336
|
+
type: 'text',
|
|
337
|
+
text: JSON.stringify({ status: 'payment_required', requirements: serialize(requirements) }, null, 2),
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if ('ok' in result) {
|
|
343
|
+
return {
|
|
344
|
+
content: [
|
|
345
|
+
{ type: 'text', text: JSON.stringify({ status: 'ok', results: result.ok }) },
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
content: [
|
|
351
|
+
{
|
|
352
|
+
type: 'text',
|
|
353
|
+
text: JSON.stringify({ status: 'error', detail: serialize(result) }),
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Tool: request_session
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
server.tool('request_session', 'Request a session intent from a canister — returns pricing (suggestedDeposit, costPerCall) without opening a session.', {
|
|
362
|
+
canisterId: z
|
|
363
|
+
.string()
|
|
364
|
+
.optional()
|
|
365
|
+
.describe('Canister to query (defaults to configured canister)'),
|
|
366
|
+
}, async ({ canisterId }) => {
|
|
367
|
+
const cid = canisterId ?? defaultCanisterId;
|
|
368
|
+
if (!cid)
|
|
369
|
+
throw new Error('No canister ID.');
|
|
370
|
+
requireAgent();
|
|
371
|
+
const actor = actorFactory(cid);
|
|
372
|
+
const intent = await actor.requestSession();
|
|
373
|
+
return textResult(serialize(intent));
|
|
374
|
+
});
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Tool: open_session
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
server.tool('open_session', 'Open a streaming micropayment session. For ICP: uses ICRC-2 escrow. For EVM: pass evmTxHash proving the USDC deposit.', {
|
|
379
|
+
canisterId: z.string().optional().describe('Canister to open session on'),
|
|
380
|
+
maxDeposit: z
|
|
381
|
+
.string()
|
|
382
|
+
.optional()
|
|
383
|
+
.describe('Max deposit in token units (defaults to canister suggestion)'),
|
|
384
|
+
evmTxHash: z
|
|
385
|
+
.string()
|
|
386
|
+
.regex(/^0x[0-9a-fA-F]{64}$/, 'Must be a 0x-prefixed 32-byte hex hash')
|
|
387
|
+
.optional()
|
|
388
|
+
.describe('EVM tx hash proving USDC deposit (for EVM sessions)'),
|
|
389
|
+
evmNetwork: z
|
|
390
|
+
.string()
|
|
391
|
+
.regex(/^eip155:\d+$/, 'Must be CAIP-2 format: eip155:<chainId>')
|
|
392
|
+
.optional()
|
|
393
|
+
.describe('CAIP-2 network, e.g., "eip155:84532" (for EVM sessions)'),
|
|
394
|
+
evmSender: z
|
|
395
|
+
.string()
|
|
396
|
+
.regex(/^0x[0-9a-fA-F]{40}$/, 'Must be a 0x-prefixed 20-byte EVM address')
|
|
397
|
+
.optional()
|
|
398
|
+
.describe('Payer EVM address for refund (for EVM sessions)'),
|
|
399
|
+
evmToken: z
|
|
400
|
+
.string()
|
|
401
|
+
.regex(/^0x[0-9a-fA-F]{40}$/, 'Must be a 0x-prefixed 20-byte EVM address')
|
|
402
|
+
.optional()
|
|
403
|
+
.describe('ERC-20 token contract address (for EVM sessions)'),
|
|
404
|
+
evmRecipient: z
|
|
405
|
+
.string()
|
|
406
|
+
.regex(/^0x[0-9a-fA-F]{40}$/, 'Must be a 0x-prefixed 20-byte EVM address')
|
|
407
|
+
.optional()
|
|
408
|
+
.describe('Canister EVM address for settlement (for EVM sessions)'),
|
|
409
|
+
authorization: z
|
|
410
|
+
.object({
|
|
411
|
+
from: z.string(),
|
|
412
|
+
to: z.string(),
|
|
413
|
+
value: z.union([z.string(), z.number()]),
|
|
414
|
+
validAfter: z.union([z.string(), z.number()]),
|
|
415
|
+
validBefore: z.union([z.string(), z.number()]),
|
|
416
|
+
nonce: z.array(z.number()),
|
|
417
|
+
v: z.number(),
|
|
418
|
+
r: z.array(z.number()),
|
|
419
|
+
s: z.array(z.number()),
|
|
420
|
+
})
|
|
421
|
+
.optional()
|
|
422
|
+
.describe('EIP-3009 authorization for EVM session deposit'),
|
|
423
|
+
confirm: z
|
|
424
|
+
.boolean()
|
|
425
|
+
.default(false)
|
|
426
|
+
.describe('Authorize the escrow deposit. Returns the proposed deposit for review when false.'),
|
|
427
|
+
}, async ({ canisterId, maxDeposit, evmTxHash, evmNetwork, evmSender, evmToken, evmRecipient, authorization, confirm, }) => {
|
|
428
|
+
const c = requireClient();
|
|
429
|
+
const cid = canisterId ?? defaultCanisterId;
|
|
430
|
+
if (!cid)
|
|
431
|
+
throw new Error('No canister ID.');
|
|
432
|
+
// H13: opening a session escrows/deposits funds — cap + confirm it.
|
|
433
|
+
// Determine the deposit amount: explicit maxDeposit, else the canister's
|
|
434
|
+
// suggested deposit from the session intent.
|
|
435
|
+
let depositAtomic;
|
|
436
|
+
if (maxDeposit) {
|
|
437
|
+
depositAtomic = BigInt(maxDeposit);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
const intent = (await actorFactory(cid).requestSession());
|
|
441
|
+
const suggested = intent.suggestedDeposit ?? intent.maxDeposit ?? 0n;
|
|
442
|
+
depositAtomic = BigInt(String(suggested));
|
|
443
|
+
}
|
|
444
|
+
const gate = requireConfirmation({
|
|
445
|
+
action: 'open_session',
|
|
446
|
+
confirm,
|
|
447
|
+
amountAtomic: depositAtomic,
|
|
448
|
+
recipient: evmRecipient ?? cid,
|
|
449
|
+
asset: evmToken,
|
|
450
|
+
chain: evmNetwork,
|
|
451
|
+
note: 'Escrow deposit for a streaming micropayment session',
|
|
452
|
+
});
|
|
453
|
+
if (gate)
|
|
454
|
+
return gate;
|
|
455
|
+
const prefs = {};
|
|
456
|
+
if (maxDeposit)
|
|
457
|
+
prefs.maxDeposit = BigInt(maxDeposit);
|
|
458
|
+
if (evmTxHash)
|
|
459
|
+
prefs.evmTxHash = evmTxHash;
|
|
460
|
+
// If authorization is provided, this is an EVM session — set evmTxHash to trigger EVM path
|
|
461
|
+
if (authorization && !evmTxHash)
|
|
462
|
+
prefs.evmTxHash = 'eip3009-deposit';
|
|
463
|
+
if (evmNetwork)
|
|
464
|
+
prefs.evmNetwork = evmNetwork;
|
|
465
|
+
if (evmSender)
|
|
466
|
+
prefs.evmSender = evmSender;
|
|
467
|
+
if (evmToken)
|
|
468
|
+
prefs.evmToken = evmToken;
|
|
469
|
+
if (evmRecipient)
|
|
470
|
+
prefs.evmRecipient = evmRecipient;
|
|
471
|
+
if (authorization) {
|
|
472
|
+
// C5: normalize the EIP-3009 numeric fields to bigint, REJECTING JS numbers that
|
|
473
|
+
// would have already lost precision (a uint256 must be passed as a decimal string).
|
|
474
|
+
prefs.authorization = {
|
|
475
|
+
...authorization,
|
|
476
|
+
value: parseAtomicAmount(authorization.value, 'authorization.value'),
|
|
477
|
+
validAfter: parseAtomicAmount(authorization.validAfter, 'authorization.validAfter'),
|
|
478
|
+
validBefore: parseAtomicAmount(authorization.validBefore, 'authorization.validBefore'),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
// Generate Ed25519 keypair for voucher signing
|
|
482
|
+
const voucherIdentity = Ed25519KeyIdentity.generate();
|
|
483
|
+
const voucherSigner = {
|
|
484
|
+
async sign(payload) {
|
|
485
|
+
return new Uint8Array(await voucherIdentity.sign(payload));
|
|
486
|
+
},
|
|
487
|
+
async getPublicKey() {
|
|
488
|
+
return new Uint8Array(voucherIdentity.getPublicKey().toRaw());
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
// SEC-0: the deposit was reserved against the cumulative cap at confirm time
|
|
492
|
+
// (requireConfirmation); release the reservation if the on-chain/escrow deposit fails.
|
|
493
|
+
const session = await c
|
|
494
|
+
.openSession(prefs, voucherSigner, cid !== defaultCanisterId ? cid : undefined)
|
|
495
|
+
.catch((e) => {
|
|
496
|
+
refundSpend(depositAtomic);
|
|
497
|
+
throw e;
|
|
498
|
+
});
|
|
499
|
+
activeSessions.set(session.id, session);
|
|
500
|
+
return {
|
|
501
|
+
content: [
|
|
502
|
+
{
|
|
503
|
+
type: 'text',
|
|
504
|
+
text: JSON.stringify({
|
|
505
|
+
sessionId: session.id,
|
|
506
|
+
deposited: session.deposited.toString(),
|
|
507
|
+
remaining: session.remaining.toString(),
|
|
508
|
+
}, null, 2),
|
|
509
|
+
},
|
|
510
|
+
],
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// Tool: session_query
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
server.tool('session_query', 'Send a query through an open session (auto-signs a voucher). Each call consumes costPerCall from the deposit.', {
|
|
517
|
+
sessionId: z.string().describe('Session ID from open_session'),
|
|
518
|
+
question: z.string().describe('Question or query text'),
|
|
519
|
+
}, async ({ sessionId, question }) => {
|
|
520
|
+
const session = activeSessions.get(sessionId);
|
|
521
|
+
if (!session)
|
|
522
|
+
throw new Error(`No active session: ${sessionId}`);
|
|
523
|
+
const answer = await session.call('sessionQuery', [question]);
|
|
524
|
+
return {
|
|
525
|
+
content: [
|
|
526
|
+
{
|
|
527
|
+
type: 'text',
|
|
528
|
+
text: JSON.stringify({
|
|
529
|
+
answer,
|
|
530
|
+
consumed: session.consumed.toString(),
|
|
531
|
+
remaining: session.remaining.toString(),
|
|
532
|
+
}),
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
};
|
|
536
|
+
});
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// Tool: get_session
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
server.tool('get_session', 'Get the current state of an active session (consumed, remaining, voucher count).', {
|
|
541
|
+
sessionId: z.string().describe('Session ID'),
|
|
542
|
+
}, async ({ sessionId }) => {
|
|
543
|
+
const session = activeSessions.get(sessionId);
|
|
544
|
+
if (!session)
|
|
545
|
+
throw new Error(`No active session: ${sessionId}`);
|
|
546
|
+
return {
|
|
547
|
+
content: [
|
|
548
|
+
{
|
|
549
|
+
type: 'text',
|
|
550
|
+
text: JSON.stringify({
|
|
551
|
+
sessionId: session.id,
|
|
552
|
+
deposited: session.deposited.toString(),
|
|
553
|
+
consumed: session.consumed.toString(),
|
|
554
|
+
remaining: session.remaining.toString(),
|
|
555
|
+
}),
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
};
|
|
559
|
+
});
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// Tool: close_session
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
server.tool('close_session', 'Close a session — settles consumed amount on-chain and refunds the remainder. Returns a payment receipt. Settles value: pass confirm:true after reviewing the consumed amount.', {
|
|
564
|
+
sessionId: z.string().describe('Session ID to close'),
|
|
565
|
+
confirm: z
|
|
566
|
+
.boolean()
|
|
567
|
+
.default(false)
|
|
568
|
+
.describe('Authorize the on-chain settlement. Returns the consumed amount for review when false.'),
|
|
569
|
+
}, async ({ sessionId, confirm }) => {
|
|
570
|
+
const session = activeSessions.get(sessionId);
|
|
571
|
+
if (!session)
|
|
572
|
+
throw new Error(`No active session: ${sessionId}`);
|
|
573
|
+
// H13: settling/broadcasting moves value — confirm the consumed amount.
|
|
574
|
+
// The consumed amount was already counted against the cumulative cap at
|
|
575
|
+
// deposit time, so we do not re-charge spendGuard here (no amountAtomic).
|
|
576
|
+
if (!confirm) {
|
|
577
|
+
return textResult({
|
|
578
|
+
status: 'confirmation_required',
|
|
579
|
+
action: 'close_session',
|
|
580
|
+
proposal: {
|
|
581
|
+
sessionId: session.id,
|
|
582
|
+
consumed: session.consumed.toString(),
|
|
583
|
+
remainingToRefund: session.remaining.toString(),
|
|
584
|
+
note: 'Closing settles the consumed amount on-chain and refunds the remainder.',
|
|
585
|
+
},
|
|
586
|
+
instruction: 'Re-invoke "close_session" with confirm:true to settle.',
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
const receipt = await session.close();
|
|
590
|
+
activeSessions.delete(sessionId);
|
|
591
|
+
return textResult(serialize(receipt));
|
|
592
|
+
});
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
// Tool: list_sessions
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
server.tool('list_sessions', 'List all active sessions managed by this MCP server.', {}, async () => {
|
|
597
|
+
const sessions = Array.from(activeSessions.entries()).map(([id, s]) => ({
|
|
598
|
+
sessionId: id,
|
|
599
|
+
deposited: s.deposited.toString(),
|
|
600
|
+
consumed: s.consumed.toString(),
|
|
601
|
+
remaining: s.remaining.toString(),
|
|
602
|
+
}));
|
|
603
|
+
return textResult(sessions);
|
|
604
|
+
});
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
// Tool: fetch_content
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
server.tool('fetch_content', 'Fetch content from a ContentDelivery response. Supports inline, httpUrl, assetCanister, and canisterQuery delivery methods.', {
|
|
609
|
+
delivery: z.string().describe('ContentDelivery JSON string (as returned by content endpoints)'),
|
|
610
|
+
canisterId: z
|
|
611
|
+
.string()
|
|
612
|
+
.optional()
|
|
613
|
+
.describe('Canister ID for canisterQuery delivery (defaults to configured canister)'),
|
|
614
|
+
}, async ({ delivery: deliveryJson, canisterId }) => {
|
|
615
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
616
|
+
let parsed;
|
|
617
|
+
try {
|
|
618
|
+
parsed = JSON.parse(deliveryJson);
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
throw new Error('Invalid JSON in delivery parameter');
|
|
622
|
+
}
|
|
623
|
+
const grant = parsed.grant;
|
|
624
|
+
const del = parsed.delivery;
|
|
625
|
+
let resultText;
|
|
626
|
+
if ('inline' in del) {
|
|
627
|
+
const buf = typeof del.inline === 'string' ? Buffer.from(del.inline, 'hex') : Buffer.from(del.inline);
|
|
628
|
+
resultText = buf.toString('utf-8');
|
|
629
|
+
}
|
|
630
|
+
else if ('httpUrl' in del) {
|
|
631
|
+
// H11/S2: SSRF guard — httpUrl comes from an untrusted ContentDelivery payload.
|
|
632
|
+
// safeFetch validates the URL and re-validates every redirect hop.
|
|
633
|
+
const resp = await safeFetch(String(del.httpUrl), undefined, ssrfOpts());
|
|
634
|
+
if (!resp.ok)
|
|
635
|
+
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
|
|
636
|
+
resultText = await resp.text();
|
|
637
|
+
}
|
|
638
|
+
else if ('assetCanister' in del) {
|
|
639
|
+
// H11: validate the canister id and build the URL via the URL API (no
|
|
640
|
+
// string concatenation of untrusted fields into host/path).
|
|
641
|
+
const assetId = validateCanisterId(String(del.assetCanister.canisterId));
|
|
642
|
+
const url = new URL(`https://${assetId}.icp0.io`);
|
|
643
|
+
// Treat the supplied path as a path only; URL() resolves/normalizes it
|
|
644
|
+
// and cannot change the origin we constructed above.
|
|
645
|
+
url.pathname = String(del.assetCanister.path ?? '/');
|
|
646
|
+
const resp = await safeFetch(url.toString(), undefined, ssrfOpts());
|
|
647
|
+
if (!resp.ok)
|
|
648
|
+
throw new Error(`Asset fetch ${resp.status}: ${resp.statusText}`);
|
|
649
|
+
resultText = await resp.text();
|
|
650
|
+
}
|
|
651
|
+
else if ('canisterQuery' in del) {
|
|
652
|
+
const cid = validateCanisterId(canisterId ?? defaultCanisterId ?? '');
|
|
653
|
+
requireAgent();
|
|
654
|
+
const actor = actorFactory(cid);
|
|
655
|
+
const { method, chunkCount } = del.canisterQuery;
|
|
656
|
+
// H11: restrict to a fixed allowlist of content-chunk query methods.
|
|
657
|
+
const CONTENT_QUERY_METHODS = new Set(['getChunk', 'getContent']);
|
|
658
|
+
if (!CONTENT_QUERY_METHODS.has(String(method))) {
|
|
659
|
+
throw new Error(`Disallowed canisterQuery method "${method}". Allowed: ${[...CONTENT_QUERY_METHODS].join(', ')}.`);
|
|
660
|
+
}
|
|
661
|
+
// M17: `chunkCount` comes from the caller-supplied (attacker-influenceable) delivery JSON.
|
|
662
|
+
// Cap it before looping — an unbounded value would spin ~N sequential canister queries and
|
|
663
|
+
// grow an unbounded buffer, hanging/OOM-ing the MCP process. 10_000 chunks is far above any
|
|
664
|
+
// real content (a chunk is ~1-2 MB) yet bounds the work.
|
|
665
|
+
const MAX_FETCH_CHUNKS = 10_000;
|
|
666
|
+
const count = Number(chunkCount);
|
|
667
|
+
if (!Number.isFinite(count) || count < 0) {
|
|
668
|
+
throw new Error(`Invalid chunkCount in delivery: ${String(chunkCount)}`);
|
|
669
|
+
}
|
|
670
|
+
if (count > MAX_FETCH_CHUNKS) {
|
|
671
|
+
throw new Error(`chunkCount ${count} exceeds the ${MAX_FETCH_CHUNKS}-chunk fetch limit.`);
|
|
672
|
+
}
|
|
673
|
+
const chunks = [];
|
|
674
|
+
for (let i = 0; i < count; i++) {
|
|
675
|
+
const raw = await actor[method](grant, i);
|
|
676
|
+
// H6: getChunk/getContent are declared `opt blob`, so agent-js decodes them as
|
|
677
|
+
// [] (None) | [Uint8Array] (Some). Buffer.from([Uint8Array]) treats the opt array as
|
|
678
|
+
// array-like-of-numbers → the element coerces to NaN → a 1-byte [0] per chunk, silently
|
|
679
|
+
// returning garbage for paid content. Unwrap the opt, then normalize the blob to bytes.
|
|
680
|
+
const some = Array.isArray(raw) ? raw[0] : raw;
|
|
681
|
+
if (some === undefined || some === null) {
|
|
682
|
+
throw new Error(`Content chunk ${i} unavailable (grant expired or index out of range)`);
|
|
683
|
+
}
|
|
684
|
+
chunks.push(Buffer.from(some instanceof Uint8Array ? some : some));
|
|
685
|
+
}
|
|
686
|
+
// Decode the concatenated bytes once so a multi-byte char split across a chunk boundary
|
|
687
|
+
// is not corrupted by per-chunk utf-8 decoding.
|
|
688
|
+
resultText = Buffer.concat(chunks).toString('utf-8');
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
throw new Error('Unknown delivery method in ContentDelivery');
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
content: [
|
|
695
|
+
{
|
|
696
|
+
type: 'text',
|
|
697
|
+
text: JSON.stringify({
|
|
698
|
+
contentId: grant?.contentRef?.id,
|
|
699
|
+
mimeType: grant?.contentRef?.mimeType,
|
|
700
|
+
content: resultText,
|
|
701
|
+
}, null, 2),
|
|
702
|
+
},
|
|
703
|
+
],
|
|
704
|
+
};
|
|
705
|
+
});
|
|
706
|
+
/** Serialize an Ic402Error (or any error) into a structured result the demo can render. */
|
|
707
|
+
function errorResult(e) {
|
|
708
|
+
if (e instanceof Ic402Error) {
|
|
709
|
+
return {
|
|
710
|
+
content: [
|
|
711
|
+
{
|
|
712
|
+
type: 'text',
|
|
713
|
+
text: JSON.stringify({
|
|
714
|
+
status: 'error',
|
|
715
|
+
error: { kind: e.kind, message: e.message, retryable: e.retryable },
|
|
716
|
+
}, null, 2),
|
|
717
|
+
},
|
|
718
|
+
],
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
722
|
+
return {
|
|
723
|
+
content: [
|
|
724
|
+
{
|
|
725
|
+
type: 'text',
|
|
726
|
+
text: JSON.stringify({
|
|
727
|
+
status: 'error',
|
|
728
|
+
error: { kind: 'unknown', message: msg, retryable: false },
|
|
729
|
+
}, null, 2),
|
|
730
|
+
},
|
|
731
|
+
],
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
// Tool: fetch_x402
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
server.tool('fetch_x402', 'Fetch from an x402-gated URL. Full flow: probe URL → canister signs payment → retry with payment header. Signing moves value: pass confirm:true to authorize after reviewing the proposed amount/recipient.', {
|
|
738
|
+
url: z.string().describe('The x402-gated URL to fetch'),
|
|
739
|
+
chainId: z.number().default(84532).describe('EVM chain ID (default: Base Sepolia 84532)'),
|
|
740
|
+
canisterId: z.string().optional().describe('Canister to sign with (defaults to configured)'),
|
|
741
|
+
confirm: z
|
|
742
|
+
.boolean()
|
|
743
|
+
.default(false)
|
|
744
|
+
.describe('Authorize signing the proposed payment. Probe-only (no signing) when false.'),
|
|
745
|
+
}, async ({ url, chainId, canisterId, confirm }) => {
|
|
746
|
+
requireClient();
|
|
747
|
+
requireAgent();
|
|
748
|
+
const cid = canisterId ?? defaultCanisterId;
|
|
749
|
+
if (!cid)
|
|
750
|
+
throw new Error('No canister ID.');
|
|
751
|
+
// C2: SSRF guard — the URL is attacker-influenceable. Validate before any fetch.
|
|
752
|
+
let target;
|
|
753
|
+
try {
|
|
754
|
+
target = validateFetchUrl(url, ssrfOpts());
|
|
755
|
+
// SEC-0 (round 2): the probe leg below (probeX402) does NOT route through safeFetch, so its
|
|
756
|
+
// only SSRF defence is validateFetchUrl (literal host). Add the DNS-resolution guard here so a
|
|
757
|
+
// public name that resolves to an internal/metadata IP is rejected before the probe ever fetches.
|
|
758
|
+
await assertResolvedHostIsPublic(target.hostname, ssrfOpts());
|
|
759
|
+
}
|
|
760
|
+
catch (e) {
|
|
761
|
+
return errorResult(e);
|
|
762
|
+
}
|
|
763
|
+
const safeUrl = target.toString();
|
|
764
|
+
try {
|
|
765
|
+
// 1. Probe (client-side HTTP, with 15s timeout)
|
|
766
|
+
const controller = new AbortController();
|
|
767
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
768
|
+
let probeResult;
|
|
769
|
+
try {
|
|
770
|
+
probeResult = await probeX402(safeUrl, chainId, { signal: controller.signal }, {
|
|
771
|
+
// S2: re-validate every redirect hop so an allowlisted origin cannot 30x us to
|
|
772
|
+
// an internal/metadata target during the x402 probe.
|
|
773
|
+
validateRedirect: (u) => {
|
|
774
|
+
validateFetchUrl(u, ssrfOpts());
|
|
775
|
+
},
|
|
776
|
+
maxRedirects: 5,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
finally {
|
|
780
|
+
clearTimeout(timeout);
|
|
781
|
+
}
|
|
782
|
+
if (probeResult.status === 'free') {
|
|
783
|
+
return {
|
|
784
|
+
content: [
|
|
785
|
+
{ type: 'text', text: JSON.stringify(serialize(probeResult), null, 2) },
|
|
786
|
+
],
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
if (probeResult.status === 'error') {
|
|
790
|
+
return {
|
|
791
|
+
content: [
|
|
792
|
+
{ type: 'text', text: JSON.stringify(serialize(probeResult), null, 2) },
|
|
793
|
+
],
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
const opt = probeResult.paymentOption;
|
|
797
|
+
const optionChainId = parseInt(opt.network.replace('eip155:', ''), 10) || chainId;
|
|
798
|
+
// C2 + H13: cap + confirmation gate BEFORE signing. The canister signs
|
|
799
|
+
// from its own EVM address, so an attacker-chosen recipient/amount must
|
|
800
|
+
// be capped and explicitly confirmed by the human.
|
|
801
|
+
const gate = requireConfirmation({
|
|
802
|
+
action: 'fetch_x402',
|
|
803
|
+
confirm,
|
|
804
|
+
amountAtomic: opt.amount,
|
|
805
|
+
recipient: opt.recipient,
|
|
806
|
+
asset: opt.asset,
|
|
807
|
+
chain: optionChainId,
|
|
808
|
+
note: `Signing an x402 payment for ${safeUrl}`,
|
|
809
|
+
});
|
|
810
|
+
if (gate)
|
|
811
|
+
return gate;
|
|
812
|
+
// 2. Sign via canister (direct actor call)
|
|
813
|
+
const actor = actorFactory(cid);
|
|
814
|
+
const signResult = (await actor.signX402Payment(optionChainId, opt.asset, opt.recipient, opt.amount, opt.tokenName, opt.tokenVersion));
|
|
815
|
+
if (!signResult || 'err' in signResult) {
|
|
816
|
+
// SEC-0: signing failed after the reservation — release it.
|
|
817
|
+
refundSpend(opt.amount);
|
|
818
|
+
return errorResult(new Ic402Error('sign_failed', String(signResult?.err ?? 'Signing failed')));
|
|
819
|
+
}
|
|
820
|
+
const signed = signResult.ok;
|
|
821
|
+
// Echo the external server's advertised requirement VERBATIM as the v2 `accepted` (the
|
|
822
|
+
// canister reconstructs it; this makes a strict facilitator's accepted check pass). The
|
|
823
|
+
// `accepted` is not EIP-712-signed, so rewriting it is safe.
|
|
824
|
+
const headerToSend = applyVerbatimAccepted(signed.header, opt.rawRequirement);
|
|
825
|
+
// SEC-0: the spend was reserved against the cumulative cap at confirm time (the reservation
|
|
826
|
+
// is kept now that signing succeeded — a later retry failure does not un-count the payment,
|
|
827
|
+
// matching the prior commit-after-sign semantics).
|
|
828
|
+
// 3. Retry with payment header (client-side HTTP, 15s timeout)
|
|
829
|
+
const retryController = new AbortController();
|
|
830
|
+
const retryTimeout = setTimeout(() => retryController.abort(), 15_000);
|
|
831
|
+
let paidResponse;
|
|
832
|
+
try {
|
|
833
|
+
paidResponse = await safeFetch(safeUrl, {
|
|
834
|
+
headers: { 'X-Payment': headerToSend, 'Payment-Signature': headerToSend },
|
|
835
|
+
signal: retryController.signal,
|
|
836
|
+
}, ssrfOpts());
|
|
837
|
+
}
|
|
838
|
+
finally {
|
|
839
|
+
clearTimeout(retryTimeout);
|
|
840
|
+
}
|
|
841
|
+
const body = await paidResponse.text();
|
|
842
|
+
if (paidResponse.ok) {
|
|
843
|
+
return {
|
|
844
|
+
content: [
|
|
845
|
+
{
|
|
846
|
+
type: 'text',
|
|
847
|
+
text: JSON.stringify({
|
|
848
|
+
status: 'ok',
|
|
849
|
+
code: paidResponse.status,
|
|
850
|
+
body,
|
|
851
|
+
paidAmount: serialize(signed.paidAmount),
|
|
852
|
+
}, null, 2),
|
|
853
|
+
},
|
|
854
|
+
],
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
if (paidResponse.status === 402) {
|
|
858
|
+
return errorResult(new Ic402Error('settlement_failed', body.slice(0, 200)));
|
|
859
|
+
}
|
|
860
|
+
return errorResult(new Ic402Error('http_error', `HTTP ${paidResponse.status}: ${body.slice(0, 200)}`));
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
return errorResult(e);
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
// Tool: register_agent
|
|
868
|
+
// ---------------------------------------------------------------------------
|
|
869
|
+
server.tool('register_agent', 'Register the canister as an ERC-8004 agent on-chain. Full flow: get nonce+gas → canister signs → broadcast → poll receipt. Broadcasts a signed tx (spends gas): pass confirm:true to authorize.', {
|
|
870
|
+
chainId: z.number().default(84532).describe('EVM chain ID (default: Base Sepolia 84532)'),
|
|
871
|
+
canisterId: z.string().optional().describe('Canister to register (defaults to configured)'),
|
|
872
|
+
rpcUrl: z.string().optional().describe('Custom EVM RPC URL (defaults to public RPC)'),
|
|
873
|
+
confirm: z
|
|
874
|
+
.boolean()
|
|
875
|
+
.default(false)
|
|
876
|
+
.describe('Authorize signing and broadcasting the registration tx (spends gas).'),
|
|
877
|
+
}, async ({ chainId, canisterId, rpcUrl, confirm }) => {
|
|
878
|
+
const c = requireClient();
|
|
879
|
+
const cid = canisterId ?? defaultCanisterId;
|
|
880
|
+
if (!cid)
|
|
881
|
+
throw new Error('No canister ID.');
|
|
882
|
+
// H13: register_agent signs + broadcasts a tx from the canister's EVM
|
|
883
|
+
// address (spends native gas). Validate any custom RPC URL against SSRF
|
|
884
|
+
// and require explicit confirmation. No USDC amount, so no spend cap.
|
|
885
|
+
if (rpcUrl !== undefined) {
|
|
886
|
+
try {
|
|
887
|
+
validateFetchUrl(rpcUrl, ssrfOpts());
|
|
888
|
+
// SEC-0 (round 2): the rpcUrl is handed to viem's http() transport (raw fetch, no safeFetch),
|
|
889
|
+
// so add the DNS-resolution guard here — otherwise a public name resolving to an internal IP
|
|
890
|
+
// reaches the internal target via the JSON-RPC POSTs. (Residual: the connect-time re-resolve
|
|
891
|
+
// window remains until the transport pins the validated IP — tracked in security-model.md.)
|
|
892
|
+
await assertResolvedHostIsPublic(new URL(rpcUrl).hostname, ssrfOpts());
|
|
893
|
+
}
|
|
894
|
+
catch (e) {
|
|
895
|
+
return errorResult(e);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
const gate = requireConfirmation({
|
|
899
|
+
action: 'register_agent',
|
|
900
|
+
confirm,
|
|
901
|
+
chain: chainId,
|
|
902
|
+
note: 'Signs and broadcasts an ERC-8004 registration tx (spends native gas).',
|
|
903
|
+
});
|
|
904
|
+
if (gate)
|
|
905
|
+
return gate;
|
|
906
|
+
try {
|
|
907
|
+
const result = await c.registerAgent(rpcUrl, chainId);
|
|
908
|
+
return {
|
|
909
|
+
content: [
|
|
910
|
+
{
|
|
911
|
+
type: 'text',
|
|
912
|
+
text: JSON.stringify({
|
|
913
|
+
status: 'ok',
|
|
914
|
+
tokenId: result.tokenId?.toString() ?? null,
|
|
915
|
+
txHash: result.txHash,
|
|
916
|
+
}, null, 2),
|
|
917
|
+
},
|
|
918
|
+
],
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
catch (e) {
|
|
922
|
+
return errorResult(e);
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
// Tool: list_services
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
server.tool('list_services', 'List available paid services from the canister.', {}, async () => {
|
|
929
|
+
const c = requireClient();
|
|
930
|
+
const services = await c.listServices();
|
|
931
|
+
return textResult(serialize(services));
|
|
932
|
+
});
|
|
933
|
+
// ---------------------------------------------------------------------------
|
|
934
|
+
// Tool: submit_request
|
|
935
|
+
// ---------------------------------------------------------------------------
|
|
936
|
+
server.tool('submit_request', 'Submit a paid service request. If the service demands payment, you must opt into autoPayment (via "configure") and pass confirm:true after reviewing the amount/recipient. Returns a job ID for polling.', {
|
|
937
|
+
serviceId: z.string().describe('Service ID to request'),
|
|
938
|
+
params: z.string().default('').describe('Job parameters (UTF-8 string, sent as bytes)'),
|
|
939
|
+
confirm: z
|
|
940
|
+
.boolean()
|
|
941
|
+
.default(false)
|
|
942
|
+
.describe('Authorize auto-payment of the amount the canister demands.'),
|
|
943
|
+
}, async ({ serviceId, params, confirm }) => {
|
|
944
|
+
const c = requireClient();
|
|
945
|
+
const cid = defaultCanisterId;
|
|
946
|
+
if (!cid)
|
|
947
|
+
throw new Error('No canister ID.');
|
|
948
|
+
const encoded = new TextEncoder().encode(params);
|
|
949
|
+
// SEC-0 (round 2): tracks the amount reserved at confirm time so a failed submit (where NO
|
|
950
|
+
// value settled — approve error / error-variant / transient throw) RELEASES the reservation,
|
|
951
|
+
// instead of permanently burning cap headroom toward a self-inflicted spend-cap DoS. Stays 0
|
|
952
|
+
// (no refund) for any throw before the gate passed, so a pre-reservation error never over-refunds.
|
|
953
|
+
let reservedAmount = 0n;
|
|
954
|
+
try {
|
|
955
|
+
// C4: discover the price via a READ-ONLY query (no nonce minted), instead of the prior
|
|
956
|
+
// state-changing submitServiceRequest "dry-run" probe. The client's submitServiceRequest
|
|
957
|
+
// still does the single probe+pay; this removes the redundant extra update call.
|
|
958
|
+
const actor = actorFactory(cid);
|
|
959
|
+
const quote = (await actor.quoteServiceRequest(serviceId));
|
|
960
|
+
if (quote && typeof quote === 'object' && 'err' in quote) {
|
|
961
|
+
return errorResult(new Ic402Error('unknown', String(quote.err)));
|
|
962
|
+
}
|
|
963
|
+
const q = quote.ok;
|
|
964
|
+
if (!q) {
|
|
965
|
+
return errorResult(new Ic402Error('unknown', `Unexpected quote: ${JSON.stringify(serialize(quote))}`));
|
|
966
|
+
}
|
|
967
|
+
if (!q.enabled) {
|
|
968
|
+
return errorResult(new Ic402Error('unknown', `Service "${serviceId}" is disabled`));
|
|
969
|
+
}
|
|
970
|
+
// A1: the buyer actually pays price + ledger fee. Cap-check and commit against the TOTAL
|
|
971
|
+
// (what really moves), not the bare service price, so the spend guard isn't under-counting.
|
|
972
|
+
const price = BigInt(String(q.amount));
|
|
973
|
+
const fee = BigInt(String(q.fee ?? 0n));
|
|
974
|
+
const amountAtomic = BigInt(String(q.total ?? price + fee));
|
|
975
|
+
// Free / session-billed services quote total 0 — submit directly (no payment).
|
|
976
|
+
if (amountAtomic === 0n) {
|
|
977
|
+
const result = await c.submitServiceRequest(serviceId, encoded);
|
|
978
|
+
return textResult({ status: 'ok', jobId: result.jobId });
|
|
979
|
+
}
|
|
980
|
+
// Paid: H12 auto-payment must be opt-in.
|
|
981
|
+
if (!securityConfig.autoPayment) {
|
|
982
|
+
return textResult({
|
|
983
|
+
status: 'payment_required',
|
|
984
|
+
serviceId,
|
|
985
|
+
amount: amountAtomic.toString(),
|
|
986
|
+
price: price.toString(),
|
|
987
|
+
fee: fee.toString(),
|
|
988
|
+
instruction: 'This service requires payment (price + ledger fee). Auto-payment is disabled. Re-run "configure" with autoPayment:true to enable it, then re-invoke with confirm:true.',
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
// H12 + H13: cap + confirmation before any auto-approval/payment.
|
|
992
|
+
const gate = requireConfirmation({
|
|
993
|
+
action: 'submit_request',
|
|
994
|
+
confirm,
|
|
995
|
+
amountAtomic,
|
|
996
|
+
recipient: cid,
|
|
997
|
+
note: `Auto-pay ${amountAtomic} (price ${price} + ledger fee ${fee}) for service "${serviceId}" on canister ${cid}`,
|
|
998
|
+
});
|
|
999
|
+
if (gate)
|
|
1000
|
+
return gate;
|
|
1001
|
+
// Gate passed → the amount is now reserved against the cumulative cap; record it so the catch
|
|
1002
|
+
// can release it on a no-money-moved failure (and ONLY then — pre-gate throws leave it 0).
|
|
1003
|
+
reservedAmount = amountAtomic;
|
|
1004
|
+
// Confirmed and within caps — the client performs the single probe + approve + pay.
|
|
1005
|
+
const result = await c.submitServiceRequest(serviceId, encoded);
|
|
1006
|
+
return textResult({
|
|
1007
|
+
status: 'ok',
|
|
1008
|
+
jobId: result.jobId,
|
|
1009
|
+
paidAmount: amountAtomic.toString(),
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
catch (e) {
|
|
1013
|
+
// SEC-0 (round 2): submitServiceRequest threw without settling (approve error, error-variant,
|
|
1014
|
+
// transient) — release the confirm-time reservation so repeated failed submits don't drain the
|
|
1015
|
+
// cumulative cap. reservedAmount is 0 for any throw before the gate passed, so this is safe.
|
|
1016
|
+
if (reservedAmount > 0n)
|
|
1017
|
+
refundSpend(reservedAmount);
|
|
1018
|
+
return errorResult(e);
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
// ---------------------------------------------------------------------------
|
|
1022
|
+
// Tool: get_job_result
|
|
1023
|
+
// ---------------------------------------------------------------------------
|
|
1024
|
+
server.tool('get_job_result', 'Poll for a job result. Waits until the job completes or times out.', {
|
|
1025
|
+
jobId: z.string().describe('Job ID from submit_request'),
|
|
1026
|
+
maxAttempts: z.number().default(15).describe('Max poll attempts'),
|
|
1027
|
+
}, async ({ jobId, maxAttempts }) => {
|
|
1028
|
+
const c = requireClient();
|
|
1029
|
+
try {
|
|
1030
|
+
const job = await c.pollJobResult(jobId, maxAttempts);
|
|
1031
|
+
return textResult(serialize(job));
|
|
1032
|
+
}
|
|
1033
|
+
catch (e) {
|
|
1034
|
+
return errorResult(e);
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
// ---------------------------------------------------------------------------
|
|
1038
|
+
// Tool: dispute_job
|
|
1039
|
+
// ---------------------------------------------------------------------------
|
|
1040
|
+
server.tool('dispute_job', 'Dispute a job result (for BuyerConfirm verification services).', {
|
|
1041
|
+
jobId: z.string().describe('Job ID to dispute'),
|
|
1042
|
+
reason: z.string().describe('Reason for dispute'),
|
|
1043
|
+
}, async ({ jobId, reason }) => {
|
|
1044
|
+
const c = requireClient();
|
|
1045
|
+
try {
|
|
1046
|
+
await c.disputeJob(jobId, reason);
|
|
1047
|
+
return textResult({ status: 'ok' });
|
|
1048
|
+
}
|
|
1049
|
+
catch (e) {
|
|
1050
|
+
return errorResult(e);
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
// ---------------------------------------------------------------------------
|
|
1054
|
+
// Tool: call
|
|
1055
|
+
// ---------------------------------------------------------------------------
|
|
1056
|
+
// C3: explicit allowlist of READ-ONLY / query methods callable via the generic
|
|
1057
|
+
// `call` tool. Every entry here is a `query` (or otherwise non-state-changing
|
|
1058
|
+
// read) in the IDL. Anything not listed is rejected; the LLM must use the
|
|
1059
|
+
// dedicated, capped, confirmation-gated tool for state-changing/signing calls.
|
|
1060
|
+
// isCallMethodAllowed + the READONLY_CALL_ALLOWLIST / CALL_BLOCK_SUBSTRINGS tables live in guards.ts (testable).
|
|
1061
|
+
server.tool('call', 'Call a READ-ONLY/query method on the configured canister (allowlisted getters only). State-changing, signing, payment, and admin methods are blocked here — use their dedicated tools.', {
|
|
1062
|
+
method: z.string().describe('Canister query/read method name (allowlisted getters only)'),
|
|
1063
|
+
args: z.string().default('[]').describe('JSON array of arguments'),
|
|
1064
|
+
canisterId: z
|
|
1065
|
+
.string()
|
|
1066
|
+
.optional()
|
|
1067
|
+
.describe('Canister to call (defaults to configured canister)'),
|
|
1068
|
+
}, async ({ method, args, canisterId }) => {
|
|
1069
|
+
const cid = canisterId ?? defaultCanisterId;
|
|
1070
|
+
if (!cid)
|
|
1071
|
+
throw new Error('No canister ID.');
|
|
1072
|
+
requireAgent();
|
|
1073
|
+
// C3: restrict the generic call path to read-only / query methods.
|
|
1074
|
+
const verdict = isCallMethodAllowed(method);
|
|
1075
|
+
if (!verdict.ok) {
|
|
1076
|
+
throw new Error(verdict.reason);
|
|
1077
|
+
}
|
|
1078
|
+
const actor = actorFactory(cid);
|
|
1079
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1080
|
+
let parsedArgs;
|
|
1081
|
+
try {
|
|
1082
|
+
parsedArgs = JSON.parse(args);
|
|
1083
|
+
}
|
|
1084
|
+
catch {
|
|
1085
|
+
throw new Error('Invalid JSON in args parameter');
|
|
1086
|
+
}
|
|
1087
|
+
if (typeof actor[method] !== 'function') {
|
|
1088
|
+
throw new Error(`Unknown method "${method}" on canister ${cid}.`);
|
|
1089
|
+
}
|
|
1090
|
+
const result = await actor[method](...(Array.isArray(parsedArgs) ? parsedArgs : [parsedArgs]));
|
|
1091
|
+
return textResult(serialize(result));
|
|
1092
|
+
});
|
|
1093
|
+
// ---------------------------------------------------------------------------
|
|
1094
|
+
// Dedicated admin / signing tools
|
|
1095
|
+
//
|
|
1096
|
+
// These expose specific controller-gated canister methods as named tools with
|
|
1097
|
+
// explicit confirmation. Unlike the generic `call` tool (read-only allowlist),
|
|
1098
|
+
// the method per tool is FIXED — an LLM cannot pick an arbitrary method — and
|
|
1099
|
+
// every state-changing/signing action requires confirm:true.
|
|
1100
|
+
// ---------------------------------------------------------------------------
|
|
1101
|
+
async function invokeCanisterMethod(cid, method, argsJson) {
|
|
1102
|
+
const actor = actorFactory(cid);
|
|
1103
|
+
let parsed;
|
|
1104
|
+
try {
|
|
1105
|
+
parsed = JSON.parse(argsJson);
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
throw new Error('Invalid JSON in args parameter');
|
|
1109
|
+
}
|
|
1110
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1111
|
+
const fn = actor[method];
|
|
1112
|
+
if (typeof fn !== 'function') {
|
|
1113
|
+
throw new Error(`Unknown method "${method}" on canister ${cid}.`);
|
|
1114
|
+
}
|
|
1115
|
+
return await fn(...(Array.isArray(parsed) ? parsed : [parsed]));
|
|
1116
|
+
}
|
|
1117
|
+
function adminTool(toolName, canisterMethod, description, argsHint, note) {
|
|
1118
|
+
server.tool(toolName, description, {
|
|
1119
|
+
args: z.string().describe(argsHint),
|
|
1120
|
+
canisterId: z.string().optional().describe('Canister to call (defaults to configured)'),
|
|
1121
|
+
confirm: z.boolean().default(false).describe('Authorize this state-changing/signing action.'),
|
|
1122
|
+
}, async ({ args, canisterId, confirm }) => {
|
|
1123
|
+
// S1/S9 + SEC-3: refuse dangerous primitives (raw EIP-712 signing oracle, destructive
|
|
1124
|
+
// delete) AND state-changing admin tools (register/enable service, claim/submit job,
|
|
1125
|
+
// upload content) unless the operator enabled them at startup — they bypass/ignore the
|
|
1126
|
+
// spend caps or mutate the canister, so a prompt-injected LLM must not be able to reach
|
|
1127
|
+
// them via an in-band confirm alone.
|
|
1128
|
+
if (!isToolAllowed(toolName, allowDangerousTools, allowAdminTools)) {
|
|
1129
|
+
return errorResult(new Error(`Tool "${toolName}" is disabled by default: it is a dangerous primitive (raw EIP-712 ` +
|
|
1130
|
+
`signing / destructive deletion) or a state-changing admin tool. An operator must ` +
|
|
1131
|
+
`enable it explicitly at startup — IC402_MCP_ALLOW_DANGEROUS_TOOLS=1 (signing/delete) ` +
|
|
1132
|
+
`or IC402_MCP_ALLOW_ADMIN_TOOLS=1 (service/job/upload admin).`));
|
|
1133
|
+
}
|
|
1134
|
+
const cid = canisterId ?? defaultCanisterId;
|
|
1135
|
+
if (!cid)
|
|
1136
|
+
throw new Error('No canister ID.');
|
|
1137
|
+
requireAgent();
|
|
1138
|
+
const gate = requireConfirmation({ action: toolName, confirm, note });
|
|
1139
|
+
if (gate)
|
|
1140
|
+
return gate;
|
|
1141
|
+
try {
|
|
1142
|
+
return textResult(serialize(await invokeCanisterMethod(cid, canisterMethod, args)));
|
|
1143
|
+
}
|
|
1144
|
+
catch (e) {
|
|
1145
|
+
return errorResult(e);
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
adminTool('upload_content', 'uploadContent', 'Upload + encrypt content into the canister ContentStore (controller-gated, state-changing).', 'JSON array: [id, mimeType, dataBytes] where dataBytes is a number[] of the raw bytes.', 'Uploads and encrypts content into the canister (controller-gated).');
|
|
1150
|
+
adminTool('delete_content', 'deleteContent', 'Delete a content entry from the canister ContentStore (controller-gated, destructive).', 'JSON array: [id].', 'Permanently deletes content from the canister (controller-gated, destructive).');
|
|
1151
|
+
adminTool('register_service', 'registerService', 'Register a paid service in the marketplace (controller-gated, state-changing).', 'JSON array of registerService args: [name, description, serviceType, pricing, verificationMethod, verifierCanisterId?, verificationKey?, delivery, timeout].', 'Registers a marketplace service on the canister (controller-gated).');
|
|
1152
|
+
adminTool('enable_service', 'enableService', 'Enable a registered service so it can accept paid requests (controller-gated).', 'JSON array: [serviceId].', 'Enables a marketplace service on the canister (controller-gated).');
|
|
1153
|
+
adminTool('claim_job', 'claimJob', 'Operator claims a pending marketplace job (state-changing).', 'JSON array: [jobId].', 'Claims a marketplace job for the operator.');
|
|
1154
|
+
adminTool('submit_job_result', 'submitJobResult', 'Operator submits a job result (and optional proof) for verification + settlement (state-changing).', 'JSON array: [jobId, resultBytes, proof?, actualCost?].', 'Submits a job result to the canister (triggers verification + settlement).');
|
|
1155
|
+
adminTool('sign_typed_data', 'signTypedData', 'Sign arbitrary EIP-712 typed data with the canister tECDSA key. SENSITIVE — this is a generic signing primitive; only sign digests you constructed and trust.', 'JSON array: [domainSeparatorBytes, structHashBytes] — two 32-byte number[] arrays.', 'Signs an EIP-712 digest with the canister key (a generic signature primitive — forgeable use is dangerous).');
|
|
1156
|
+
// ---------------------------------------------------------------------------
|
|
1157
|
+
// Start
|
|
1158
|
+
// ---------------------------------------------------------------------------
|
|
1159
|
+
function cliFlag(name) {
|
|
1160
|
+
const i = process.argv.indexOf(name);
|
|
1161
|
+
return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : undefined;
|
|
1162
|
+
}
|
|
1163
|
+
/** Print the effective security posture to STDERR (stdout is the MCP JSON-RPC channel and
|
|
1164
|
+
* must not be polluted). Lets the operator verify what's active before the agent connects. */
|
|
1165
|
+
function printSecurityBanner(cfg, source) {
|
|
1166
|
+
const L = (s) => console.error(s);
|
|
1167
|
+
L('───────────────────────────────────────────────────────────────');
|
|
1168
|
+
L(' ic402 MCP — effective security config (operator-set, out-of-band)');
|
|
1169
|
+
L(` source : ${source ? `config file: ${source}` : 'env vars / defaults'}`);
|
|
1170
|
+
L(` perCallMaxAtomic : ${cfg.security.perCallMaxAtomic}`);
|
|
1171
|
+
L(` sessionMaxAtomic : ${cfg.security.sessionMaxAtomic}`);
|
|
1172
|
+
L(` localDev : ${cfg.security.localDev}`);
|
|
1173
|
+
L(` autoPayment : ${cfg.security.autoPayment}`);
|
|
1174
|
+
L(` allowSecurityChanges : ${cfg.allowSecurityChanges} (LLM may retune caps via "configure")`);
|
|
1175
|
+
L(` allowDangerousTools : ${cfg.allowDangerousTools} (sign_typed_data / delete_content)`);
|
|
1176
|
+
L(` allowAdminTools : ${cfg.allowAdminTools} (register/enable service, claim/submit job, upload_content)`);
|
|
1177
|
+
if (cfg.allowSecurityChanges || cfg.allowDangerousTools || cfg.allowAdminTools) {
|
|
1178
|
+
L(' ⚠ a loosened knob is enabled — only do this in a TRUSTED context (no prompt-injection risk).');
|
|
1179
|
+
}
|
|
1180
|
+
L('───────────────────────────────────────────────────────────────');
|
|
1181
|
+
}
|
|
1182
|
+
async function main() {
|
|
1183
|
+
// Operator config (out-of-band): optional JSON file via `--config <path>` or IC402_MCP_CONFIG,
|
|
1184
|
+
// merged with env vars (env wins). The LLM can influence neither, so the security boundary
|
|
1185
|
+
// stays operator-set (audit S8).
|
|
1186
|
+
const configPath = cliFlag('--config') ?? process.env.IC402_MCP_CONFIG ?? null;
|
|
1187
|
+
let fileJson = null;
|
|
1188
|
+
if (configPath) {
|
|
1189
|
+
try {
|
|
1190
|
+
fileJson = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
1191
|
+
}
|
|
1192
|
+
catch (e) {
|
|
1193
|
+
console.error(`ic402-mcp: failed to read config file ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1194
|
+
process.exit(1);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
const cfg = resolveOperatorConfig(fileJson, process.env, {
|
|
1198
|
+
perCallMaxAtomic: DEFAULT_PER_CALL_MAX_ATOMIC,
|
|
1199
|
+
sessionMaxAtomic: DEFAULT_SESSION_MAX_ATOMIC,
|
|
1200
|
+
});
|
|
1201
|
+
securityConfig.localDev = cfg.security.localDev;
|
|
1202
|
+
securityConfig.autoPayment = cfg.security.autoPayment;
|
|
1203
|
+
securityConfig.perCallMaxAtomic = cfg.security.perCallMaxAtomic;
|
|
1204
|
+
securityConfig.sessionMaxAtomic = cfg.security.sessionMaxAtomic;
|
|
1205
|
+
allowSecurityChanges = cfg.allowSecurityChanges;
|
|
1206
|
+
allowDangerousTools = cfg.allowDangerousTools;
|
|
1207
|
+
allowAdminTools = cfg.allowAdminTools;
|
|
1208
|
+
printSecurityBanner(cfg, configPath);
|
|
1209
|
+
const transport = new StdioServerTransport();
|
|
1210
|
+
await server.connect(transport);
|
|
1211
|
+
}
|
|
1212
|
+
main().catch((err) => {
|
|
1213
|
+
console.error('ic402 MCP server failed:', err);
|
|
1214
|
+
process.exit(1);
|
|
1215
|
+
});
|
|
1216
|
+
//# sourceMappingURL=index.js.map
|