@fidesbuild/mcp-server 0.5.1 → 0.8.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/dist/server.d.ts CHANGED
@@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { type Hex } from 'viem';
4
4
  import { type PrivateKeyAccount } from 'viem/accounts';
5
5
  import { type FidesAddresses } from '@fidesbuild/sdk';
6
+ import { type SubAddresses } from '@fidesbuild/sub';
6
7
  export type Config = {
7
8
  /** Folder for agent.json and the default fides.json. */
8
9
  home: string;
@@ -16,6 +17,8 @@ export type Config = {
16
17
  app: string;
17
18
  fidesFile: string;
18
19
  addresses?: FidesAddresses;
20
+ /** SUB-FIDES contracts, when not the published ones for the chain. */
21
+ sub?: SubAddresses;
19
22
  agentKey?: Hex;
20
23
  };
21
24
  export declare function configFromEnv(env?: NodeJS.ProcessEnv): Config;
package/dist/server.js CHANGED
@@ -16,9 +16,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
16
16
  import { z } from 'zod';
17
17
  import { encodeFunctionData, erc20Abi, formatUnits, parseUnits, isAddress, getAddress } from 'viem';
18
18
  import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
19
- import { Fides, FidesBuilder, Policy, Receipts, createFidesClient, fromJson, toJson } from '@fidesbuild/sdk';
19
+ import { Fides, FidesBuilder, Policy, Receipts, accountOf, createFidesClient, fromJson, toJson } from '@fidesbuild/sdk';
20
20
  import { createX402, decodePaymentRequired, exactEip3009 } from '@fidesbuild/x402';
21
21
  import { netObligations, settle, netRoots, PUBLISH_SELECTOR, NetError } from '@fidesbuild/net';
22
+ import { createSubClient, SubFidesKit, SubAgent, SubFidesError, subDeployments } from '@fidesbuild/sub';
23
+ import { quoteUniswap, checkMandate, swapActions, verifySwap, describeRoute, mandateOf, uniswap, NATIVE, SwapError } from '@fidesbuild/swap';
22
24
  export function configFromEnv(env = process.env) {
23
25
  const home = env.FIDES_HOME ?? path.join(os.homedir(), '.fides');
24
26
  return {
@@ -30,6 +32,7 @@ export function configFromEnv(env = process.env) {
30
32
  app: (env.FIDES_APP ?? 'https://fidesbuilder.xyz/app').replace(/\/$/, ''),
31
33
  fidesFile: env.FIDES_FILE ?? path.join(home, 'fides.json'),
32
34
  addresses: env.FIDES_ADDRESSES ? JSON.parse(fs.readFileSync(env.FIDES_ADDRESSES, 'utf8')) : undefined,
35
+ sub: env.FIDES_SUB_ADDRESSES ? JSON.parse(fs.readFileSync(env.FIDES_SUB_ADDRESSES, 'utf8')) : undefined,
33
36
  agentKey: env.FIDES_AGENT_KEY,
34
37
  };
35
38
  }
@@ -87,6 +90,11 @@ export function createFidesMcpServer(cfg) {
87
90
  const txLink = (h) => (h && explorer ? `${explorer}/tx/${h}` : h);
88
91
  const plans = new Map();
89
92
  const tokens = new Map();
93
+ // SUB-FIDES: a FIDES on a V2 account can hand slices of itself to other agents.
94
+ const subAddrs = cfg.sub ?? subDeployments[cfg.chainId];
95
+ const subClient = subAddrs ? createSubClient({ chainId: cfg.chainId, rpc: cfg.rpc, base: cfg.addresses, addresses: subAddrs }) : null;
96
+ const subPlans = new Map();
97
+ const subsDir = path.join(cfg.home, 'subs');
90
98
  const token = async (asset) => {
91
99
  const key = asset.toLowerCase();
92
100
  if (!tokens.has(key)) {
@@ -168,7 +176,7 @@ export function createFidesMcpServer(cfg) {
168
176
  ...(outside > 0n ? { warning: `${amount(outside)} sits on the agent key ${agent.address} outside the FIDES: it was sent to the agent address directly instead of to the FIDES Account. FIDES never spends it. To get it back, the owner can import the agent key from ${path.join(cfg.home, 'agent.json')} into a wallet on this computer, add a little ETH for gas, and send it to themselves. Funds for the agent always go to the FIDES Account address.` } : {}),
169
177
  };
170
178
  }
171
- const server = new McpServer({ name: 'fides', version: '0.5.1' }, {
179
+ const server = new McpServer({ name: 'fides', version: '0.8.0' }, {
172
180
  instructions: 'FIDES lets you act with the owner\'s funds only inside limits the owner signed (a FIDES): a budget, allowed contracts and functions, paid services, and an expiry. ' +
173
181
  'Funds move from the owner\'s FIDES Account, never from the owner\'s wallet. If no FIDES is set up yet, call fides_setup and give the user the link. Always call fides_plan first and show the user what is inside the FIDES, what is blocked and why, ' +
174
182
  'and ask before calling fides_execute. Actions outside the FIDES are blocked before anything is sent. Amounts are in whole token units, like "1.5". ' +
@@ -192,6 +200,8 @@ export function createFidesMcpServer(cfg) {
192
200
  'Send USDG from the FIDES Account, to one address or many, inside the budget. Several transfers go out as one transaction.',
193
201
  'Call contracts the FIDES allows, with the functions it allows.',
194
202
  'Buy from paid APIs that charge per request over x402, paying from the FIDES budget, each capped per service.',
203
+ 'Swap on Uniswap inside a trading mandate the owner signed: tokens it may buy, worst price, most per swap; best route across Robinhood Chain including v4 hook pools, approval and swap as one transaction (fides_swap). The FIDES must allow swaps: fides_setup with swap.',
204
+ 'Hand another agent a slice of the FIDES with its own budget and expiry (fides_delegate, SUB-FIDES); or, as the child, act inside a slice handed to you (fides_sub_plan). The FIDES must allow delegation: fides_setup with delegate: true.',
195
205
  'Settle with FIDES NET: give the payments owed to and from the FIDES Account, and they are netted to final positions and settled in one transaction, with the round published onchain (fides_net). The FIDES must allow NET: fides_setup with net: true.',
196
206
  'Show the budget spent and left, what is allowed, the expiry, and every transaction executed, with explorer links.',
197
207
  'Everything is planned and simulated first, and shown to the user before anything is sent. Anything outside the FIDES is blocked. The hosted relayer pays the gas.',
@@ -230,22 +240,64 @@ export function createFidesMcpServer(cfg) {
230
240
  });
231
241
  server.registerTool('fides_setup', {
232
242
  title: 'Set up FIDES',
233
- description: 'Use when the user wants to set up FIDES, connect their wallet or sign a new FIDES. Returns this computer\'s agent address and a link to the FIDES app with it filled in, where the user sets the limits and signs. The signed FIDES is then picked up automatically. To let the user buy from paid APIs (fides_pay_request), pass the services with a spending cap each. Paid APIs known to work use the ids "fides-demo", "spraay" and "madeonsol" (fides_help has their URLs and prices); for any other x402 API, the id is any name the user chooses.',
243
+ description: 'Use when the user wants to set up FIDES, connect their wallet or sign a new FIDES. Returns this computer\'s agent address and a link to the FIDES app with it filled in, where the user sets the limits and signs. The signed FIDES is then picked up automatically. To let the user buy from paid APIs (fides_pay_request), pass the services with a spending cap each. Paid APIs known to work use the ids "fides-demo", "spraay" and "madeonsol" (fides_help has their URLs and prices); for any other paid API, call fides_check_api with its URL and use the service_id it returns. To let the agent call any contract on the chain, pass contracts with the functions it may call by name (fides_inspect_contract lists them and flags the risky ones).',
234
244
  inputSchema: {
235
- services: z.array(z.object({ id: z.string().describe('A name for the paid service, chosen by the user, e.g. "fides-demo" or "spraay"'), max: z.string().describe('Most the agent may spend on it, in USDG, e.g. "0.1"') })).optional(),
245
+ services: z.array(z.object({ id: z.string().describe('The service id: "fides-demo", "spraay", "madeonsol", or for any other paid API the service_id fides_check_api returns (its host)'), max: z.string().describe('Most the agent may spend on it, in USDG, e.g. "0.1"') })).optional(),
246
+ contracts: z.array(z.object({
247
+ address: z.string().describe('The contract address'),
248
+ functions: z.array(z.string()).min(1).describe('Function names (e.g. "supply", "withdraw") or 4-byte selectors. fides_inspect_contract lists them'),
249
+ })).optional().describe('Any contracts on the chain the agent may call, and exactly which functions; everything else on them stays blocked'),
236
250
  net: z.boolean().optional().describe('true to allow FIDES NET: lets the FIDES publish netting roots on FidesNetRoots, which fides_net needs'),
251
+ delegate: z.boolean().optional().describe('true to let this agent hand slices of the FIDES to other agents (SUB-FIDES), which fides_delegate needs'),
252
+ swap: z.object({
253
+ buy: z.array(z.string()).min(1).describe('Tokens the agent may buy: "ETH", "WETH" or addresses'),
254
+ max_slippage_percent: z.number().positive().max(50).optional().describe('The worst price it may accept against the quote, in percent. Default 1'),
255
+ max_per_swap: z.string().optional().describe('The most one swap may spend, in the budget token. Default "1"'),
256
+ }).optional().describe('Let the agent swap on Uniswap inside a trading mandate the owner signs; fides_swap needs it'),
237
257
  },
238
- }, async ({ services, net }) => ok({
239
- agent_address: agent.address,
240
- link: setupLink + (services ?? []).map((x) => `&service=${encodeURIComponent(x.id)}:${encodeURIComponent(x.max)}`).join('') + (net && netRoots[cfg.chainId] ? `&target=${netRoots[cfg.chainId]}:${PUBLISH_SELECTOR}` : ''),
241
- services: services?.length ? services.map((x) => `${x.id}: up to ${x.max} USDG`) : undefined,
242
- steps: [
243
- 'Open the link. It opens the FIDES app with this agent already filled in.',
244
- 'Connect your wallet. Set the budget, the contracts allowed and the expiry. Start small.',
245
- 'Sign (free), click Create FIDES Account (a little gas), and send the budget to the FIDES Account address shown.',
246
- 'Come back and ask me for what you want. I pick up the signed FIDES by myself.',
247
- ],
248
- }));
258
+ }, async ({ services, net, delegate, swap, contracts }) => {
259
+ let targets = '';
260
+ const added = [];
261
+ for (const c of contracts ?? []) {
262
+ if (!isAddress(c.address))
263
+ return fail(`"${c.address}" is not a contract address.`);
264
+ const sels = c.functions.filter((x) => /^0x[0-9a-fA-F]{8}$/.test(x)).map((x) => x.toLowerCase());
265
+ const names = c.functions.filter((x) => !/^0x[0-9a-fA-F]{8}$/.test(x));
266
+ let label = c.address;
267
+ if (names.length) {
268
+ let info;
269
+ try {
270
+ info = await inspect(c.address);
271
+ }
272
+ catch (err) {
273
+ return fail(`Could not read ${c.address}: ${err?.message ?? err}`);
274
+ }
275
+ if (!info.verified)
276
+ return fail(`${c.address} is not verified, so its functions cannot be found by name. Give their 4-byte selectors instead.`);
277
+ label = info.name ?? c.address;
278
+ for (const n of names) {
279
+ const fn = info.functions.find((x) => x.name === n || x.signature.replace(/ \w+(?=[,)])/g, '') === n);
280
+ if (!fn)
281
+ return fail(`${label} has no function "${n}" that changes anything. It has: ${info.functions.map((x) => x.name).join(', ')}.`);
282
+ sels.push(fn.selector);
283
+ }
284
+ }
285
+ targets += `&target=${getAddress(c.address)}:${[...new Set(sels)].join(',')}`;
286
+ added.push(`${label}: ${c.functions.join(', ')}`);
287
+ }
288
+ return ok({
289
+ agent_address: agent.address,
290
+ link: setupLink + targets + (services ?? []).map((x) => `&service=${encodeURIComponent(x.id)}:${encodeURIComponent(x.max)}`).join('') + (net && netRoots[cfg.chainId] ? `&target=${netRoots[cfg.chainId]}:${PUBLISH_SELECTOR}` : '') + (delegate ? '&delegate=1' : '') + (swap ? `&swap=${encodeURIComponent(swap.buy.map((t) => (/^weth$/i.test(t) ? uniswap[cfg.chainId]?.weth ?? t : t)).join('|'))}:${Math.round((swap.max_slippage_percent ?? 1) * 100)}:${encodeURIComponent(swap.max_per_swap ?? '1')}` : ''),
291
+ services: services?.length ? services.map((x) => `${x.id}: up to ${x.max} USDG`) : undefined,
292
+ contracts: added.length ? added : undefined,
293
+ steps: [
294
+ 'Open the link. It opens the FIDES app with this agent already filled in.',
295
+ 'Connect your wallet. Set the budget, the contracts allowed and the expiry. Start small.',
296
+ 'Sign (free), click Create FIDES Account (a little gas), and send the budget to the FIDES Account address shown.',
297
+ 'Come back and ask me for what you want. I pick up the signed FIDES by myself.',
298
+ ],
299
+ });
300
+ });
249
301
  server.registerTool('fides_use_file', {
250
302
  title: 'Use a FIDES file',
251
303
  description: 'Only needed if the user has a fides.json file: loads a signed FIDES from a path on this computer, checks that it is for this computer\'s agent key, and keeps a copy. FIDES signed in the app are picked up without this.',
@@ -285,6 +337,51 @@ export function createFidesMcpServer(cfg) {
285
337
  data: z.string().optional().describe('call: hex calldata, starting with the 4-byte function selector'),
286
338
  label: z.string().optional().describe('A short description shown in the plan'),
287
339
  });
340
+ /** The tool's actions as Builder actions, in the budget token unless a transfer names another. */
341
+ async function buildActions(actions, budget) {
342
+ const built = [];
343
+ for (const [i, a] of actions.entries()) {
344
+ const n = i + 1;
345
+ if (a.kind === 'transfer') {
346
+ if (!a.to || !isAddress(a.to))
347
+ return { error: `Action ${n}: "to" must be an address.` };
348
+ const asset = a.token ? (isAddress(a.token) ? getAddress(a.token) : null) : budget;
349
+ if (!asset)
350
+ return { error: `Action ${n}: "token" must be an address.` };
351
+ const t = await token(asset);
352
+ let amount;
353
+ try {
354
+ amount = parseUnits(String(a.amount ?? ''), t.decimals);
355
+ }
356
+ catch {
357
+ return { error: `Action ${n}: "amount" must be a number of ${t.symbol}, like "1.5".` };
358
+ }
359
+ if (amount <= 0n)
360
+ return { error: `Action ${n}: "amount" must be more than zero.` };
361
+ built.push({ id: `a${n}`, type: 'TOKEN_TRANSFER', label: a.label ?? `send ${formatUnits(amount, t.decimals)} ${t.symbol} to ${getAddress(a.to)}`, target: asset, spend: { asset, amount },
362
+ payload: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [getAddress(a.to), amount] }) });
363
+ }
364
+ else {
365
+ if (!a.target || !isAddress(a.target))
366
+ return { error: `Action ${n}: "target" must be a contract address.` };
367
+ if (!a.data || !/^0x([0-9a-fA-F]{2}){4,}$/.test(a.data))
368
+ return { error: `Action ${n}: "data" must be hex calldata starting with the 4-byte selector.` };
369
+ const t = await token(budget);
370
+ let spend = 0n;
371
+ if (a.amount) {
372
+ try {
373
+ spend = parseUnits(a.amount, t.decimals);
374
+ }
375
+ catch {
376
+ return { error: `Action ${n}: "amount" must be a number of ${t.symbol}.` };
377
+ }
378
+ }
379
+ built.push({ id: `a${n}`, type: 'CONTRACT_WRITE', label: a.label ?? `call ${a.data.slice(0, 10)} on ${getAddress(a.target)}`, target: getAddress(a.target), payload: a.data,
380
+ ...(spend > 0n ? { spend: { asset: budget, amount: spend } } : {}) });
381
+ }
382
+ }
383
+ return { built };
384
+ }
288
385
  server.registerTool('fides_plan', {
289
386
  title: 'Plan actions under the FIDES',
290
387
  description: 'Builds the actions into one execution plan: checks every action against the FIDES, groups what can go out together, and simulates it against the chain. Sends nothing. Returns a plan_id for fides_execute. Show the result to the user before executing.',
@@ -296,48 +393,10 @@ export function createFidesMcpServer(cfg) {
296
393
  return fail(notLoaded());
297
394
  if (fides.agent.toLowerCase() !== agent.address.toLowerCase())
298
395
  return fail(`The loaded FIDES is for agent ${fides.agent}, not this computer's agent key ${agent.address}.`);
299
- const budget = fides.spendLimits[0].asset;
300
- const built = [];
301
- for (const [i, a] of actions.entries()) {
302
- const n = i + 1;
303
- if (a.kind === 'transfer') {
304
- if (!a.to || !isAddress(a.to))
305
- return fail(`Action ${n}: "to" must be an address.`);
306
- const asset = a.token ? (isAddress(a.token) ? getAddress(a.token) : null) : budget;
307
- if (!asset)
308
- return fail(`Action ${n}: "token" must be an address.`);
309
- const t = await token(asset);
310
- let amount;
311
- try {
312
- amount = parseUnits(String(a.amount ?? ''), t.decimals);
313
- }
314
- catch {
315
- return fail(`Action ${n}: "amount" must be a number of ${t.symbol}, like "1.5".`);
316
- }
317
- if (amount <= 0n)
318
- return fail(`Action ${n}: "amount" must be more than zero.`);
319
- built.push({ id: `a${n}`, type: 'TOKEN_TRANSFER', label: a.label ?? `send ${formatUnits(amount, t.decimals)} ${t.symbol} to ${getAddress(a.to)}`, target: asset, spend: { asset, amount },
320
- payload: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [getAddress(a.to), amount] }) });
321
- }
322
- else {
323
- if (!a.target || !isAddress(a.target))
324
- return fail(`Action ${n}: "target" must be a contract address.`);
325
- if (!a.data || !/^0x([0-9a-fA-F]{2}){4,}$/.test(a.data))
326
- return fail(`Action ${n}: "data" must be hex calldata starting with the 4-byte selector.`);
327
- const t = await token(budget);
328
- let spend = 0n;
329
- if (a.amount) {
330
- try {
331
- spend = parseUnits(a.amount, t.decimals);
332
- }
333
- catch {
334
- return fail(`Action ${n}: "amount" must be a number of ${t.symbol}.`);
335
- }
336
- }
337
- built.push({ id: `a${n}`, type: 'CONTRACT_WRITE', label: a.label ?? `call ${a.data.slice(0, 10)} on ${getAddress(a.target)}`, target: getAddress(a.target), payload: a.data,
338
- ...(spend > 0n ? { spend: { asset: budget, amount: spend } } : {}) });
339
- }
340
- }
396
+ const made = await buildActions(actions, fides.spendLimits[0].asset);
397
+ if ('error' in made)
398
+ return fail(made.error);
399
+ const built = made.built;
341
400
  const plan = await new FidesBuilder({ client, fides, agent }).add(built).build();
342
401
  const savings = plan.estimatedSavings();
343
402
  const simulations = savings.estimatedTransactions ? await plan.simulate() : [];
@@ -487,7 +546,13 @@ export function createFidesMcpServer(cfg) {
487
546
  return fail(notLoaded());
488
547
  if (!fides.services.length)
489
548
  return fail(`This FIDES allows no paid services. To buy from a paid API, the user signs a new FIDES that includes one: call fides_setup with services, for example [{ id: "fides-demo", max: "0.1" }].`);
490
- const id = service ?? (fides.services.length === 1 ? fides.services[0].serviceId : undefined);
549
+ const host = (() => { try {
550
+ return new URL(url).host.toLowerCase().replace(/^www\./, '');
551
+ }
552
+ catch {
553
+ return '';
554
+ } })();
555
+ const id = service ?? (fides.services.some((x) => x.serviceId === host) ? host : fides.services.length === 1 ? fides.services[0].serviceId : undefined);
491
556
  if (!id)
492
557
  return fail(`Say which service this is. The FIDES allows: ${fides.services.map((x) => x.serviceId).join(', ')}.`);
493
558
  const rule = fides.services.find((x) => x.serviceId === id);
@@ -583,18 +648,303 @@ export function createFidesMcpServer(cfg) {
583
648
  return fail('Paid request stopped: ' + failure(err?.shortMessage ?? err?.message ?? err));
584
649
  }
585
650
  });
651
+ // ---- SUB-FIDES ----
652
+ // ---- Connect anything ----
653
+ const lookupBase = () => cfg.api ?? cfg.directory ?? 'https://api.fidesbuilder.xyz';
654
+ async function inspect(address) {
655
+ const res = await fetch(`${lookupBase()}/v1/contracts/${address}`);
656
+ const json = fromJson(await res.text());
657
+ if (!res.ok || json?.error)
658
+ throw new Error(json?.error?.message ?? `HTTP ${res.status}`);
659
+ return json;
660
+ }
661
+ server.registerTool('fides_inspect_contract', {
662
+ title: 'What a contract lets an agent do',
663
+ description: 'Reads any contract on the chain from its verified source (proxies followed) and lists the functions that change something, in plain words, with risks flagged: "danger" (lasting approvals, handing control to another address, upgrades, debt), "warn" (the agent picks where funds go, or it sends ETH) and "admin" (operator-only; will fail for an agent). Use it before fides_setup with contracts, and recommend only the functions the user needs. Sends nothing.',
664
+ inputSchema: { address: z.string().describe('The contract address') },
665
+ }, async ({ address }) => {
666
+ try {
667
+ if (!isAddress(address))
668
+ return fail('That is not a contract address.');
669
+ const info = await inspect(address);
670
+ if (!info.verified)
671
+ return ok({ address: info.address, verified: false, note: 'Not verified on Sourcify, so its functions cannot be read. The user can still allow it by 4-byte selector.' });
672
+ return ok({
673
+ name: info.name, address: info.address, verified: true, proxy: info.proxy ?? undefined,
674
+ functions: info.functions.map((fn) => ({ name: fn.name, does: fn.about || undefined, signature: fn.signature, selector: fn.selector, risks: fn.risks.length ? fn.risks.map((r) => `${r.level}: ${r.why}`) : undefined })),
675
+ next: 'To allow some of these, call fides_setup with contracts: [{ address, functions: [names] }] and give the user the link.',
676
+ });
677
+ }
678
+ catch (err) {
679
+ return fail('Could not read the contract: ' + (err?.message ?? err));
680
+ }
681
+ });
682
+ server.registerTool('fides_check_api', {
683
+ title: 'Can FIDES pay this API?',
684
+ description: 'Calls a paid API\'s URL once, without paying, and reads its x402 price: whether a FIDES on Robinhood Chain can pay it, the price, who is paid, and the service id and a suggested cap to use with fides_setup. Use it when the user wants the agent to use a paid API that is not one of the known ones. Pays nothing.',
685
+ inputSchema: { url: z.string().describe('The API URL, https') },
686
+ }, async ({ url }) => {
687
+ try {
688
+ const res = await fetch(`${lookupBase()}/v1/services/probe`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: toJson({ url, budgetToken: (await currentFides().catch(() => null))?.spendLimits[0].asset ?? '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168' }) });
689
+ const p = fromJson(await res.text());
690
+ if (!res.ok || p?.error)
691
+ return fail(p?.error?.message ?? `HTTP ${res.status}`);
692
+ if (!p.payable)
693
+ return ok({ url: p.url, payable: false, why: p.reason, charges_on: p.networks?.length ? p.networks : undefined });
694
+ const dec = p.offer.decimals ?? 6, sym = p.offer.symbol ?? 'USDG';
695
+ return ok({
696
+ url: p.url, payable: true, price: `${formatUnits(BigInt(p.offer.amount), dec)} ${sym} a request`, paid_to: p.offer.payTo, what_it_is: p.description,
697
+ service_id: p.serviceId, suggested_cap: `${formatUnits(BigInt(p.suggestedCap), dec)} ${sym} (10 requests)`,
698
+ next: `To allow it, call fides_setup with services: [{ id: "${p.serviceId}", max: "${formatUnits(BigInt(p.suggestedCap), dec)}" }]. Then fides_pay_request with this URL.`,
699
+ });
700
+ }
701
+ catch (err) {
702
+ return fail('Could not check the API: ' + (err?.message ?? err));
703
+ }
704
+ });
705
+ // ---- Uniswap ----
706
+ const tokenArg = (t) => (/^eth$/i.test(t) ? 'ETH' : /^weth$/i.test(t) ? uniswap[cfg.chainId]?.weth : isAddress(t) ? getAddress(t) : null);
707
+ server.registerTool('fides_swap', {
708
+ title: 'Swap on Uniswap inside the FIDES',
709
+ description: 'Gets the best route from Uniswap on Robinhood Chain (v2, v3 and v4 pools, including v4 pools with hooks such as the aggregator hooks) for the FIDES Account, checks it against the trading mandate the owner signed (tokens it may buy, worst price, most per swap), and simulates it to measure what would actually arrive. Sends nothing. Returns a plan_id for fides_execute, which sends an exact approval and the swap as one transaction; gas is paid by the relayer. Show the route, the quoted amount and the worst accepted amount to the user before executing. Needs a FIDES that allows swaps: fides_setup with swap.',
710
+ inputSchema: {
711
+ token_out: z.string().describe('What to buy: "ETH", "WETH" or a token address'),
712
+ amount: z.string().describe('How much of the budget token to spend, e.g. "1"'),
713
+ max_slippage_percent: z.number().positive().optional().describe('Worst price against the quote, in percent. Defaults to the most the FIDES allows'),
714
+ },
715
+ }, async ({ token_out, amount, max_slippage_percent }) => {
716
+ try {
717
+ const fides = await currentFides();
718
+ if (!fides)
719
+ return fail(notLoaded());
720
+ if (fides.agent.toLowerCase() !== agent.address.toLowerCase())
721
+ return fail(`The loaded FIDES is for agent ${fides.agent}, not this computer's agent key ${agent.address}.`);
722
+ const m = mandateOf(fides);
723
+ if (!m)
724
+ return fail('This FIDES does not allow swaps. The user signs one that does: call fides_setup with swap, for example { buy: ["ETH"], max_slippage_percent: 1, max_per_swap: "1" }, and give them the link.');
725
+ const out = tokenArg(token_out);
726
+ if (!out)
727
+ return fail('"token_out" must be "ETH", "WETH" or a token address.');
728
+ const budgetToken = fides.spendLimits[0].asset, tIn = await token(budgetToken);
729
+ let value;
730
+ try {
731
+ value = parseUnits(amount, tIn.decimals);
732
+ }
733
+ catch {
734
+ return fail(`"amount" must be a number of ${tIn.symbol}, like "1".`);
735
+ }
736
+ const slippageBps = max_slippage_percent ? Math.round(max_slippage_percent * 100) : m.maxSlippageBps;
737
+ let q, blocked;
738
+ if (cfg.api) {
739
+ const r = await post('/v1/swap/quote', { fides, tokenOut: out, amount: value, slippageBps });
740
+ q = r.quote;
741
+ blocked = r.mandate?.allowed ? null : { code: r.mandate.code, message: r.mandate.message };
742
+ }
743
+ else {
744
+ if (!process.env.UNISWAP_API_KEY)
745
+ return fail('Swaps need the FIDES API, or UNISWAP_API_KEY when running without it.');
746
+ q = await quoteUniswap({ apiKey: process.env.UNISWAP_API_KEY, fides, tokenOut: out, amount: value, slippageBps });
747
+ blocked = checkMandate(fides, q);
748
+ }
749
+ const outInfo = q.tokenOut === NATIVE ? { symbol: 'ETH', decimals: 18 } : await token(q.tokenOut);
750
+ const fmt = (v) => `${formatUnits(v, outInfo.decimals)} ${outInfo.symbol}`;
751
+ if (blocked)
752
+ return fail(`Blocked by the trading mandate: ${blocked.message} Nothing was sent.`);
753
+ const actions = swapActions(q, `swap ${amount} ${tIn.symbol} to ${outInfo.symbol} on Uniswap`);
754
+ const plan = await new FidesBuilder({ client, fides, agent }).add(actions).build();
755
+ if (plan.rejected().length) {
756
+ const r = plan.rejected()[0];
757
+ return fail(`Blocked by the FIDES: ${REASONS[r.reason ?? ''] ?? r.reason}. Nothing was sent.`);
758
+ }
759
+ const v = await verifySwap(client, fides, plan, q);
760
+ const id = plan.planHash.slice(0, 18);
761
+ if (v.ok)
762
+ plans.set(id, { plan, actions, fides, swap: { quote: q, outDecimals: outInfo.decimals, outSymbol: outInfo.symbol } });
763
+ return ok({
764
+ plan_id: v.ok ? id : null,
765
+ ready_to_execute: v.ok,
766
+ route: describeRoute(q),
767
+ spend: `${amount} ${tIn.symbol}`,
768
+ quoted: fmt(q.quotedOut),
769
+ worst_accepted: `${fmt(q.minOut)} (${q.slippageBps / 100}% below the quote)`,
770
+ simulated: v.ok ? `would receive ${fmt(v.received)}` : `refused: ${v.reason}`,
771
+ transactions: `${plan.estimatedSavings().baselineTransactions} onchain write(s) go out as ${plan.estimatedSavings().estimatedTransactions} transaction(s): an exact approval and the swap, nothing left approved`,
772
+ mandate: `may buy ${m.buy.join(', ')}; worst price ${m.maxSlippageBps / 100}%; most per swap ${formatUnits(m.maxPerSwap, tIn.decimals)} ${tIn.symbol}`,
773
+ sent: 'nothing has been sent',
774
+ });
775
+ }
776
+ catch (err) {
777
+ return fail(err instanceof SwapError ? `Uniswap: ${err.message}` : 'Could not get a swap: ' + (err?.shortMessage ?? err?.message ?? err));
778
+ }
779
+ });
780
+ server.registerTool('fides_delegate', {
781
+ title: 'Hand another agent a slice of the FIDES (SUB-FIDES)',
782
+ description: 'This agent hands another agent a SUB-FIDES: its own budget, contracts and expiry, inside this FIDES. The child can never exceed it, and everything it spends also counts against this FIDES. This agent signs it; nothing is sent onchain and it costs nothing. The child agent (Claude or an SDK agent running FIDES) finds it by itself, or the user can hand it the file. Needs a FIDES that allows delegation: fides_setup with delegate: true. Confirm the amount and the agent with the user first.',
783
+ inputSchema: {
784
+ agent: z.string().describe('The child agent\'s address'),
785
+ amount: z.string().describe('The most the child may spend, in the budget token, e.g. "0.1"'),
786
+ minutes: z.number().int().positive().optional().describe('How long the slice lasts. Defaults to as long as this FIDES; never longer'),
787
+ },
788
+ }, async ({ agent: child, amount, minutes }) => {
789
+ try {
790
+ if (!subClient || !subAddrs)
791
+ return fail(`SUB-FIDES is not deployed on chain ${cfg.chainId}.`);
792
+ const parent = await currentFides();
793
+ if (!parent)
794
+ return fail(notLoaded());
795
+ if (parent.agent.toLowerCase() !== agent.address.toLowerCase())
796
+ return fail(`The loaded FIDES is for agent ${parent.agent}, not this computer's agent key ${agent.address}.`);
797
+ if (!isAddress(child))
798
+ return fail('"agent" must be an address.');
799
+ if (child.toLowerCase() === agent.address.toLowerCase())
800
+ return fail('An agent cannot delegate to itself.');
801
+ const v2 = await accountOf(subClient, parent.owner);
802
+ if (v2.toLowerCase() !== parent.account.toLowerCase())
803
+ return fail('This FIDES cannot delegate: its FIDES Account is not one that supports SUB-FIDES. The user signs a new FIDES that allows delegation: call fides_setup with delegate: true and give them the link.');
804
+ const asset = parent.spendLimits[0].asset, t = await token(asset);
805
+ let value;
806
+ try {
807
+ value = parseUnits(amount, t.decimals);
808
+ }
809
+ catch {
810
+ return fail(`"amount" must be a number of ${t.symbol}, like "0.1".`);
811
+ }
812
+ const expiry = minutes ? BigInt(Math.min(Number(parent.validUntil), Math.floor(Date.now() / 1000) + minutes * 60)) : parent.validUntil;
813
+ const unsigned = SubFidesKit.create(parent, { agent: getAddress(child), spend: [{ asset, amount: value }], expiry });
814
+ const sub = await SubFidesKit.sign(unsigned, agent, subClient, parent);
815
+ if (cfg.api)
816
+ await SubFidesKit.register(cfg.api, parent, sub);
817
+ fs.mkdirSync(subsDir, { recursive: true });
818
+ const file = path.join(subsDir, `${sub.id.slice(0, 18)}.json`);
819
+ fs.writeFileSync(file, toJson({ parent, sub }, 2) + '\n');
820
+ return ok({
821
+ sub_fides: sub.id,
822
+ child_agent: sub.agent,
823
+ budget: `${formatUnits(value, t.decimals)} ${t.symbol}`,
824
+ expires: new Date(Number(sub.validUntil) * 1000).toISOString(),
825
+ contracts: sub.targets.map((r) => r.target),
826
+ file,
827
+ how_the_child_gets_it: cfg.api ? 'Shared with the FIDES API: a child running FIDES (Claude or the SDK) finds it by its agent address. The file is a copy to hand over directly.' : 'Hand the child the file.',
828
+ revoke: 'The owner can revoke it any time in the FIDES app, on this FIDES\'s page.',
829
+ });
830
+ }
831
+ catch (err) {
832
+ return fail(err instanceof SubFidesError ? err.message : 'Could not delegate: ' + (err?.shortMessage ?? err?.message ?? err));
833
+ }
834
+ });
835
+ /** The SUB-FIDES handed to this agent that can still act: from a file, or found through the FIDES API. */
836
+ async function receivedSubs(file) {
837
+ const found = [];
838
+ if (file) {
839
+ const full = file.replace(/^~(?=$|[\\/])/, os.homedir());
840
+ found.push(fromJson(fs.readFileSync(full, 'utf8')));
841
+ }
842
+ if (cfg.directory) {
843
+ try {
844
+ const res = await fetch(`${cfg.directory}/v1/agents/${agent.address}/subs`);
845
+ if (res.ok)
846
+ for (const x of fromJson(await res.text()).subs ?? [])
847
+ if (x.parentStatus?.valid)
848
+ found.push({ parent: x.parent, sub: x.sub });
849
+ }
850
+ catch { /* the API is optional when a file is given */ }
851
+ }
852
+ const now = BigInt(Math.floor(Date.now() / 1000));
853
+ const live = [];
854
+ for (const x of found) {
855
+ if (x.sub.agent.toLowerCase() !== agent.address.toLowerCase() || now >= x.sub.validUntil)
856
+ continue;
857
+ if (subClient && await SubFidesKit.isRevoked(subClient, x.parent, x.sub).catch(() => true))
858
+ continue;
859
+ live.push(x);
860
+ }
861
+ return live.sort((a, b) => Number(b.sub.validAfter - a.sub.validAfter));
862
+ }
863
+ server.registerTool('fides_sub_plan', {
864
+ title: 'Plan actions under a SUB-FIDES handed to this agent',
865
+ description: 'For when another agent handed this agent a SUB-FIDES (a slice of its FIDES). Builds the actions into one plan, checks every action against the slice, and simulates it. Sends nothing. Returns a plan_id for fides_execute, which sends it through the relayer (no gas needed). Show the result to the user before executing.',
866
+ inputSchema: { actions: z.array(actionSchema).min(1).max(50), file: z.string().optional().describe('Path to a SUB-FIDES file, if the user has one. Otherwise it is found by this agent\'s address') },
867
+ }, async ({ actions, file }) => {
868
+ try {
869
+ if (!subClient)
870
+ return fail(`SUB-FIDES is not deployed on chain ${cfg.chainId}.`);
871
+ const [x] = await receivedSubs(file);
872
+ if (!x)
873
+ return fail(`No active SUB-FIDES has been handed to this agent (${agent.address}). The delegating agent creates one with this address, or the user gives you its file.`);
874
+ const made = await buildActions(actions, x.sub.spendLimits[0]?.asset ?? x.parent.spendLimits[0].asset);
875
+ if ('error' in made)
876
+ return fail(made.error);
877
+ const child = new SubAgent({ client: subClient, parent: x.parent, sub: x.sub, agent, ...(cfg.api ? { api: cfg.api } : { relayer: agent }) });
878
+ const p = await child.plan(made.built);
879
+ const sim = p.bundle ? await child.simulate(p) : { success: false, reason: 'NOTHING_TO_SEND' };
880
+ const id = (p.bundleHash ?? '0x').slice(0, 18);
881
+ if (sim.success)
882
+ subPlans.set(id, { agent: child, plan: p, parent: x.parent, sub: x.sub });
883
+ const t = await token(x.sub.spendLimits[0]?.asset ?? x.parent.spendLimits[0].asset);
884
+ return ok({
885
+ plan_id: sim.success ? id : null,
886
+ ready_to_execute: sim.success,
887
+ sub_fides: x.sub.id,
888
+ slice: `${x.sub.spendLimits.map((l) => formatUnits(l.amount, t.decimals) + ' ' + t.symbol).join(', ')} until ${new Date(Number(x.sub.validUntil) * 1000).toISOString()}`,
889
+ delegated_by: x.parent.agent,
890
+ actions: p.plan.actions.map((a) => ({ action: a.label, decision: a.decision === 'AUTHORIZED' ? 'inside the SUB-FIDES' : a.decision === 'REJECTED' ? `blocked: ${REASONS[a.reason ?? ''] ?? a.reason} (${a.reason})` : 'duplicate, sent once' })),
891
+ transactions: p.bundle ? `${p.bundle.calls.length} onchain write(s) go out as 1 transaction` : 'nothing to execute',
892
+ simulation: !p.bundle ? 'nothing to execute: no action is inside the SUB-FIDES' : sim.success ? 'simulated against the chain: it would succeed' : `would fail: ${failure(sim.reason)}`,
893
+ sent: 'nothing has been sent',
894
+ });
895
+ }
896
+ catch (err) {
897
+ return fail('Could not build the plan: ' + (err?.shortMessage ?? err?.message ?? err));
898
+ }
899
+ });
900
+ server.registerTool('fides_subs', {
901
+ title: 'SUB-FIDES handed out and received',
902
+ description: 'The SUB-FIDES this agent has handed to other agents, and the ones handed to it that can still act.',
903
+ inputSchema: {},
904
+ }, async () => {
905
+ try {
906
+ const given = fs.existsSync(subsDir) ? fs.readdirSync(subsDir).filter((n) => n.endsWith('.json')).map((n) => fromJson(fs.readFileSync(path.join(subsDir, n), 'utf8')).sub) : [];
907
+ const now = BigInt(Math.floor(Date.now() / 1000));
908
+ return ok({
909
+ handed_out: await Promise.all(given.map(async (s) => ({ sub_fides: s.id, child_agent: s.agent, budget: (await Promise.all(s.spendLimits.map(async (l) => { const t = await token(l.asset); return `${formatUnits(l.amount, t.decimals)} ${t.symbol}`; }))).join(', '), expires: new Date(Number(s.validUntil) * 1000).toISOString(), expired: now >= s.validUntil }))),
910
+ received: (await receivedSubs()).map((x) => ({ sub_fides: x.sub.id, delegated_by: x.parent.agent, expires: new Date(Number(x.sub.validUntil) * 1000).toISOString() })),
911
+ });
912
+ }
913
+ catch (err) {
914
+ return fail('Could not list SUB-FIDES: ' + (err?.shortMessage ?? err?.message ?? err));
915
+ }
916
+ });
586
917
  server.registerTool('fides_execute', {
587
918
  title: 'Execute a plan',
588
919
  description: 'Executes a plan made by fides_plan, by its plan_id. Only the actions inside the FIDES are sent; blocked ones never are. Moves real funds from the FIDES Account: confirm with the user first.',
589
920
  inputSchema: { plan_id: z.string() },
590
921
  }, async ({ plan_id }) => {
922
+ const subEntry = subPlans.get(plan_id);
923
+ if (subEntry) {
924
+ subPlans.delete(plan_id);
925
+ try {
926
+ const r = await subEntry.agent.execute(subEntry.plan);
927
+ return ok({
928
+ result: r.status === 'EXECUTED' ? 'executed' : 'not executed',
929
+ transactions: r.txHash || r.error ? [{ status: r.status.toLowerCase(), transaction: txLink(r.txHash), ...(r.error ? { reason: failure(r.error) } : {}) }] : [],
930
+ under: `SUB-FIDES ${subEntry.sub.id} from ${subEntry.parent.agent}`,
931
+ blocked_and_not_sent: subEntry.plan.plan.rejected.map((a) => `${a.label}: ${REASONS[a.reason ?? ''] ?? a.reason}`),
932
+ });
933
+ }
934
+ catch (err) {
935
+ return fail('Execution stopped: ' + failure(err?.shortMessage ?? err?.message ?? err));
936
+ }
937
+ }
591
938
  const entry = plans.get(plan_id);
592
939
  if (!entry)
593
940
  return fail('No plan with that id. Make one with fides_plan first (plans are kept until they are executed or the server restarts).');
594
941
  plans.delete(plan_id); // a plan's bundles can be sent once
595
- const { plan, actions, fides, net } = entry;
942
+ const { plan, actions, fides, net, swap } = entry;
596
943
  try {
944
+ const outBalance = () => !swap ? Promise.resolve(0n) : swap.quote.tokenOut === NATIVE ? client.public.getBalance({ address: fides.account }) : client.public.readContract({ address: swap.quote.tokenOut, abi: erc20Abi, functionName: 'balanceOf', args: [fides.account] });
945
+ const before = await outBalance();
597
946
  const bundles = await send(plan, actions, fides);
947
+ const received = swap ? (await outBalance()) - before : 0n;
598
948
  const [spend] = await Policy.remainingSpend(client, fides);
599
949
  const t = await token(fides.spendLimits[0].asset);
600
950
  const executed = bundles.filter((b) => b.status === 'EXECUTED').length;
@@ -602,6 +952,12 @@ export function createFidesMcpServer(cfg) {
602
952
  result: executed === bundles.length ? 'executed' : executed ? 'partly executed' : 'not executed',
603
953
  transactions: bundles.map((b) => ({ status: b.status.toLowerCase(), transaction: txLink(b.txHash), ...(b.error ? { reason: failure(b.error) } : {}) })),
604
954
  blocked_and_not_sent: plan.rejected().map((a) => `${a.label}: ${REASONS[a.reason ?? ''] ?? a.reason}`),
955
+ ...(swap && executed === bundles.length ? { best_execution: {
956
+ quoted: `${formatUnits(swap.quote.quotedOut, swap.outDecimals)} ${swap.outSymbol}`,
957
+ received: `${formatUnits(received, swap.outDecimals)} ${swap.outSymbol}`,
958
+ versus_quote: received >= swap.quote.quotedOut ? 'at or better than the quote' : `${(Number(((swap.quote.quotedOut - received) * 10000n) / swap.quote.quotedOut) / 100).toFixed(2)}% below the quote, inside the ${swap.quote.slippageBps / 100}% allowed`,
959
+ route: describeRoute(swap.quote),
960
+ } } : {}),
605
961
  ...(net && executed === bundles.length ? { netting_record: { contract: netRoots[cfg.chainId], operator: fides.account, epoch: net.epoch.toString(), obligations_root: net.obligationsRoot, settlement_root: net.settlementRoot, contract_link: explorer ? `${explorer}/address/${netRoots[cfg.chainId]}` : undefined } } : {}),
606
962
  budget_remaining: `${formatUnits(spend.remaining, t.decimals)} of ${formatUnits(spend.limit, t.decimals)} ${t.symbol}`,
607
963
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fidesbuild/mcp-server",
3
- "version": "0.5.1",
3
+ "version": "0.8.0",
4
4
  "description": "FIDES as tools for Claude and any MCP client: plan and execute actions inside a signed FIDES, gas paid by the hosted relayer.",
5
5
  "keywords": [
6
6
  "fides",
@@ -40,6 +40,8 @@
40
40
  "viem": "^2.21.0",
41
41
  "zod": "^4.6.5",
42
42
  "@fidesbuild/x402": "0.2.0",
43
- "@fidesbuild/net": "0.2.1"
43
+ "@fidesbuild/net": "0.2.1",
44
+ "@fidesbuild/sub": "0.2.0",
45
+ "@fidesbuild/swap": "0.2.0"
44
46
  }
45
47
  }