@fidesbuild/mcp-server 0.8.1 → 0.9.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.
Files changed (3) hide show
  1. package/dist/server.js +48 -21
  2. package/package.json +12 -15
  3. package/src/server.ts +0 -968
package/dist/server.js CHANGED
@@ -21,6 +21,7 @@ import { createX402, decodePaymentRequired, exactEip3009 } from '@fidesbuild/x40
21
21
  import { netObligations, settle, netRoots, PUBLISH_SELECTOR, NetError } from '@fidesbuild/net';
22
22
  import { createSubClient, SubFidesKit, SubAgent, SubFidesError, subDeployments } from '@fidesbuild/sub';
23
23
  import { quoteUniswap, checkMandate, swapActions, verifySwap, describeRoute, mandateOf, uniswap, NATIVE, SwapError } from '@fidesbuild/swap';
24
+ import { route } from '@fidesbuild/router';
24
25
  export function configFromEnv(env = process.env) {
25
26
  const home = env.FIDES_HOME ?? path.join(os.homedir(), '.fides');
26
27
  return {
@@ -822,7 +823,7 @@ export function createFidesMcpServer(cfg) {
822
823
  const tokenArg = (t) => (/^eth$/i.test(t) ? 'ETH' : /^weth$/i.test(t) ? uniswap[cfg.chainId]?.weth : isAddress(t) ? getAddress(t) : null);
823
824
  server.registerTool('fides_swap', {
824
825
  title: 'Swap on Uniswap inside the FIDES',
825
- 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.',
826
+ description: 'Swaps on Uniswap on Robinhood Chain with FIDES ROUTER: gets a route through each kind of pool (v2, v3 and v4, including v4 pools with hooks), checks each against the FIDES and the trading mandate the owner signed (tokens it may buy, worst price, most per swap), simulates them, ranks them by output, and offers only the best valid one. Show the user routes_compared (the ranking, how far behind each route is, and why any are out) with the chosen route, before executing. 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.',
826
827
  inputSchema: {
827
828
  token_out: z.string().describe('What to buy: "ETH", "WETH" or a token address'),
828
829
  amount: z.string().describe('How much of the budget token to spend, e.g. "1"'),
@@ -850,28 +851,51 @@ export function createFidesMcpServer(cfg) {
850
851
  return fail(`"amount" must be a number of ${tIn.symbol}, like "1".`);
851
852
  }
852
853
  const slippageBps = max_slippage_percent ? Math.round(max_slippage_percent * 100) : m.maxSlippageBps;
853
- let q, blocked;
854
- if (cfg.api) {
855
- const r = await post('/v1/swap/quote', { fides, tokenOut: out, amount: value, slippageBps });
856
- q = r.quote;
857
- blocked = r.mandate?.allowed ? null : { code: r.mandate.code, message: r.mandate.message };
858
- }
859
- else {
860
- if (!process.env.UNISWAP_API_KEY)
861
- return fail('Swaps need the FIDES API, or UNISWAP_API_KEY when running without it.');
862
- q = await quoteUniswap({ apiKey: process.env.UNISWAP_API_KEY, fides, tokenOut: out, amount: value, slippageBps });
863
- blocked = checkMandate(fides, q);
854
+ if (!cfg.api && !process.env.UNISWAP_API_KEY)
855
+ return fail('Swaps need the FIDES API, or UNISWAP_API_KEY when running without it.');
856
+ // FIDES ROUTER: a route through each kind of Uniswap pool, each checked against the trading mandate and the
857
+ // FIDES, simulated and ranked by output. Only the best valid one is offered to execute.
858
+ const POOLS = [['uniswap v2', ['V2']], ['uniswap v3', ['V3']], ['uniswap v4', ['V4']]];
859
+ const quotes = new Map(), excluded = [];
860
+ await Promise.all(POOLS.map(async ([name, protocols]) => {
861
+ try {
862
+ let q, blocked;
863
+ if (cfg.api) {
864
+ const r = await post('/v1/swap/quote', { fides, tokenOut: out, amount: value, slippageBps, protocols });
865
+ q = r.quote;
866
+ blocked = r.mandate?.allowed ? null : { code: r.mandate.code, message: r.mandate.message };
867
+ }
868
+ else {
869
+ q = await quoteUniswap({ apiKey: process.env.UNISWAP_API_KEY, fides, tokenOut: out, amount: value, slippageBps, protocols });
870
+ blocked = checkMandate(fides, q);
871
+ }
872
+ if (blocked)
873
+ excluded.push({ route: name, why: `trading mandate: ${blocked.message}` });
874
+ else
875
+ quotes.set(name, q);
876
+ }
877
+ catch (err) {
878
+ excluded.push({ route: name, why: `no route: ${err?.message ?? err}` });
879
+ }
880
+ }));
881
+ if (!quotes.size) {
882
+ const mandate = excluded.find((x) => x.why.startsWith('trading mandate'));
883
+ return fail(mandate ? `Blocked by the ${mandate.why} Nothing was sent.` : `Uniswap has no route for this swap: ${excluded.map((x) => `${x.route} (${x.why})`).join('; ')}. Nothing was sent.`);
864
884
  }
865
- const outInfo = q.tokenOut === NATIVE ? { symbol: 'ETH', decimals: 18 } : await token(q.tokenOut);
885
+ const first = [...quotes.values()][0];
886
+ const outInfo = first.tokenOut === NATIVE ? { symbol: 'ETH', decimals: 18 } : await token(first.tokenOut);
866
887
  const fmt = (v) => `${formatUnits(v, outInfo.decimals)} ${outInfo.symbol}`;
867
- if (blocked)
868
- return fail(`Blocked by the trading mandate: ${blocked.message} Nothing was sent.`);
869
- const actions = swapActions(q, `swap ${amount} ${tIn.symbol} to ${outInfo.symbol} on Uniswap`);
870
- const plan = await new FidesBuilder({ client, fides, agent }).add(actions).build();
871
- if (plan.rejected().length) {
872
- const r = plan.rejected()[0];
873
- return fail(`Blocked by the FIDES: ${REASONS[r.reason ?? ''] ?? r.reason}. Nothing was sent.`);
874
- }
888
+ const label = `swap ${amount} ${tIn.symbol} to ${outInfo.symbol} on Uniswap`;
889
+ const routed = await route({ client, fides, agent }, [...quotes].map(([name, q]) => ({ name, actions: swapActions(q, label), expectedOut: q.quotedOut, minOut: q.minOut })));
890
+ const ranking = [
891
+ ...routed.ranked.map((x) => x.valid
892
+ ? { route: x.name, verdict: x === routed.best ? 'best, chosen' : 'valid', quoted: fmt(x.expectedOut ?? 0n), behind_best: `${(x.slippageBps ?? 0) / 100}%`, gas: String(x.gas), pools: describeRoute(quotes.get(x.name)) }
893
+ : { route: x.name, verdict: 'out', why: REASONS[x.reasons[0]?.split(': ')[1] ?? ''] ?? x.reasons[0] }),
894
+ ...excluded.map((x) => ({ route: x.route, verdict: 'out', why: x.why })),
895
+ ];
896
+ if (!routed.best)
897
+ return fail(`No route is inside the FIDES, so nothing can run: ${ranking.map((x) => `${x.route} (${x.why})`).join('; ')}. Nothing was sent.`);
898
+ const q = quotes.get(routed.best.name), plan = routed.plan(routed.best.name), actions = swapActions(q, label);
875
899
  const v = await verifySwap(client, fides, plan, q);
876
900
  const id = plan.planHash.slice(0, 18);
877
901
  if (v.ok)
@@ -879,6 +903,9 @@ export function createFidesMcpServer(cfg) {
879
903
  return ok({
880
904
  plan_id: v.ok ? id : null,
881
905
  ready_to_execute: v.ok,
906
+ router: `FIDES ROUTER compared ${ranking.length} routes, one per kind of Uniswap pool: each checked against the FIDES and the trading mandate, simulated, and ranked by output. Only the chosen route runs.`,
907
+ routes_compared: ranking,
908
+ chosen: routed.best.name,
882
909
  route: describeRoute(q),
883
910
  spend: `${amount} ${tIn.symbol}`,
884
911
  quoted: fmt(q.quotedOut),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fidesbuild/mcp-server",
3
- "version": "0.8.1",
3
+ "version": "0.9.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",
@@ -14,9 +14,14 @@
14
14
  "homepage": "https://fidesbuilder.xyz/docs",
15
15
  "license": "MIT",
16
16
  "type": "module",
17
- "exports": "./src/server.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/server.d.ts",
20
+ "default": "./dist/server.js"
21
+ }
22
+ },
18
23
  "bin": {
19
- "fides-mcp": "./src/server.ts"
24
+ "fides-mcp": "./dist/server.js"
20
25
  },
21
26
  "files": [
22
27
  "dist",
@@ -27,16 +32,7 @@
27
32
  "node": ">=20.10"
28
33
  },
29
34
  "publishConfig": {
30
- "access": "public",
31
- "exports": {
32
- ".": {
33
- "types": "./dist/server.d.ts",
34
- "default": "./dist/server.js"
35
- }
36
- },
37
- "bin": {
38
- "fides-mcp": "./dist/server.js"
39
- }
35
+ "access": "public"
40
36
  },
41
37
  "dependencies": {
42
38
  "@modelcontextprotocol/sdk": "^1.32.1",
@@ -46,6 +42,7 @@
46
42
  "@fidesbuild/x402": "0.2.0",
47
43
  "@fidesbuild/net": "0.2.1",
48
44
  "@fidesbuild/sub": "0.2.0",
49
- "@fidesbuild/swap": "0.2.0"
45
+ "@fidesbuild/swap": "0.2.1",
46
+ "@fidesbuild/router": "0.2.0"
50
47
  }
51
- }
48
+ }
package/src/server.ts DELETED
@@ -1,968 +0,0 @@
1
- #!/usr/bin/env node
2
- // fides-mcp — FIDES as tools for Claude and any other MCP client.
3
- //
4
- // npx -y @fidesbuild/mcp-server setup make this computer's agent key and print what to do next
5
- // npx -y @fidesbuild/mcp-server run the MCP server over stdio (what Claude starts)
6
- //
7
- // The agent key lives in ~/.fides/agent.json and holds no funds. It can only sign actions inside the
8
- // FIDES the owner signed for it. Bundles are relayed by the hosted FIDES API, which pays the gas,
9
- // unless FIDES_RELAY=self, in which case the agent key pays.
10
- import fs from 'node:fs';
11
- import os from 'node:os';
12
- import path from 'node:path';
13
- import { pathToFileURL } from 'node:url';
14
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
- import { z } from 'zod';
17
- import { encodeFunctionData, decodeFunctionResult, erc20Abi, formatUnits, parseUnits, isAddress, getAddress, type Address, type Hex, type AbiFunction, type AbiParameter } from 'viem';
18
- import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts';
19
- import { Fides, FidesBuilder, Policy, Receipts, accountOf, createFidesClient, fromJson, toJson, type SignedFides, type ActionInput, type ExecutionPlan, type FidesClient, type FidesAddresses } from '@fidesbuild/sdk';
20
- import { createX402, decodePaymentRequired, exactEip3009 } from '@fidesbuild/x402';
21
- import { netObligations, settle, netRoots, PUBLISH_SELECTOR, NetError, type Obligation } from '@fidesbuild/net';
22
- import { createSubClient, SubFidesKit, SubAgent, SubFidesError, subDeployments, type SignedSubFides, type SubAddresses, type SubPlan } from '@fidesbuild/sub';
23
- import { quoteUniswap, checkMandate, swapActions, verifySwap, describeRoute, mandateOf, uniswap, NATIVE, SwapError, type SwapQuote } from '@fidesbuild/swap';
24
-
25
- export type Config = {
26
- /** Folder for agent.json and the default fides.json. */
27
- home: string;
28
- rpc: string;
29
- chainId: number;
30
- /** The relaying API, or null to have the agent key pay gas itself. */
31
- api: string | null;
32
- /** The FIDES API, asked for the FIDES signed for this agent. Null to only use fidesFile. */
33
- directory: string | null;
34
- /** The FIDES app, for the setup link. */
35
- app: string;
36
- fidesFile: string;
37
- addresses?: FidesAddresses;
38
- /** SUB-FIDES contracts, when not the published ones for the chain. */
39
- sub?: SubAddresses;
40
- agentKey?: Hex;
41
- };
42
-
43
- export function configFromEnv(env: NodeJS.ProcessEnv = process.env): Config {
44
- const home = env.FIDES_HOME ?? path.join(os.homedir(), '.fides');
45
- return {
46
- home,
47
- rpc: env.FIDES_RPC ?? 'https://rpc.mainnet.chain.robinhood.com',
48
- chainId: Number(env.FIDES_CHAIN_ID ?? 4663),
49
- api: env.FIDES_RELAY === 'self' ? null : (env.FIDES_API ?? 'https://api.fidesbuilder.xyz').replace(/\/$/, ''),
50
- directory: env.FIDES_LOOKUP === 'off' ? null : (env.FIDES_API ?? 'https://api.fidesbuilder.xyz').replace(/\/$/, ''),
51
- app: (env.FIDES_APP ?? 'https://fidesbuilder.xyz/app').replace(/\/$/, ''),
52
- fidesFile: env.FIDES_FILE ?? path.join(home, 'fides.json'),
53
- addresses: env.FIDES_ADDRESSES ? JSON.parse(fs.readFileSync(env.FIDES_ADDRESSES, 'utf8')) : undefined,
54
- sub: env.FIDES_SUB_ADDRESSES ? JSON.parse(fs.readFileSync(env.FIDES_SUB_ADDRESSES, 'utf8')) : undefined,
55
- agentKey: env.FIDES_AGENT_KEY as Hex | undefined,
56
- };
57
- }
58
-
59
- /** This computer's agent key: from FIDES_AGENT_KEY, or ~/.fides/agent.json, made on first use. */
60
- export function agentKey(cfg: Config): { account: PrivateKeyAccount; file: string | null; created: boolean } {
61
- if (cfg.agentKey) return { account: privateKeyToAccount(cfg.agentKey), file: null, created: false };
62
- const file = path.join(cfg.home, 'agent.json');
63
- if (fs.existsSync(file)) return { account: privateKeyToAccount(JSON.parse(fs.readFileSync(file, 'utf8')).privateKey), file, created: false };
64
- fs.mkdirSync(cfg.home, { recursive: true, mode: 0o700 });
65
- const privateKey = generatePrivateKey();
66
- const account = privateKeyToAccount(privateKey);
67
- fs.writeFileSync(file, JSON.stringify({ address: account.address, privateKey, note: 'FIDES agent key. It holds no funds and can only act inside the FIDES signed for it. Keep this file private.' }, null, 2) + '\n', { mode: 0o600 });
68
- return { account, file, created: true };
69
- }
70
-
71
- const EXPLORER: Record<number, string> = { 4663: 'https://robinhoodchain.blockscout.com' };
72
- const SELECTORS: Record<string, string> = { '0xa9059cbb': 'transfer', '0x095ea7b3': 'approve', '0x23b872dd': 'transferFrom' };
73
- const REASONS: Record<string, string> = {
74
- SPEND_LIMIT_EXCEEDED: 'over the FIDES’s budget', ASSET_NOT_ALLOWED: 'token not allowed by the FIDES', TARGET_NOT_ALLOWED: 'contract not allowed by the FIDES',
75
- SELECTOR_NOT_ALLOWED: 'function not allowed by the FIDES', NATIVE_VALUE_EXCEEDED: 'sends more ETH than the FIDES allows', ACTION_LIMIT_EXCEEDED: 'over the FIDES’s action limit',
76
- FIDES_EXPIRED: 'the FIDES has expired', FIDES_REVOKED: 'the FIDES has been revoked', DEPENDENCY_REJECTED: 'depends on a blocked action',
77
- };
78
-
79
- /** Simulation and relay failures, in words. */
80
- function failure(reason: unknown): string {
81
- const r = String(reason ?? '');
82
- if (/InsufficientBalance|InsufficientFunds|0x356680b7|exceeds balance/i.test(r)) return 'The FIDES Account does not hold enough of the token. The owner needs to fund it.';
83
- if (/SpendLimitExceeded|AssetOutflowExceeded/.test(r)) return 'This would go over the FIDES’s budget, counting what it has already spent.';
84
- if (/TargetNotAllowed|SelectorNotAllowed/.test(r)) return 'This calls a contract or function the FIDES does not allow.';
85
- if (/FidesRevoked/.test(r)) return 'The FIDES has been revoked.';
86
- if (/FidesExpired|FidesNotYetValid/.test(r)) return 'The FIDES is outside its valid time window.';
87
- if (/NonceAlreadyUsed/.test(r)) return 'This plan was already executed. Make a new plan.';
88
- if (/RELAYER_BUDGET_EXHAUSTED|RELAYER_OUT_OF_GAS_MONEY|NO_RELAYER/.test(r)) return 'The hosted relayer cannot pay for gas right now. Try later, or set FIDES_RELAY=self and give the agent address a little ETH.';
89
- return r;
90
- }
91
-
92
- // Paid APIs that have been bought through FIDES with real payments on mainnet (the docs list the same ones).
93
- const KNOWN_SERVICES = [
94
- { id: 'fides-demo', name: 'FIDES demo', url: 'https://api.fidesbuilder.xyz/v1/demo/chain-stats', price: '0.01 USDG', returns: 'Robinhood Chain\'s latest block, gas price and USDG supply', run_by: 'FIDES' },
95
- { id: 'spraay', name: 'Spraay', url: 'https://gateway.spraay.app/api/v1/models', price: '0.001 USDG', returns: 'The AI models the Spraay gateway offers, with providers and prices', run_by: 'Spraay, not FIDES' },
96
- { id: 'madeonsol', name: 'MadeOnSol', url: 'https://madeonsol.com/api/x402/rhc/kol/feed?limit=5', price: '0.04 USDG', returns: 'Recent trades on Robinhood Chain by tracked wallets', run_by: 'MadeOnSol, not FIDES' },
97
- ];
98
-
99
- /** Turns plain JSON values into what viem encodes for each ABI type. "me" is the FIDES Account. */
100
- export function coerce(p: AbiParameter, v: unknown, me: Address | null, at: string): unknown {
101
- const arr = p.type.match(/^(.*)\[(\d*)\]$/);
102
- if (arr) {
103
- if (!Array.isArray(v)) throw new Error(`${at} must be a list`);
104
- return v.map((x, i) => coerce({ ...p, type: arr[1] } as AbiParameter, x, me, `${at}[${i}]`));
105
- }
106
- if (p.type === 'tuple') {
107
- const parts = (p as any).components as AbiParameter[];
108
- const get = (c: AbiParameter, i: number) => (Array.isArray(v) ? v[i] : (v as any)?.[c.name ?? '']);
109
- if (v === null || typeof v !== 'object') throw new Error(`${at} must be an object with ${parts.map((c) => c.name).join(', ')}`);
110
- const out = parts.map((c, i) => coerce(c, get(c, i), me, `${at}.${c.name ?? i}`));
111
- return Array.isArray(v) || parts.some((c) => !c.name) ? out : Object.fromEntries(parts.map((c, i) => [c.name, out[i]]));
112
- }
113
- if (/^u?int\d*$/.test(p.type)) {
114
- if (!/^-?\d+$/.test(String(v ?? '').trim())) throw new Error(`${at} must be a whole number in the token's smallest unit, as a string (got ${JSON.stringify(v)})`);
115
- return BigInt(String(v).trim());
116
- }
117
- if (p.type === 'address') {
118
- if (typeof v === 'string' && /^me$/i.test(v)) { if (!me) throw new Error(`${at}: "me" needs a signed FIDES`); return me; }
119
- if (typeof v !== 'string' || !isAddress(v)) throw new Error(`${at} must be an address`);
120
- return getAddress(v);
121
- }
122
- if (p.type === 'bool') return v === true || v === 'true';
123
- if (/^bytes\d*$/.test(p.type)) {
124
- const h = v === '' || v == null ? '0x' : String(v);
125
- if (!/^0x([0-9a-fA-F]{2})*$/.test(h)) throw new Error(`${at} must be hex bytes, like "0x"`);
126
- return h;
127
- }
128
- return v;
129
- }
130
-
131
- /** Encodes a call from one ABI function and plain JSON args. */
132
- export function encodeCall(abi: AbiFunction, args: unknown[], me: Address | null): Hex {
133
- const values = abi.inputs.map((p, i) => coerce(p, args[i], me, p.name || `arg ${i + 1}`));
134
- return encodeFunctionData({ abi: [abi], functionName: abi.name, args: values as any });
135
- }
136
-
137
- const ok = (value: unknown) =>({ content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] });
138
- const fail = (message: string) => ({ content: [{ type: 'text' as const, text: message }], isError: true });
139
-
140
- export function createFidesMcpServer(cfg: Config) {
141
- const agent = agentKey(cfg).account;
142
- const client: FidesClient = createFidesClient({ chainId: cfg.chainId, rpc: cfg.rpc, addresses: cfg.addresses });
143
- const explorer = EXPLORER[cfg.chainId];
144
- const txLink = (h?: string) => (h && explorer ? `${explorer}/tx/${h}` : h);
145
- const plans = new Map<string, { plan: ExecutionPlan; actions: ActionInput[]; fides: SignedFides; net?: { epoch: bigint; obligationsRoot: Hex; settlementRoot: Hex }; swap?: { quote: SwapQuote; outDecimals: number; outSymbol: string } }>();
146
- const tokens = new Map<string, { symbol: string; decimals: number }>();
147
- // SUB-FIDES: a FIDES on a V2 account can hand slices of itself to other agents.
148
- const subAddrs = cfg.sub ?? subDeployments[cfg.chainId];
149
- const subClient = subAddrs ? createSubClient({ chainId: cfg.chainId, rpc: cfg.rpc, base: cfg.addresses, addresses: subAddrs }) : null;
150
- const subPlans = new Map<string, { agent: SubAgent; plan: SubPlan; parent: SignedFides; sub: SignedSubFides }>();
151
- const subsDir = path.join(cfg.home, 'subs');
152
-
153
- const token = async (asset: Address) => {
154
- const key = asset.toLowerCase();
155
- if (!tokens.has(key)) {
156
- const [symbol, decimals] = await Promise.all([
157
- client.public.readContract({ address: asset, abi: erc20Abi, functionName: 'symbol' }).catch(() => 'TOKEN'),
158
- client.public.readContract({ address: asset, abi: erc20Abi, functionName: 'decimals' }).catch(() => 18),
159
- ]);
160
- tokens.set(key, { symbol: String(symbol), decimals: Number(decimals) });
161
- }
162
- return tokens.get(key)!;
163
- };
164
-
165
- const setupLink = `${cfg.app}/#/create?agent=${agent.address}&via=claude`;
166
- const notLoaded = () => `No FIDES is signed for this agent yet. This computer's agent address is ${agent.address}.\n\n` +
167
- `To set one up, the user opens this link: ${setupLink}\n` +
168
- 'It opens the FIDES app with this agent filled in. They set the budget, contracts and expiry, sign with their wallet, then create and fund their FIDES Account. ' +
169
- 'Then they come back here: the signed FIDES is picked up automatically, with nothing to download.';
170
-
171
- function readFides(): SignedFides | null {
172
- if (!fs.existsSync(cfg.fidesFile)) return null;
173
- return fromJson(fs.readFileSync(cfg.fidesFile, 'utf8')) as SignedFides;
174
- }
175
- const keep = (fides: SignedFides) => { fs.mkdirSync(path.dirname(cfg.fidesFile), { recursive: true }); fs.writeFileSync(cfg.fidesFile, toJson(fides, 2) + '\n'); };
176
-
177
- // The FIDES to act under: the newest valid one signed for this agent, from the copy kept here or from
178
- // the FIDES API, where the app registers a FIDES when the owner signs it. Each is verified onchain here.
179
- async function currentFides(): Promise<SignedFides | null> {
180
- const mine = (o: SignedFides | null) => Boolean(o && String(o.agent).toLowerCase() === agent.address.toLowerCase() && Number(o.chainId) === cfg.chainId);
181
- const local = readFides();
182
- const candidates: SignedFides[] = mine(local) ? [local!] : [];
183
- if (cfg.directory) {
184
- try {
185
- const res = await fetch(`${cfg.directory}/v1/agents/${agent.address}/fides`);
186
- if (res.ok) for (const x of (fromJson(await res.text()) as any).fidesList ?? []) if (mine(x.fides) && x.status?.valid) candidates.push(x.fides);
187
- } catch { /* the API is optional: the copy kept here still works */ }
188
- }
189
- const valid: SignedFides[] = [];
190
- for (const o of candidates) if ((await Fides.verify(client, o).catch(() => null))?.valid) valid.push(o);
191
- valid.sort((a, b) => Number(BigInt(b.validAfter) - BigInt(a.validAfter)));
192
- const best = valid[0];
193
- if (best && (!local || local.id !== best.id)) { keep(best); plans.clear(); }
194
- return best ?? local;
195
- }
196
-
197
- async function summary(fides: SignedFides) {
198
- const t = await token(fides.spendLimits[0].asset);
199
- const [check, [spend], balance, code] = await Promise.all([
200
- Fides.verify(client, fides),
201
- Policy.remainingSpend(client, fides),
202
- client.public.readContract({ address: fides.spendLimits[0].asset, abi: erc20Abi, functionName: 'balanceOf', args: [fides.account] }),
203
- client.public.getCode({ address: fides.account }),
204
- ]);
205
- const amount = (v: bigint) => `${formatUnits(v, t.decimals)} ${t.symbol}`;
206
- const outside = await stray(fides.spendLimits[0].asset);
207
- const status = check.revoked ? 'revoked' : check.expired ? 'expired' : check.notYetValid ? 'not valid yet' : !check.validSignature || !check.idMatches ? 'signature does not verify' : 'active';
208
- return {
209
- status,
210
- fides: fides.id,
211
- owner: fides.owner,
212
- agent: fides.agent,
213
- fides_account: fides.account,
214
- fides_account_created: Boolean(code),
215
- fides_account_balance: amount(balance as bigint),
216
- budget: amount(spend.limit),
217
- spent: amount(spend.spent),
218
- remaining: amount(spend.remaining),
219
- budget_token: fides.spendLimits[0].asset,
220
- expires: new Date(Number(fides.validUntil) * 1000).toISOString(),
221
- allowed_contracts: fides.targets.map((r) => ({ contract: r.target, functions: r.selectors.map((s) => SELECTORS[s.toLowerCase()] ? `${SELECTORS[s.toLowerCase()]} (${s})` : s) })),
222
- allowed_services: fides.services.map((s) => s.serviceId),
223
- max_actions: fides.maxActions.toString(),
224
- relay: cfg.api ? `hosted relayer at ${cfg.api} pays the gas` : `the agent key ${agent.address} pays the gas`,
225
- ...(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.` } : {}),
226
- };
227
- }
228
-
229
- const server = new McpServer({ name: 'fides', version: '0.8.0' }, {
230
- 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. ' +
231
- '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, ' +
232
- '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". ' +
233
- 'When the user asks what you can do with FIDES, which paid APIs or services there are, or how to start, call fides_help and answer from it: give the paid APIs by name with their price and suggest a next step. ' +
234
- 'A paid service\'s id is a name the user chooses when signing, like "spraay"; nobody issues it. Any API that charges USDG on Robinhood Chain over x402 (exact, EIP-3009) works, not only the ones fides_help lists. ' +
235
- 'When the user has many payments owed between their agent and services (or other parties), fides_net nets them to final positions and builds the FIDES Account\'s side of the settlement, plus the publication of the round\'s roots onchain, into one plan for fides_execute.',
236
- });
237
-
238
- server.registerTool('fides_help', {
239
- title: 'What FIDES can do',
240
- description: 'Use when the user asks what you can do with FIDES, what paid APIs or services are available, what to try, or how to get started. Returns what FIDES can do, paid APIs known to work with their URL and price, example requests, and the next step for this user.',
241
- inputSchema: {},
242
- }, async () => {
243
- let fides: SignedFides | null = null;
244
- try { fides = await currentFides(); } catch { /* answer without it */ }
245
- const allowed = fides?.services.map((s) => s.serviceId) ?? [];
246
- return ok({
247
- can_do: [
248
- 'Send USDG from the FIDES Account, to one address or many, inside the budget. Several transfers go out as one transaction.',
249
- 'Use any verified contract on Robinhood Chain: read what it lets an agent do, risky functions flagged (fides_inspect_contract), allow the functions you choose (fides_setup with contracts), read values from it for free (fides_read_contract), and call it by function name and plain values (fides_plan with function and args). Example: supply USDG to a Morpho market and withdraw it, never borrow.',
250
- 'Use any paid API that charges on Robinhood Chain: read its price without paying (fides_check_api), allow it with a cap (fides_setup with services), then pay per request (fides_pay_request).',
251
- 'Buy from paid APIs that charge per request over x402, paying from the FIDES budget, each capped per service.',
252
- '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.',
253
- '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.',
254
- '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.',
255
- 'Show the budget spent and left, what is allowed, the expiry, and every transaction executed, with explorer links.',
256
- '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.',
257
- ],
258
- paid_apis: KNOWN_SERVICES.map((s) => ({ ...s, in_this_fides: allowed.includes(s.id) })),
259
- other_paid_apis: 'Any API that charges USDG on Robinhood Chain over x402 (exact scheme, EIP-3009) works the same way. The user adds it as a service with a cap when signing (the id is any name they choose), then gives you its URL.',
260
- try_saying: [
261
- 'Set up FIDES with these paid services: fides-demo up to 0.1 USDG, spraay up to 0.01 USDG, madeonsol up to 0.1 USDG',
262
- 'Pay 0.5 USDG to 0x... and 0.5 USDG to 0x...',
263
- 'Buy the chain stats from the FIDES demo',
264
- 'What\'s left in my FIDES?',
265
- 'Show what you\'ve executed',
266
- ],
267
- next_step: !fides ? 'No FIDES is set up yet. Offer to call fides_setup, with any paid services the user wants and a cap for each.'
268
- : !allowed.length ? 'This FIDES has no paid services. To use a paid API, call fides_setup with the services and caps, and the user signs the new FIDES.'
269
- : `This FIDES allows these paid services: ${allowed.join(', ')}. Make sure the FIDES Account holds USDG (fides_status shows the balance).`,
270
- docs: 'https://fidesbuilder.xyz/docs#/claude',
271
- });
272
- });
273
-
274
- server.registerTool('fides_status', {
275
- title: 'FIDES status',
276
- description: 'The loaded FIDES: whether it is active, the budget spent and remaining, the FIDES Account balance, the allowed contracts and the expiry. Also gives this computer\'s agent address: use it when the user asks for their FIDES agent address. With no FIDES loaded, explains how to set one up.',
277
- inputSchema: {},
278
- }, async () => {
279
- try {
280
- const fides = await currentFides();
281
- if (!fides) return ok(notLoaded());
282
- if (fides.agent.toLowerCase() !== agent.address.toLowerCase()) return fail(`The loaded FIDES is for agent ${fides.agent}, but this computer's agent key is ${agent.address}. Create a FIDES for ${agent.address}.`);
283
- return ok(await summary(fides));
284
- } catch (err: any) { return fail('Could not read the FIDES: ' + (err?.shortMessage ?? err?.message ?? err)); }
285
- });
286
-
287
- server.registerTool('fides_setup', {
288
- title: 'Set up FIDES',
289
- 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). When a function pulls tokens from the account (such as Morpho supply, or a vault deposit), also allow approve on that token, so the agent can approve exactly the amount in the same transaction.',
290
- inputSchema: {
291
- 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(),
292
- contracts: z.array(z.object({
293
- address: z.string().describe('The contract address'),
294
- functions: z.array(z.string()).min(1).describe('Function names (e.g. "supply", "withdraw") or 4-byte selectors. fides_inspect_contract lists them'),
295
- })).optional().describe('Any contracts on the chain the agent may call, and exactly which functions; everything else on them stays blocked'),
296
- net: z.boolean().optional().describe('true to allow FIDES NET: lets the FIDES publish netting roots on FidesNetRoots, which fides_net needs'),
297
- delegate: z.boolean().optional().describe('true to let this agent hand slices of the FIDES to other agents (SUB-FIDES), which fides_delegate needs'),
298
- swap: z.object({
299
- buy: z.array(z.string()).min(1).describe('Tokens the agent may buy: "ETH", "WETH" or addresses'),
300
- max_slippage_percent: z.number().positive().max(50).optional().describe('The worst price it may accept against the quote, in percent. Default 1'),
301
- max_per_swap: z.string().optional().describe('The most one swap may spend, in the budget token. Default "1"'),
302
- }).optional().describe('Let the agent swap on Uniswap inside a trading mandate the owner signs; fides_swap needs it'),
303
- },
304
- }, async ({ services, net, delegate, swap, contracts }) => {
305
- let targets = '';
306
- const added: string[] = [];
307
- for (const c of contracts ?? []) {
308
- if (!isAddress(c.address)) return fail(`"${c.address}" is not a contract address.`);
309
- const sels = c.functions.filter((x) => /^0x[0-9a-fA-F]{8}$/.test(x)).map((x) => x.toLowerCase());
310
- const names = c.functions.filter((x) => !/^0x[0-9a-fA-F]{8}$/.test(x));
311
- let label = c.address;
312
- if (names.length) {
313
- let info: any;
314
- try { info = await inspect(c.address); } catch (err: any) { return fail(`Could not read ${c.address}: ${err?.message ?? err}`); }
315
- if (!info.verified) return fail(`${c.address} is not verified, so its functions cannot be found by name. Give their 4-byte selectors instead.`);
316
- label = info.name ?? c.address;
317
- for (const n of names) {
318
- const fn = info.functions.find((x: any) => x.name === n || x.signature.replace(/ \w+(?=[,)])/g, '') === n);
319
- if (!fn) return fail(`${label} has no function "${n}" that changes anything. It has: ${info.functions.map((x: any) => x.name).join(', ')}.`);
320
- sels.push(fn.selector);
321
- }
322
- }
323
- targets += `&target=${getAddress(c.address)}:${[...new Set(sels)].join(',')}`;
324
- added.push(`${label}: ${c.functions.join(', ')}`);
325
- }
326
- return ok({
327
- agent_address: agent.address,
328
- 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')}` : ''),
329
- services: services?.length ? services.map((x) => `${x.id}: up to ${x.max} USDG`) : undefined,
330
- contracts: added.length ? added : undefined,
331
- steps: [
332
- 'Open the link. It opens the FIDES app with this agent already filled in.',
333
- 'Connect your wallet. Set the budget, the contracts allowed and the expiry. Start small.',
334
- 'Sign (free), click Create FIDES Account (a little gas), and send the budget to the FIDES Account address shown.',
335
- 'Come back and ask me for what you want. I pick up the signed FIDES by myself.',
336
- ],
337
- });
338
- });
339
-
340
- server.registerTool('fides_use_file', {
341
- title: 'Use a FIDES file',
342
- 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.',
343
- inputSchema: { path: z.string().describe('Full path to the fides.json file, e.g. C:\\Users\\me\\Downloads\\fides.json') },
344
- }, async ({ path: file }) => {
345
- try {
346
- const full = file.replace(/^~(?=$|[\\/])/, os.homedir());
347
- if (!fs.existsSync(full)) return fail(`No file at ${full}.`);
348
- let fides: SignedFides;
349
- try { fides = fromJson(fs.readFileSync(full, 'utf8')) as SignedFides; } catch { return fail(`${full} is not valid JSON.`); }
350
- if (!fides || !fides.id || !fides.signature || !fides.agent || !Array.isArray(fides.spendLimits)) return fail('That file is not a signed FIDES. In the FIDES app, open the FIDES and click "Download signed FIDES".');
351
- if (fides.agent.toLowerCase() !== agent.address.toLowerCase()) return fail(`That FIDES is for agent ${fides.agent}, but this computer's agent key is ${agent.address}. Create a new FIDES in the app with "An external agent" and paste ${agent.address}.`);
352
- if (Number(fides.chainId) !== cfg.chainId) return fail(`That FIDES is for chain ${fides.chainId}; this server is set up for chain ${cfg.chainId}.`);
353
- keep(fides);
354
- plans.clear();
355
- return ok({ loaded: cfg.fidesFile, ...(await summary(fides)) });
356
- } catch (err: any) { return fail('Could not load the FIDES: ' + (err?.shortMessage ?? err?.message ?? err)); }
357
- });
358
-
359
- const actionSchema = z.object({
360
- kind: z.enum(['transfer', 'call']).describe('"transfer" sends a token from the FIDES Account. "call" calls an allowed contract, by function name and args (encoded from its verified source), or with raw calldata.'),
361
- to: z.string().optional().describe('transfer: the recipient address'),
362
- amount: z.string().optional().describe('transfer: how much, in whole token units, e.g. "1.5". call: how much of the budget token the call spends, if any, in whole units'),
363
- token: z.string().optional().describe('transfer: the token address. Defaults to the FIDES\'s budget token'),
364
- target: z.string().optional().describe('call: the contract address'),
365
- function: z.string().optional().describe('call: the function name, e.g. "supply", or its signature when the name is overloaded. FIDES encodes the call from the contract\'s verified source'),
366
- args: z.array(z.any()).optional().describe('call with function: the arguments in order. Integers as strings in the token\'s smallest unit (USDG has 6 decimals: 0.05 USDG is "50000"). Structs as objects keyed by field name. "me" for an address means this FIDES Account. Empty bytes as "0x"'),
367
- data: z.string().optional().describe('call: hex calldata, starting with the 4-byte function selector. Use function and args instead when you can'),
368
- label: z.string().optional().describe('A short description shown in the plan'),
369
- });
370
-
371
- /** The tool's actions as Builder actions, in the budget token unless a transfer names another. */
372
- async function buildActions(actions: z.infer<typeof actionSchema>[], budget: Address): Promise<{ built: ActionInput[] } | { error: string }> {
373
- const built: ActionInput[] = [];
374
- for (let [i, a] of actions.entries()) {
375
- const n = i + 1;
376
- if (a.kind === 'transfer') {
377
- if (!a.to || !isAddress(a.to)) return { error: `Action ${n}: "to" must be an address.` };
378
- const asset = a.token ? (isAddress(a.token) ? getAddress(a.token) : null) : budget;
379
- if (!asset) return { error: `Action ${n}: "token" must be an address.` };
380
- const t = await token(asset);
381
- let amount: bigint;
382
- try { amount = parseUnits(String(a.amount ?? ''), t.decimals); } catch { return { error: `Action ${n}: "amount" must be a number of ${t.symbol}, like "1.5".` }; }
383
- if (amount <= 0n) return { error: `Action ${n}: "amount" must be more than zero.` };
384
- 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 },
385
- payload: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [getAddress(a.to), amount] }) } as ActionInput);
386
- } else {
387
- if (!a.target || !isAddress(a.target)) return { error: `Action ${n}: "target" must be a contract address.` };
388
- if (a.function && !a.data) {
389
- const enc = await encodeByName(getAddress(a.target), a.function, a.args ?? [], 'write');
390
- if ('error' in enc) return { error: `Action ${n}: ${enc.error}` };
391
- a = { ...a, data: enc.data, label: a.label ?? enc.label };
392
- }
393
- if (!a.data || !/^0x([0-9a-fA-F]{2}){4,}$/.test(a.data)) return { error: `Action ${n}: give "function" and "args", or "data" as hex calldata starting with the 4-byte selector.` };
394
- const t = await token(budget);
395
- let spend: bigint = 0n;
396
- if (a.amount) { try { spend = parseUnits(a.amount, t.decimals); } catch { return { error: `Action ${n}: "amount" must be a number of ${t.symbol}.` }; } }
397
- 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 as Hex,
398
- ...(spend > 0n ? { spend: { asset: budget, amount: spend } } : {}) } as ActionInput);
399
- }
400
- }
401
- return { built };
402
- }
403
-
404
- server.registerTool('fides_plan', {
405
- title: 'Plan actions under the FIDES',
406
- 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. Call contracts by function name and args; FIDES encodes them from the verified source. When a contract pulls tokens (e.g. Morpho supply), put an approve of exactly that amount first in the same plan, so nothing stays approved, and set amount on the call so it counts against the budget. Use "me" for addresses that should be the FIDES Account.',
407
- inputSchema: { actions: z.array(actionSchema).min(1).max(50) },
408
- }, async ({ actions }) => {
409
- try {
410
- const fides = await currentFides();
411
- if (!fides) return fail(notLoaded());
412
- if (fides.agent.toLowerCase() !== agent.address.toLowerCase()) return fail(`The loaded FIDES is for agent ${fides.agent}, not this computer's agent key ${agent.address}.`);
413
- const made = await buildActions(actions, fides.spendLimits[0].asset);
414
- if ('error' in made) return fail(made.error);
415
- const built = made.built;
416
- const plan = await new FidesBuilder({ client, fides, agent }).add(built).build();
417
- const savings = plan.estimatedSavings();
418
- const simulations = savings.estimatedTransactions ? await plan.simulate() : [];
419
- const failed = simulations.filter((s) => !s.success);
420
- const id = plan.planHash.slice(0, 18);
421
- if (savings.estimatedTransactions && !failed.length) plans.set(id, { plan, actions: built, fides });
422
- return ok({
423
- plan_id: savings.estimatedTransactions && !failed.length ? id : null,
424
- ready_to_execute: Boolean(savings.estimatedTransactions && !failed.length),
425
- actions: plan.actions().map((a) => ({ action: a.label, decision: a.decision === 'AUTHORIZED' ? 'inside the FIDES' : a.decision === 'REJECTED' ? `blocked: ${REASONS[a.reason ?? ''] ?? a.reason} (${a.reason})` : 'duplicate, sent once' })),
426
- transactions: `${savings.baselineTransactions} onchain write(s) go out as ${savings.estimatedTransactions} transaction(s)`,
427
- simulation: !simulations.length ? 'nothing to execute: no action is inside the FIDES' : failed.length ? `would fail: ${failure(failed[0].revertReason)}` : 'simulated against the chain: it would succeed',
428
- sent: 'nothing has been sent',
429
- });
430
- } catch (err: any) { return fail('Could not build the plan: ' + (err?.shortMessage ?? err?.message ?? err)); }
431
- });
432
-
433
- const obligationSchema = z.object({
434
- from: z.string().describe('Who owes: an address, or "me" for this FIDES Account'),
435
- to: z.string().describe('Who is owed: an address, or "me" for this FIDES Account'),
436
- amount: z.string().describe('How much, in whole token units, e.g. "1.5"'),
437
- ref: z.string().optional().describe('What it was for, e.g. a job or invoice id. Goes into the obligations root'),
438
- });
439
-
440
- server.registerTool('fides_net', {
441
- title: 'Net and settle what is owed (FIDES NET)',
442
- description: 'Takes the payments owed between this FIDES Account ("me") and other parties, nets them to one final position per party, and builds the FIDES Account\'s side of the settlement into one plan: its net transfers plus the publication of the round\'s two roots (every obligation, every settling transfer) on FidesNetRoots, so payments and the public record go out in the same transaction. Checks every action against the FIDES and simulates. Sends nothing: returns a plan_id for fides_execute. Transfers owed by other parties are listed, not sent. Show the result to the user before executing.',
443
- inputSchema: { obligations: z.array(obligationSchema).min(1).max(200), token: z.string().optional().describe('The token address. Defaults to the FIDES\'s budget token') },
444
- }, async ({ obligations, token: tokenArg }) => {
445
- try {
446
- const fides = await currentFides();
447
- if (!fides) return fail(notLoaded());
448
- if (fides.agent.toLowerCase() !== agent.address.toLowerCase()) return fail(`The loaded FIDES is for agent ${fides.agent}, not this computer's agent key ${agent.address}.`);
449
- const roots = netRoots[cfg.chainId];
450
- if (!roots) return fail(`FIDES NET is not deployed on chain ${cfg.chainId}.`);
451
- const allowsRoots = fides.targets.some((t) => t.target.toLowerCase() === roots.toLowerCase() && t.selectors.some((x) => x.toLowerCase() === PUBLISH_SELECTOR));
452
- if (!allowsRoots) return fail(`This FIDES does not allow FIDES NET to publish its roots on ${roots}. The user signs a new FIDES that does: call fides_setup with net: true (and the same services, if any) and give them the link.`);
453
- const asset = tokenArg ? (isAddress(tokenArg) ? getAddress(tokenArg) : null) : fides.spendLimits[0].asset;
454
- if (!asset) return fail('"token" must be an address.');
455
- const t = await token(asset);
456
- const party = (x: string, n: number, side: string) => {
457
- if (x.trim().toLowerCase() === 'me') return fides.account;
458
- if (!isAddress(x)) throw new NetError(`Obligation ${n}: "${side}" must be an address or "me".`);
459
- return getAddress(x);
460
- };
461
- const list: Obligation[] = obligations.map((o, i) => {
462
- let amount: bigint;
463
- try { amount = parseUnits(o.amount, t.decimals); } catch { throw new NetError(`Obligation ${i + 1}: "amount" must be a number of ${t.symbol}, like "1.5".`); }
464
- return { from: party(o.from, i + 1, 'from'), to: party(o.to, i + 1, 'to'), asset, amount, fidesId: fides.id, ref: o.ref ?? `${i + 1}` };
465
- });
466
- const s = netObligations(list);
467
- const p = settle(s, { account: fides.account, chainId: cfg.chainId, decimals: t.decimals, symbol: t.symbol });
468
- const plan = await new FidesBuilder({ client, fides, agent }).add(p.actions).build();
469
- const savings = plan.estimatedSavings();
470
- const simulations = savings.estimatedTransactions ? await plan.simulate() : [];
471
- const failed = simulations.filter((x) => !x.success);
472
- const ready = Boolean(savings.estimatedTransactions && !failed.length && !plan.rejected().length);
473
- const id = plan.planHash.slice(0, 18);
474
- if (ready) plans.set(id, { plan, actions: p.actions, fides, net: { epoch: p.epoch, obligationsRoot: s.obligationsRoot, settlementRoot: s.settlementRoot } });
475
- const amt = (v: bigint) => `${formatUnits(v < 0n ? -v : v, t.decimals)} ${t.symbol}`;
476
- const name = (a: string) => (a.toLowerCase() === fides.account.toLowerCase() ? 'me' : getAddress(a));
477
- const book = s.positions[asset.toLowerCase()] ?? {};
478
- return ok({
479
- plan_id: ready ? id : null,
480
- ready_to_execute: ready,
481
- gross: `${s.gross.obligations} obligations, ${amt(s.gross.volume[asset.toLowerCase()] ?? 0n)} moving`,
482
- net: `${s.net.transfers} transfer(s), ${amt(s.net.volume[asset.toLowerCase()] ?? 0n)} moving`,
483
- positions: Object.entries(book).map(([who, v]) => `${name(who)}: ${v < 0n ? 'pays' : 'receives'} ${amt(v)}`),
484
- my_transfers: p.own.map((x) => `pay ${amt(x.amount)} to ${getAddress(x.to)}`),
485
- settled_by_others: p.others.map((x) => `${name(x.from)} pays ${amt(x.amount)} to ${name(x.to)} (not sent by this FIDES)`),
486
- actions: plan.actions().map((a) => ({ action: a.label, decision: a.decision === 'AUTHORIZED' ? 'inside the FIDES' : a.decision === 'REJECTED' ? `blocked: ${REASONS[a.reason ?? ''] ?? a.reason} (${a.reason})` : 'duplicate, sent once' })),
487
- transactions: `${savings.baselineTransactions} onchain write(s) go out as ${savings.estimatedTransactions} transaction(s)`,
488
- record: { contract: roots, epoch: p.epoch.toString(), obligations_root: s.obligationsRoot, settlement_root: s.settlementRoot },
489
- simulation: !simulations.length ? 'nothing to execute' : failed.length ? `would fail: ${failure(failed[0].revertReason)}` : 'simulated against the chain: it would succeed',
490
- sent: 'nothing has been sent',
491
- });
492
- } catch (err: any) { return fail(err instanceof NetError ? err.message : 'Could not net these: ' + (err?.shortMessage ?? err?.message ?? err)); }
493
- });
494
-
495
- async function post(route: string, body: unknown) {
496
- const res = await fetch(cfg.api + route, { method: 'POST', headers: { 'content-type': 'application/json' }, body: toJson(body) });
497
- const json = fromJson(await res.text()) as any;
498
- if (!res.ok || json?.error) throw new Error(json?.error?.code ? `${json.error.code}: ${json.error.message}` : `HTTP ${res.status}`);
499
- return json;
500
- }
501
-
502
- /** Sends a plan's bundles: through the hosted relayer, or paid by the agent key with FIDES_RELAY=self. */
503
- async function send(plan: ExecutionPlan, actions: ActionInput[], fides: SignedFides) {
504
- if (!cfg.api) return (await plan.execute()).bundles as { group: string; status: string; txHash?: string; error?: string }[];
505
- const stored = await post('/v1/plans/build', { fides, actions });
506
- if (stored.planHash !== plan.planHash) throw new Error('The relayer built a different plan from the same actions, so nothing was sent.');
507
- return (await post(`/v1/plans/${plan.planHash}/execute`, { bundles: await plan.signedBundles() })).bundles as { group: string; status: string; txHash?: string; error?: string }[];
508
- }
509
-
510
- // What each paid service has cost so far, while this server runs. The onchain limit is the budget;
511
- // the per-service caps are checked here, before anything is signed.
512
- const servicePaid = new Map<string, bigint>();
513
-
514
- // The float: what this tool moved to the agent key through a FIDES and has not spent yet, by FIDES and
515
- // token. Kept on disk so a restart does not forget it. Only this is ever used to pay; anything else on
516
- // the agent key (someone sent it there directly) did not pass through the FIDES, so it is left alone.
517
- const floatFile = path.join(cfg.home, 'float.json');
518
- const floats = (): Record<string, string> => { try { return JSON.parse(fs.readFileSync(floatFile, 'utf8')); } catch { return {}; } };
519
- const floatKey = (fides: SignedFides, asset: string) => `${fides.id}:${asset.toLowerCase()}`;
520
- const floatOf = (fides: SignedFides, asset: string) => BigInt(floats()[floatKey(fides, asset)] ?? '0');
521
- const setFloat = (fides: SignedFides, asset: string, v: bigint) => { const m = floats(); m[floatKey(fides, asset)] = (v > 0n ? v : 0n).toString(); fs.mkdirSync(cfg.home, { recursive: true }); fs.writeFileSync(floatFile, JSON.stringify(m, null, 2)); };
522
- /** What sits on the agent key that did not come through any FIDES by this tool. */
523
- const stray = async (asset: Address) => {
524
- const held = await client.public.readContract({ address: asset, abi: erc20Abi, functionName: 'balanceOf', args: [agent.address] }) as bigint;
525
- const ours = Object.entries(floats()).filter(([k]) => k.endsWith(':' + asset.toLowerCase())).reduce((n, [, v]) => n + BigInt(v), 0n);
526
- return held > ours ? held - ours : 0n;
527
- };
528
-
529
- server.registerTool('fides_pay_request', {
530
- title: 'Buy from a paid API',
531
- description: `Fetches a URL from a paid API that charges per request over x402, paying from the FIDES budget. Reads the price first and refuses if it is over the service\'s cap in the FIDES or over max_price. Moves exactly the price from the FIDES Account to the agent key, pays, and returns the response. The service must be in the FIDES (its services list). Known URLs: ${KNOWN_SERVICES.map((s) => `"${s.id}" ${s.url}`).join(', ')}. Tell the user the price and get their OK before paying unless they already asked you to buy it.`,
532
- inputSchema: {
533
- url: z.string().describe('The paid API URL'),
534
- service: z.string().optional().describe('The service id in the FIDES. Can be left out if the FIDES has exactly one service'),
535
- max_price: z.string().optional().describe('Refuse if the price is above this, in USDG'),
536
- },
537
- }, async ({ url, service, max_price }) => {
538
- try {
539
- const fides = await currentFides();
540
- if (!fides) return fail(notLoaded());
541
- if (!fides.services.length) 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" }].`);
542
- const host = (() => { try { return new URL(url).host.toLowerCase().replace(/^www\./, ''); } catch { return ''; } })();
543
- const id = service ?? (fides.services.some((x) => x.serviceId === host) ? host : fides.services.length === 1 ? fides.services[0].serviceId : undefined);
544
- if (!id) return fail(`Say which service this is. The FIDES allows: ${fides.services.map((x) => x.serviceId).join(', ')}.`);
545
- const rule = fides.services.find((x) => x.serviceId === id);
546
- if (!rule) return fail(`"${id}" is not a service in this FIDES. It allows: ${fides.services.map((x) => x.serviceId).join(', ')}. Anything else is blocked.`);
547
-
548
- // 1. the price
549
- let first: Response;
550
- try { first = await fetch(url); } catch (err: any) { return fail('Could not reach ' + url + ': ' + (err?.message ?? err)); }
551
- const header = first.headers.get('PAYMENT-REQUIRED');
552
- if (first.status !== 402 || !header) {
553
- const text = await first.text();
554
- return ok({ paid: 'nothing: this URL did not ask for payment', status: first.status, response: text.slice(0, 4000) });
555
- }
556
- const required = decodePaymentRequired(header);
557
- const offer = required.accepts.find((r) => exactEip3009.supports(r, { fides, payer: agent }) && fides.spendLimits.some((l) => l.asset.toLowerCase() === r.asset.toLowerCase()));
558
- if (!offer) return fail('This service asks for a payment FIDES cannot make from this FIDES: it needs the budget token, paid with EIP-3009 on this chain. Nothing was paid.');
559
- const t = await token(offer.asset as Address);
560
- const price = BigInt(offer.amount);
561
- const shown = `${formatUnits(price, t.decimals)} ${t.symbol}`;
562
- if (max_price) { let cap: bigint; try { cap = parseUnits(max_price, t.decimals); } catch { return fail('max_price must be a number, like "0.05".'); } if (price > cap) return fail(`Blocked: the price is ${shown}, over your max of ${max_price}. Nothing was paid.`); }
563
- const paidSoFar = servicePaid.get(id) ?? 0n;
564
- if (paidSoFar + price > BigInt(rule.maxSpend)) return fail(`Blocked: ${shown} would take "${id}" past its cap of ${formatUnits(BigInt(rule.maxSpend), t.decimals)} ${t.symbol} in this FIDES (${formatUnits(paidSoFar, t.decimals)} spent). Nothing was paid.`);
565
-
566
- // 2. fund the agent key through the FIDES: reuse only float this tool moved there under this FIDES
567
- const held = await client.public.readContract({ address: offer.asset as Address, abi: erc20Abi, functionName: 'balanceOf', args: [agent.address] }) as bigint;
568
- let float = floatOf(fides, offer.asset);
569
- if (float > held) float = held;
570
- const x402 = createX402({ fides, payer: agent, serviceId: () => id });
571
- let funded: string | undefined;
572
- if (float < price) {
573
- const actions = [x402.float(offer.asset as Address, price - float, 'pay-float')];
574
- const plan = await new FidesBuilder({ client, fides, agent }).add(actions).build();
575
- if (plan.rejected().length) { const r = plan.rejected()[0]; return fail(`Blocked by the FIDES: ${REASONS[r.reason ?? ''] ?? r.reason}. Nothing was paid.`); }
576
- const [sim] = await plan.simulate();
577
- if (sim && !sim.success) return fail('Blocked: ' + failure(sim.revertReason) + ' Nothing was paid.');
578
- const bundles = await send(plan, actions, fides);
579
- if (!bundles.length || bundles.some((b) => b.status !== 'EXECUTED')) return fail('Could not move the price to the agent key: ' + failure(bundles.find((b) => b.error)?.error) + ' Nothing was paid.');
580
- funded = txLink(bundles[0].txHash);
581
- float = price;
582
- setFloat(fides, offer.asset, float);
583
- }
584
-
585
- // 3. pay and fetch
586
- const res = await x402.fetch(url);
587
- const body = await res.text();
588
- const p = x402.payments[0];
589
- // No confirmed payment (a 402, or an error before the service settled): the price moved to the agent
590
- // key is still there, recorded as float, and pays for the next request instead of moving it again.
591
- if (!p) return fail(`The service did not take the payment (it answered ${res.status}${body ? ': ' + body.slice(0, 200) : ''}), so nothing was paid. ${funded ? 'The price already moved to the agent key stays there and pays for the next attempt, so a retry does not move it again.' : ''}`);
592
- if (p) { servicePaid.set(id, paidSoFar + p.amount); setFloat(fides, offer.asset, float - p.amount); }
593
- let response: unknown = body.slice(0, 4000);
594
- try { response = JSON.parse(body); } catch {}
595
- const [spend] = await Policy.remainingSpend(client, fides);
596
- return ok({
597
- paid: p ? shown : 'nothing',
598
- service: id,
599
- settlement: txLink((p?.settlement as any)?.transaction),
600
- moved_to_agent_key: funded,
601
- status: res.status,
602
- response,
603
- budget_remaining: `${formatUnits(spend.remaining, t.decimals)} of ${formatUnits(spend.limit, t.decimals)} ${t.symbol}`,
604
- });
605
- } catch (err: any) { return fail('Paid request stopped: ' + failure(err?.shortMessage ?? err?.message ?? err)); }
606
- });
607
-
608
- // ---- SUB-FIDES ----
609
-
610
- // ---- Connect anything ----
611
-
612
- const lookupBase = () => cfg.api ?? cfg.directory ?? 'https://api.fidesbuilder.xyz';
613
- async function inspect(address: string) {
614
- const res = await fetch(`${lookupBase()}/v1/contracts/${address}`);
615
- const json = fromJson(await res.text()) as any;
616
- if (!res.ok || json?.error) throw new Error(json?.error?.message ?? `HTTP ${res.status}`);
617
- return json;
618
- }
619
-
620
- /** Encodes a call to `fn` on `target` from the contract's verified ABI (standard ERC-20 functions work without one). */
621
- async function encodeByName(target: Address, fn: string, args: unknown[], kind: 'write' | 'read'): Promise<{ data: Hex; label: string; abi: AbiFunction; name: string } | { error: string }> {
622
- let info: any = null;
623
- try { info = await inspect(target); } catch { /* fall back to ERC-20 below */ }
624
- const list: { abi: AbiFunction }[] = (kind === 'write' ? info?.functions : info?.reads) ?? [];
625
- const byName = (x: { abi: AbiFunction }) => x.abi.name === fn || `${x.abi.name}(${x.abi.inputs.map((i) => i.type).join(',')})` === fn.replace(/\s/g, '') || (x as any).signature === fn;
626
- let matches = list.filter((x) => x.abi && byName(x));
627
- if (!matches.length) {
628
- const std = (erc20Abi as readonly any[]).filter((x) => x.type === 'function' && x.name === fn && (kind === 'read') === (x.stateMutability === 'view'));
629
- matches = std.map((abi) => ({ abi }));
630
- }
631
- if (matches.length > 1) matches = matches.filter((x) => x.abi.inputs.length === args.length);
632
- if (!matches.length) {
633
- if (info && !info.verified) return { error: `${target} is not verified on Sourcify, so "${fn}" cannot be encoded by name. Pass "data" as hex calldata instead.` };
634
- const names = list.map((x: any) => x.name).filter(Boolean);
635
- return { error: `${info?.name ?? target} has no ${kind === 'write' ? 'write' : 'read'} function "${fn}"${names.length ? `. It has: ${[...new Set(names)].join(', ')}` : ''}.` };
636
- }
637
- if (matches.length > 1) return { error: `"${fn}" is overloaded; give its signature, one of: ${matches.map((x) => `${x.abi.name}(${x.abi.inputs.map((i) => i.type).join(',')})`).join(', ')}.` };
638
- const abi = matches[0].abi;
639
- if (args.length !== abi.inputs.length) return { error: `${abi.name} takes ${abi.inputs.length} argument(s): ${abi.inputs.map((i) => `${i.type} ${i.name ?? ''}`.trim()).join(', ')}. Got ${args.length}.` };
640
- const me = (await currentFides().catch(() => null))?.account ?? null;
641
- try {
642
- const data = encodeCall(abi, args, me);
643
- return { data, label: `${info?.name ?? 'contract'}.${abi.name} on ${target}`, abi, name: info?.name ?? null };
644
- } catch (err: any) { return { error: err?.shortMessage ?? err?.message ?? String(err) }; }
645
- }
646
-
647
- server.registerTool('fides_inspect_contract', {
648
- title: 'What a contract lets an agent do',
649
- 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.',
650
- inputSchema: { address: z.string().describe('The contract address') },
651
- }, async ({ address }) => {
652
- try {
653
- if (!isAddress(address)) return fail('That is not a contract address.');
654
- const info = await inspect(address);
655
- if (!info.verified) 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.' });
656
- return ok({
657
- name: info.name, address: info.address, verified: true, proxy: info.proxy ?? undefined,
658
- functions: info.functions.map((fn: any) => ({ name: fn.name, does: fn.about || undefined, signature: fn.signature, selector: fn.selector, risks: fn.risks.length ? fn.risks.map((r: any) => `${r.level}: ${r.why}`) : undefined })),
659
- reads: info.reads?.length ? info.reads.map((fn: any) => fn.signature) : undefined,
660
- next: 'To allow some of these, call fides_setup with contracts: [{ address, functions: [names] }] and give the user the link. Once signed, call them with fides_plan: { kind: "call", target, function, args }. Read functions are free: fides_read_contract.',
661
- });
662
- } catch (err: any) { return fail('Could not read the contract: ' + (err?.message ?? err)); }
663
- });
664
-
665
- server.registerTool('fides_read_contract', {
666
- title: 'Read from a contract',
667
- description: 'Calls a read-only (view) function on any verified contract on the chain, by name, and returns the result. Free: nothing is signed or sent, and it needs no permission in the FIDES. Use it to look up what a write needs, such as a market\'s parameters or a balance. Integers come back as strings in the smallest unit.',
668
- inputSchema: {
669
- address: z.string().describe('The contract address'),
670
- function: z.string().describe('The view function name, or its signature when overloaded'),
671
- args: z.array(z.any()).optional().describe('The arguments in order. Integers as strings. "me" for an address means this FIDES Account'),
672
- },
673
- }, async ({ address, function: fn, args }) => {
674
- try {
675
- if (!isAddress(address)) return fail('That is not a contract address.');
676
- const enc = await encodeByName(getAddress(address), fn, args ?? [], 'read');
677
- if ('error' in enc) return fail(enc.error);
678
- const { data } = await client.public.call({ to: getAddress(address), data: enc.data });
679
- const result = decodeFunctionResult({ abi: [enc.abi], functionName: enc.abi.name, data: data ?? '0x' });
680
- const named = enc.abi.outputs.length > 1 && enc.abi.outputs.every((o) => o.name) && Array.isArray(result) ? Object.fromEntries(enc.abi.outputs.map((o, i) => [o.name, (result as unknown[])[i]])) : result;
681
- return ok({ contract: enc.name ?? address, function: enc.abi.name, result: JSON.parse(JSON.stringify(named, (_k, x) => (typeof x === 'bigint' ? x.toString() : x))) });
682
- } catch (err: any) { return fail('Could not read: ' + failure(err?.shortMessage ?? err?.message ?? err)); }
683
- });
684
-
685
- server.registerTool('fides_check_api', {
686
- title: 'Can FIDES pay this API?',
687
- 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.',
688
- inputSchema: { url: z.string().describe('The API URL, https') },
689
- }, async ({ url }) => {
690
- try {
691
- 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' }) });
692
- const p = fromJson(await res.text()) as any;
693
- if (!res.ok || p?.error) return fail(p?.error?.message ?? `HTTP ${res.status}`);
694
- if (!p.payable) return ok({ url: p.url, payable: false, why: p.reason, charges_on: p.networks?.length ? p.networks : undefined });
695
- const dec = p.offer.decimals ?? 6, sym = p.offer.symbol ?? 'USDG';
696
- return ok({
697
- 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,
698
- service_id: p.serviceId, suggested_cap: `${formatUnits(BigInt(p.suggestedCap), dec)} ${sym} (10 requests)`,
699
- 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.`,
700
- });
701
- } catch (err: any) { return fail('Could not check the API: ' + (err?.message ?? err)); }
702
- });
703
-
704
- // ---- Uniswap ----
705
-
706
- const tokenArg = (t: string) => (/^eth$/i.test(t) ? 'ETH' as const : /^weth$/i.test(t) ? uniswap[cfg.chainId]?.weth : isAddress(t) ? getAddress(t) : null);
707
-
708
- server.registerTool('fides_swap', {
709
- title: 'Swap on Uniswap inside the FIDES',
710
- 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.',
711
- inputSchema: {
712
- token_out: z.string().describe('What to buy: "ETH", "WETH" or a token address'),
713
- amount: z.string().describe('How much of the budget token to spend, e.g. "1"'),
714
- max_slippage_percent: z.number().positive().optional().describe('Worst price against the quote, in percent. Defaults to the most the FIDES allows'),
715
- },
716
- }, async ({ token_out, amount, max_slippage_percent }) => {
717
- try {
718
- const fides = await currentFides();
719
- if (!fides) return fail(notLoaded());
720
- if (fides.agent.toLowerCase() !== agent.address.toLowerCase()) return fail(`The loaded FIDES is for agent ${fides.agent}, not this computer's agent key ${agent.address}.`);
721
- const m = mandateOf(fides);
722
- if (!m) 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.');
723
- const out = tokenArg(token_out);
724
- if (!out) return fail('"token_out" must be "ETH", "WETH" or a token address.');
725
- const budgetToken = fides.spendLimits[0].asset, tIn = await token(budgetToken);
726
- let value: bigint;
727
- try { value = parseUnits(amount, tIn.decimals); } catch { return fail(`"amount" must be a number of ${tIn.symbol}, like "1".`); }
728
- const slippageBps = max_slippage_percent ? Math.round(max_slippage_percent * 100) : m.maxSlippageBps;
729
- let q: SwapQuote, blocked: { code: string; message: string } | null;
730
- if (cfg.api) {
731
- const r = await post('/v1/swap/quote', { fides, tokenOut: out, amount: value, slippageBps });
732
- q = r.quote as SwapQuote; blocked = r.mandate?.allowed ? null : { code: r.mandate.code, message: r.mandate.message };
733
- } else {
734
- if (!process.env.UNISWAP_API_KEY) return fail('Swaps need the FIDES API, or UNISWAP_API_KEY when running without it.');
735
- q = await quoteUniswap({ apiKey: process.env.UNISWAP_API_KEY, fides, tokenOut: out, amount: value, slippageBps });
736
- blocked = checkMandate(fides, q);
737
- }
738
- const outInfo = q.tokenOut === NATIVE ? { symbol: 'ETH', decimals: 18 } : await token(q.tokenOut);
739
- const fmt = (v: bigint) => `${formatUnits(v, outInfo.decimals)} ${outInfo.symbol}`;
740
- if (blocked) return fail(`Blocked by the trading mandate: ${blocked.message} Nothing was sent.`);
741
- const actions = swapActions(q, `swap ${amount} ${tIn.symbol} to ${outInfo.symbol} on Uniswap`);
742
- const plan = await new FidesBuilder({ client, fides, agent }).add(actions).build();
743
- if (plan.rejected().length) { const r = plan.rejected()[0]; return fail(`Blocked by the FIDES: ${REASONS[r.reason ?? ''] ?? r.reason}. Nothing was sent.`); }
744
- const v = await verifySwap(client as any, fides, plan as any, q);
745
- const id = plan.planHash.slice(0, 18);
746
- if (v.ok) plans.set(id, { plan, actions, fides, swap: { quote: q, outDecimals: outInfo.decimals, outSymbol: outInfo.symbol } });
747
- return ok({
748
- plan_id: v.ok ? id : null,
749
- ready_to_execute: v.ok,
750
- route: describeRoute(q),
751
- spend: `${amount} ${tIn.symbol}`,
752
- quoted: fmt(q.quotedOut),
753
- worst_accepted: `${fmt(q.minOut)} (${q.slippageBps / 100}% below the quote)`,
754
- simulated: v.ok ? `would receive ${fmt(v.received)}` : `refused: ${v.reason}`,
755
- transactions: `${plan.estimatedSavings().baselineTransactions} onchain write(s) go out as ${plan.estimatedSavings().estimatedTransactions} transaction(s): an exact approval and the swap, nothing left approved`,
756
- mandate: `may buy ${m.buy.join(', ')}; worst price ${m.maxSlippageBps / 100}%; most per swap ${formatUnits(m.maxPerSwap, tIn.decimals)} ${tIn.symbol}`,
757
- sent: 'nothing has been sent',
758
- });
759
- } catch (err: any) { return fail(err instanceof SwapError ? `Uniswap: ${err.message}` : 'Could not get a swap: ' + (err?.shortMessage ?? err?.message ?? err)); }
760
- });
761
-
762
- server.registerTool('fides_delegate', {
763
- title: 'Hand another agent a slice of the FIDES (SUB-FIDES)',
764
- 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.',
765
- inputSchema: {
766
- agent: z.string().describe('The child agent\'s address'),
767
- amount: z.string().describe('The most the child may spend, in the budget token, e.g. "0.1"'),
768
- minutes: z.number().int().positive().optional().describe('How long the slice lasts. Defaults to as long as this FIDES; never longer'),
769
- },
770
- }, async ({ agent: child, amount, minutes }) => {
771
- try {
772
- if (!subClient || !subAddrs) return fail(`SUB-FIDES is not deployed on chain ${cfg.chainId}.`);
773
- const parent = await currentFides();
774
- if (!parent) return fail(notLoaded());
775
- if (parent.agent.toLowerCase() !== agent.address.toLowerCase()) return fail(`The loaded FIDES is for agent ${parent.agent}, not this computer's agent key ${agent.address}.`);
776
- if (!isAddress(child)) return fail('"agent" must be an address.');
777
- if (child.toLowerCase() === agent.address.toLowerCase()) return fail('An agent cannot delegate to itself.');
778
- const v2 = await accountOf(subClient, parent.owner);
779
- if (v2.toLowerCase() !== parent.account.toLowerCase()) 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.');
780
- const asset = parent.spendLimits[0].asset, t = await token(asset);
781
- let value: bigint;
782
- try { value = parseUnits(amount, t.decimals); } catch { return fail(`"amount" must be a number of ${t.symbol}, like "0.1".`); }
783
- const expiry = minutes ? BigInt(Math.min(Number(parent.validUntil), Math.floor(Date.now() / 1000) + minutes * 60)) : parent.validUntil;
784
- const unsigned = SubFidesKit.create(parent, { agent: getAddress(child), spend: [{ asset, amount: value }], expiry });
785
- const sub = await SubFidesKit.sign(unsigned, agent, subClient, parent);
786
- if (cfg.api) await SubFidesKit.register(cfg.api, parent, sub);
787
- fs.mkdirSync(subsDir, { recursive: true });
788
- const file = path.join(subsDir, `${sub.id.slice(0, 18)}.json`);
789
- fs.writeFileSync(file, toJson({ parent, sub }, 2) + '\n');
790
- return ok({
791
- sub_fides: sub.id,
792
- child_agent: sub.agent,
793
- budget: `${formatUnits(value, t.decimals)} ${t.symbol}`,
794
- expires: new Date(Number(sub.validUntil) * 1000).toISOString(),
795
- contracts: sub.targets.map((r) => r.target),
796
- file,
797
- 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.',
798
- revoke: 'The owner can revoke it any time in the FIDES app, on this FIDES\'s page.',
799
- });
800
- } catch (err: any) { return fail(err instanceof SubFidesError ? err.message : 'Could not delegate: ' + (err?.shortMessage ?? err?.message ?? err)); }
801
- });
802
-
803
- /** The SUB-FIDES handed to this agent that can still act: from a file, or found through the FIDES API. */
804
- async function receivedSubs(file?: string): Promise<{ parent: SignedFides; sub: SignedSubFides }[]> {
805
- const found: { parent: SignedFides; sub: SignedSubFides }[] = [];
806
- if (file) { const full = file.replace(/^~(?=$|[\\/])/, os.homedir()); found.push(fromJson(fs.readFileSync(full, 'utf8'))); }
807
- if (cfg.directory) {
808
- try {
809
- const res = await fetch(`${cfg.directory}/v1/agents/${agent.address}/subs`);
810
- if (res.ok) for (const x of (fromJson(await res.text()) as any).subs ?? []) if (x.parentStatus?.valid) found.push({ parent: x.parent, sub: x.sub });
811
- } catch { /* the API is optional when a file is given */ }
812
- }
813
- const now = BigInt(Math.floor(Date.now() / 1000));
814
- const live: { parent: SignedFides; sub: SignedSubFides }[] = [];
815
- for (const x of found) {
816
- if (x.sub.agent.toLowerCase() !== agent.address.toLowerCase() || now >= x.sub.validUntil) continue;
817
- if (subClient && await SubFidesKit.isRevoked(subClient, x.parent, x.sub).catch(() => true)) continue;
818
- live.push(x);
819
- }
820
- return live.sort((a, b) => Number(b.sub.validAfter - a.sub.validAfter));
821
- }
822
-
823
- server.registerTool('fides_sub_plan', {
824
- title: 'Plan actions under a SUB-FIDES handed to this agent',
825
- 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.',
826
- 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') },
827
- }, async ({ actions, file }) => {
828
- try {
829
- if (!subClient) return fail(`SUB-FIDES is not deployed on chain ${cfg.chainId}.`);
830
- const [x] = await receivedSubs(file);
831
- if (!x) 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.`);
832
- const made = await buildActions(actions, x.sub.spendLimits[0]?.asset ?? x.parent.spendLimits[0].asset);
833
- if ('error' in made) return fail(made.error);
834
- const child = new SubAgent({ client: subClient, parent: x.parent, sub: x.sub, agent, ...(cfg.api ? { api: cfg.api } : { relayer: agent }) });
835
- const p = await child.plan(made.built);
836
- const sim = p.bundle ? await child.simulate(p) : { success: false, reason: 'NOTHING_TO_SEND' };
837
- const id = (p.bundleHash ?? '0x').slice(0, 18);
838
- if (sim.success) subPlans.set(id, { agent: child, plan: p, parent: x.parent, sub: x.sub });
839
- const t = await token(x.sub.spendLimits[0]?.asset ?? x.parent.spendLimits[0].asset);
840
- return ok({
841
- plan_id: sim.success ? id : null,
842
- ready_to_execute: sim.success,
843
- sub_fides: x.sub.id,
844
- slice: `${x.sub.spendLimits.map((l) => formatUnits(l.amount, t.decimals) + ' ' + t.symbol).join(', ')} until ${new Date(Number(x.sub.validUntil) * 1000).toISOString()}`,
845
- delegated_by: x.parent.agent,
846
- 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' })),
847
- transactions: p.bundle ? `${p.bundle.calls.length} onchain write(s) go out as 1 transaction` : 'nothing to execute',
848
- 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)}`,
849
- sent: 'nothing has been sent',
850
- });
851
- } catch (err: any) { return fail('Could not build the plan: ' + (err?.shortMessage ?? err?.message ?? err)); }
852
- });
853
-
854
- server.registerTool('fides_subs', {
855
- title: 'SUB-FIDES handed out and received',
856
- description: 'The SUB-FIDES this agent has handed to other agents, and the ones handed to it that can still act.',
857
- inputSchema: {},
858
- }, async () => {
859
- try {
860
- const given = fs.existsSync(subsDir) ? fs.readdirSync(subsDir).filter((n) => n.endsWith('.json')).map((n) => (fromJson(fs.readFileSync(path.join(subsDir, n), 'utf8')) as any).sub as SignedSubFides) : [];
861
- const now = BigInt(Math.floor(Date.now() / 1000));
862
- return ok({
863
- 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 }))),
864
- received: (await receivedSubs()).map((x) => ({ sub_fides: x.sub.id, delegated_by: x.parent.agent, expires: new Date(Number(x.sub.validUntil) * 1000).toISOString() })),
865
- });
866
- } catch (err: any) { return fail('Could not list SUB-FIDES: ' + (err?.shortMessage ?? err?.message ?? err)); }
867
- });
868
-
869
- server.registerTool('fides_execute', {
870
- title: 'Execute a plan',
871
- 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.',
872
- inputSchema: { plan_id: z.string() },
873
- }, async ({ plan_id }) => {
874
- const subEntry = subPlans.get(plan_id);
875
- if (subEntry) {
876
- subPlans.delete(plan_id);
877
- try {
878
- const r = await subEntry.agent.execute(subEntry.plan);
879
- return ok({
880
- result: r.status === 'EXECUTED' ? 'executed' : 'not executed',
881
- transactions: r.txHash || r.error ? [{ status: r.status.toLowerCase(), transaction: txLink(r.txHash), ...(r.error ? { reason: failure(r.error) } : {}) }] : [],
882
- under: `SUB-FIDES ${subEntry.sub.id} from ${subEntry.parent.agent}`,
883
- blocked_and_not_sent: subEntry.plan.plan.rejected.map((a) => `${a.label}: ${REASONS[a.reason ?? ''] ?? a.reason}`),
884
- });
885
- } catch (err: any) { return fail('Execution stopped: ' + failure(err?.shortMessage ?? err?.message ?? err)); }
886
- }
887
- const entry = plans.get(plan_id);
888
- if (!entry) return fail('No plan with that id. Make one with fides_plan first (plans are kept until they are executed or the server restarts).');
889
- plans.delete(plan_id); // a plan's bundles can be sent once
890
- const { plan, actions, fides, net, swap } = entry;
891
- try {
892
- 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] }) as Promise<bigint>;
893
- const before = await outBalance();
894
- const bundles = await send(plan, actions, fides);
895
- const received = swap ? (await outBalance()) - before : 0n;
896
- const [spend] = await Policy.remainingSpend(client, fides);
897
- const t = await token(fides.spendLimits[0].asset);
898
- const executed = bundles.filter((b) => b.status === 'EXECUTED').length;
899
- return ok({
900
- result: executed === bundles.length ? 'executed' : executed ? 'partly executed' : 'not executed',
901
- transactions: bundles.map((b) => ({ status: b.status.toLowerCase(), transaction: txLink(b.txHash), ...(b.error ? { reason: failure(b.error) } : {}) })),
902
- blocked_and_not_sent: plan.rejected().map((a) => `${a.label}: ${REASONS[a.reason ?? ''] ?? a.reason}`),
903
- ...(swap && executed === bundles.length ? { best_execution: {
904
- quoted: `${formatUnits(swap.quote.quotedOut, swap.outDecimals)} ${swap.outSymbol}`,
905
- received: `${formatUnits(received, swap.outDecimals)} ${swap.outSymbol}`,
906
- 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`,
907
- route: describeRoute(swap.quote),
908
- } } : {}),
909
- ...(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 } } : {}),
910
- budget_remaining: `${formatUnits(spend.remaining, t.decimals)} of ${formatUnits(spend.limit, t.decimals)} ${t.symbol}`,
911
- });
912
- } catch (err: any) { return fail('Execution stopped: ' + failure(err?.shortMessage ?? err?.message ?? err)); }
913
- });
914
-
915
- server.registerTool('fides_receipts', {
916
- title: 'Executed transactions',
917
- description: 'Every bundle executed onchain under the loaded FIDES, with transaction links, and the budget spent.',
918
- inputSchema: {},
919
- }, async () => {
920
- try {
921
- const fides = await currentFides();
922
- if (!fides) return fail(notLoaded());
923
- const [list, [spend], t] = await Promise.all([Receipts.get(client, fides), Policy.remainingSpend(client, fides), token(fides.spendLimits[0].asset)]);
924
- return ok({
925
- executed: list.map((r) => ({ transaction: txLink(r.txHash), calls_succeeded: r.succeeded, calls_failed: r.failed, block: r.blockNumber.toString() })),
926
- spent: `${formatUnits(spend.spent, t.decimals)} of ${formatUnits(spend.limit, t.decimals)} ${t.symbol}`,
927
- });
928
- } catch (err: any) { return fail('Could not read receipts: ' + (err?.shortMessage ?? err?.message ?? err)); }
929
- });
930
-
931
- return Object.assign(server, { agent: agent.address });
932
- }
933
-
934
- function setup(cfg: Config) {
935
- const { account, file, created } = agentKey(cfg);
936
- const win = process.platform === 'win32';
937
- const desktop = JSON.stringify({ mcpServers: { fides: win ? { command: 'cmd', args: ['/c', 'npx', '-y', '@fidesbuild/mcp-server@latest'] } : { command: 'npx', args: ['-y', '@fidesbuild/mcp-server@latest'] } } }, null, 2);
938
- console.log(`FIDES for Claude
939
-
940
- Agent address: ${account.address}
941
- ${file ? `${created ? 'New key saved' : 'Key'} in ${file}. Keep that file private. It holds no funds and can only act inside the FIDES you sign for it.` : 'Key from FIDES_AGENT_KEY.'}
942
-
943
- Next:
944
- 1. Open this link. It opens the FIDES app with this agent filled in:
945
- ${cfg.app}/#/create?agent=${account.address}&via=claude
946
- 2. Set the budget, contracts and expiry, then sign. Create your FIDES Account and fund it.
947
- 3. Add FIDES to Claude, if you have not yet.
948
- Claude Code, in a terminal:
949
- claude mcp add fides -- ${win ? 'cmd /c ' : ''}npx -y @fidesbuild/mcp-server@latest
950
- Claude Desktop: Settings, Developer, Edit Config, and add:
951
- ${desktop.split('\n').map((l) => ' ' + l).join('\n')}
952
- Then restart Claude Desktop.
953
- 4. Ask Claude for what you want. It picks up the signed FIDES by itself.
954
- `);
955
- }
956
-
957
- // Run directly, or through the npm bin link (which may be a symlink to this file).
958
- const entry = (() => { try { return pathToFileURL(fs.realpathSync(process.argv[1] ?? '')).href; } catch { return ''; } })();
959
- if (import.meta.url === entry) {
960
- const cfg = configFromEnv();
961
- if (process.argv[2] === 'setup') setup(cfg);
962
- else if (process.argv[2] === '--help' || process.argv[2] === '-h') console.log('npx -y @fidesbuild/mcp-server setup make the agent key and print the next steps\nnpx -y @fidesbuild/mcp-server run the MCP server over stdio\n\nEnvironment: FIDES_HOME, FIDES_FILE, FIDES_RPC, FIDES_CHAIN_ID, FIDES_API, FIDES_APP, FIDES_RELAY=self, FIDES_LOOKUP=off, FIDES_AGENT_KEY, FIDES_ADDRESSES');
963
- else {
964
- const server = createFidesMcpServer(cfg);
965
- await server.connect(new StdioServerTransport());
966
- console.error(`fides-mcp running. Agent address ${server.agent}.`);
967
- }
968
- }