@finchagentic/mcp 4.6.0 → 4.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +7 -7
  2. package/package.json +5 -6
  3. package/dist/_http-cache.js +0 -96
  4. package/dist/agent-loop.js +0 -301
  5. package/dist/annotations.js +0 -122
  6. package/dist/cli.js +0 -1391
  7. package/dist/clink-input.js +0 -15
  8. package/dist/config.js +0 -132
  9. package/dist/convex.js +0 -175
  10. package/dist/dex-pair.js +0 -54
  11. package/dist/enrichment-router.js +0 -315
  12. package/dist/index.js +0 -258
  13. package/dist/llm.js +0 -298
  14. package/dist/local-memory-file.js +0 -147
  15. package/dist/local-memory.js +0 -135
  16. package/dist/local-vault.js +0 -454
  17. package/dist/output-schemas.js +0 -605
  18. package/dist/project.js +0 -36
  19. package/dist/prompts.js +0 -111
  20. package/dist/public-url.js +0 -107
  21. package/dist/resources.js +0 -111
  22. package/dist/server.js +0 -322
  23. package/dist/signal-gate.js +0 -57
  24. package/dist/token-decimals.js +0 -26
  25. package/dist/token-gate.js +0 -88
  26. package/dist/tool-filter.js +0 -53
  27. package/dist/tools/_solidity-scan.js +0 -313
  28. package/dist/tools/agents.js +0 -441
  29. package/dist/tools/automation.js +0 -354
  30. package/dist/tools/base-mcp.js +0 -466
  31. package/dist/tools/base.js +0 -283
  32. package/dist/tools/chronicle.js +0 -268
  33. package/dist/tools/coder.js +0 -94
  34. package/dist/tools/deep-research.js +0 -1421
  35. package/dist/tools/defi.js +0 -292
  36. package/dist/tools/equity.js +0 -372
  37. package/dist/tools/events.js +0 -182
  38. package/dist/tools/github.js +0 -564
  39. package/dist/tools/insider.js +0 -264
  40. package/dist/tools/insight.js +0 -630
  41. package/dist/tools/market.js +0 -555
  42. package/dist/tools/memory.js +0 -1044
  43. package/dist/tools/miroshark.js +0 -350
  44. package/dist/tools/monitor.js +0 -319
  45. package/dist/tools/os.js +0 -236
  46. package/dist/tools/packets.js +0 -296
  47. package/dist/tools/research-chain.js +0 -226
  48. package/dist/tools/research-compare.js +0 -280
  49. package/dist/tools/research.js +0 -188
  50. package/dist/tools/rh-bridge.js +0 -148
  51. package/dist/tools/rh-mcp.js +0 -1448
  52. package/dist/tools/rh-orders.js +0 -556
  53. package/dist/tools/scanner.js +0 -564
  54. package/dist/tools/stake.js +0 -369
  55. package/dist/tools/vault.js +0 -1020
  56. package/dist/tools/wallet.js +0 -200
  57. package/dist/types.js +0 -2
  58. package/dist/wallet.js +0 -372
@@ -1,292 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFI_TOOLS = void 0;
4
- exports.buildDefiYields = buildDefiYields;
5
- exports.handleDefiTool = handleDefiTool;
6
- const zod_1 = require("zod");
7
- const convex_js_1 = require("../convex.js");
8
- const wallet_js_1 = require("../wallet.js");
9
- const token_decimals_js_1 = require("../token-decimals.js");
10
- exports.DEFI_TOOLS = [
11
- {
12
- name: "get_defi_yields",
13
- description: "Fetch top DeFi yield opportunities on Base - Morpho, Moonwell, Aerodrome, Uniswap, and more. " +
14
- "Returns APY, TVL, and pool info aggregated from DeFiLlama (no API key required) - broadest " +
15
- "protocol coverage, but DeFiLlama's numbers can lag the protocol's own API by hours. Good first " +
16
- "stop for comparing across protocols. For the freshest Morpho-vault-specific numbers use " +
17
- "base_mcp_yield_vaults instead; for the freshest Moonwell supply/borrow rates use " +
18
- "base_mcp_lending_rates instead - both query the protocol directly. " +
19
- "Filter by token or minimum APY. Use before depositing to find the best rates.",
20
- inputSchema: {
21
- type: "object",
22
- properties: {
23
- token: { type: "string", description: "Optional: filter by token symbol, e.g. 'USDC', 'ETH', 'WETH'" },
24
- minApy: { type: "number", description: "Optional: minimum APY % to show (default 1)" },
25
- limit: { type: "number", description: "Max results to return (default 20)" },
26
- },
27
- required: [],
28
- },
29
- },
30
- ];
31
- const SwapSchema = zod_1.z.object({
32
- fromToken: zod_1.z.string().min(1),
33
- toToken: zod_1.z.string().min(1),
34
- amount: zod_1.z.string().min(1),
35
- maxSlippagePct: zod_1.z.number().positive().max(50).optional(),
36
- maxPriceImpactPct: zod_1.z.number().positive().max(50).optional(),
37
- });
38
- const DEFAULT_MAX_SLIPPAGE_PCT = 1.0;
39
- const DEFAULT_MAX_PRICE_IMPACT_PCT = 3.0;
40
- const SendSchema = zod_1.z.object({ token: zod_1.z.string().min(1), toAddress: zod_1.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "must be a valid 0x address"), amount: zod_1.z.string().min(1) });
41
- const DefiYieldsSchema = zod_1.z.object({
42
- token: zod_1.z.string().optional(),
43
- minApy: zod_1.z.number().optional(),
44
- limit: zod_1.z.number().int().min(1).max(100).optional(),
45
- }).default({});
46
- function formatTokenAmount(raw, token) {
47
- const dec = (0, token_decimals_js_1.decimalsFor)(token);
48
- // Unknown decimals: show raw units rather than a confidently wrong number.
49
- if (dec === undefined)
50
- return `${raw} (raw units — decimals unknown)`;
51
- // BigInt math: parseInt on an 18-digit wei string loses precision past
52
- // MAX_SAFE_INTEGER.
53
- try {
54
- const places = dec === 6 ? 2 : 6;
55
- const scale = 10n ** BigInt(dec);
56
- const value = BigInt(raw);
57
- const whole = value / scale;
58
- const frac = (value % scale).toString().padStart(dec, "0").slice(0, places);
59
- return places > 0 ? `${whole}.${frac}` : `${whole}`;
60
- }
61
- catch {
62
- return raw;
63
- }
64
- }
65
- // Structured output builder for get_defi_yields (schema in output-schemas.ts).
66
- function buildDefiYields(token, minApy, pools) {
67
- return {
68
- token: token ? token.toUpperCase() : null,
69
- minApy,
70
- count: pools.length,
71
- pools: pools.map((p) => ({
72
- symbol: p.symbol ?? p.pool ?? null,
73
- project: p.project ?? null,
74
- apyPct: p.apy ?? null,
75
- tvlUsd: p.tvlUsd ?? null,
76
- chain: p.chain ?? null,
77
- })),
78
- };
79
- }
80
- async function handleDefiTool(name, args) {
81
- switch (name) {
82
- case "get_portfolio": {
83
- const result = await (0, convex_js_1.callConvex)("/mcp/defi/portfolio", "GET", undefined, "get_portfolio");
84
- if (result.error)
85
- return { content: [{ type: "text", text: `Portfolio fetch failed: ${result.error}` }], isError: true };
86
- const balances = result.balances ?? [];
87
- const totalUsd = result.totalUsd ?? result.totalValueUsd ?? balances.reduce((s, b) => s + (b.valueUsd ?? 0), 0);
88
- if (!balances.length) {
89
- return { content: [{ type: "text", text: "Your wallet has no tokens yet. Send ETH or USDC on Base to get started." }] };
90
- }
91
- const lines = [`**Portfolio** - Total: $${totalUsd.toFixed(2)}`, ""];
92
- for (const b of balances) {
93
- const value = b.valueUsd != null ? ` ($${Number(b.valueUsd).toFixed(2)})` : "";
94
- lines.push(`• **${b.token ?? b.symbol}**: ${Number(b.balance ?? b.amount).toLocaleString(undefined, { maximumFractionDigits: 6 })}${value}`);
95
- }
96
- lines.push("", `Wallet: \`${result.address ?? "unknown"}\``);
97
- return { content: [{ type: "text", text: lines.join("\n") }] };
98
- }
99
- case "estimate_swap": {
100
- const parsed = SwapSchema.safeParse(args);
101
- if (!parsed.success)
102
- return { content: [{ type: "text", text: `${String(parsed.error.issues[0].path[0])}: ${parsed.error.issues[0].message}` }], isError: true };
103
- const { fromToken, toToken, amount, maxSlippagePct, maxPriceImpactPct } = parsed.data;
104
- const slippageLimit = maxSlippagePct ?? DEFAULT_MAX_SLIPPAGE_PCT;
105
- const impactLimit = maxPriceImpactPct ?? DEFAULT_MAX_PRICE_IMPACT_PCT;
106
- // Pass the MCP local wallet as the swap taker so 0x routes output back
107
- // to the wallet that's signing - not the backend's custodial wallet.
108
- const localWallet = await (0, wallet_js_1.getOrCreateWallet)();
109
- const result = await (0, convex_js_1.callConvex)("/mcp/defi/swap", "POST", { fromToken, toToken, amount, taker: localWallet.address, slippagePercentage: slippageLimit / 100 }, "swap_tokens");
110
- if (!result.success)
111
- return { content: [{ type: "text", text: `Estimate failed: ${result.error}` }], isError: true };
112
- const q = result.quote;
113
- const buyHuman = formatTokenAmount(q.buyAmount, q.buyToken ?? toToken);
114
- const sellHuman = formatTokenAmount(q.sellAmount ?? "0", q.sellToken ?? fromToken);
115
- const impactPct = q.estimatedPriceImpact != null ? Number(q.estimatedPriceImpact) : 0;
116
- const priceImpact = q.estimatedPriceImpact != null ? `${impactPct.toFixed(3)}%` : "< 0.01%";
117
- const impactWarning = impactPct > impactLimit
118
- ? `\n⚠️ **Price impact ${impactPct.toFixed(2)}% exceeds limit ${impactLimit}%** - \`swap_tokens\` will refuse execution. Increase \`maxPriceImpactPct\` to override.`
119
- : "";
120
- return {
121
- content: [{
122
- type: "text",
123
- text: [
124
- `**Swap Estimate** (not executed)`,
125
- ``,
126
- `You sell: **${sellHuman} ${(q.sellToken ?? fromToken).toUpperCase()}**`,
127
- `You get: **~${buyHuman} ${(q.buyToken ?? toToken).toUpperCase()}**`,
128
- `Price impact: ${priceImpact}`,
129
- `Slippage cap: ${slippageLimit}% · Price-impact cap: ${impactLimit}%`,
130
- impactWarning,
131
- ``,
132
- `Run \`swap_tokens\` with the same params to execute.`,
133
- ].join("\n"),
134
- }],
135
- };
136
- }
137
- case "swap_tokens": {
138
- const parsed = SwapSchema.safeParse(args);
139
- if (!parsed.success)
140
- return { content: [{ type: "text", text: `${String(parsed.error.issues[0].path[0])}: ${parsed.error.issues[0].message}` }], isError: true };
141
- const { fromToken, toToken, amount, maxSlippagePct, maxPriceImpactPct } = parsed.data;
142
- const slippageLimit = maxSlippagePct ?? DEFAULT_MAX_SLIPPAGE_PCT;
143
- const impactLimit = maxPriceImpactPct ?? DEFAULT_MAX_PRICE_IMPACT_PCT;
144
- const wallet = await (0, wallet_js_1.getOrCreateWallet)();
145
- const result = await (0, convex_js_1.callConvex)("/mcp/defi/swap", "POST", { fromToken, toToken, amount, taker: wallet.address, slippagePercentage: slippageLimit / 100 }, "swap_tokens");
146
- if (!result.success)
147
- return { content: [{ type: "text", text: `Swap failed: ${result.error}` }], isError: true };
148
- const q = result.quote;
149
- const impactPct = q.estimatedPriceImpact != null ? Number(q.estimatedPriceImpact) : 0;
150
- if (impactPct > impactLimit) {
151
- return {
152
- content: [{
153
- type: "text",
154
- text: [
155
- `🛑 **Swap refused - price impact too high.**`,
156
- ``,
157
- `Quoted price impact: **${impactPct.toFixed(3)}%**`,
158
- `Configured cap: **${impactLimit}%**`,
159
- ``,
160
- `Override by passing \`maxPriceImpactPct: ${Math.ceil(impactPct) + 1}\` if you understand the risk,`,
161
- `or reduce \`amount\` to lower the impact.`,
162
- ].join("\n"),
163
- }],
164
- isError: true,
165
- };
166
- }
167
- const txHash = await (0, wallet_js_1.signAndBroadcast)(wallet, q);
168
- const buyAmountHuman = formatTokenAmount(q.buyAmount, q.buyToken ?? toToken);
169
- const receipt = await (0, wallet_js_1.waitForReceipt)(txHash);
170
- const header = !receipt.mined
171
- ? `⏳ Broadcast, not yet confirmed within the wait window.`
172
- : receipt.ok
173
- ? `✅ Swap confirmed on-chain (block ${receipt.blockNumber}).`
174
- : `❌ Transaction REVERTED - no swap occurred (gas was still spent).`;
175
- return {
176
- content: [{
177
- type: "text",
178
- text: [
179
- header,
180
- `${amount} ${fromToken.toUpperCase()} → ${receipt.mined && receipt.ok ? buyAmountHuman : "(quoted, not received)"} ${q.buyToken}`,
181
- `Slippage cap: ${slippageLimit}% · Price impact: ${impactPct.toFixed(3)}%`,
182
- `Tx Hash: \`${txHash}\``,
183
- `https://basescan.org/tx/${txHash}`,
184
- !receipt.mined ? `Check the link above before telling the user this succeeded or failed.` : "",
185
- ].filter(Boolean).join("\n"),
186
- }],
187
- isError: receipt.mined && !receipt.ok,
188
- };
189
- }
190
- case "send_token": {
191
- const parsed = SendSchema.safeParse(args);
192
- if (!parsed.success)
193
- return { content: [{ type: "text", text: `${String(parsed.error.issues[0].path[0])}: ${parsed.error.issues[0].message}` }], isError: true };
194
- const { token, toAddress, amount } = parsed.data;
195
- const wallet = await (0, wallet_js_1.getOrCreateWallet)();
196
- const result = await (0, convex_js_1.callConvex)("/mcp/defi/send", "POST", parsed.data, "send_token");
197
- if (!result.success)
198
- return { content: [{ type: "text", text: `Send failed: ${result.error}` }], isError: true };
199
- const txHash = await (0, wallet_js_1.signAndBroadcast)(wallet, result.txData);
200
- const receipt = await (0, wallet_js_1.waitForReceipt)(txHash);
201
- const header = !receipt.mined
202
- ? `⏳ Broadcast, not yet confirmed within the wait window.`
203
- : receipt.ok
204
- ? `✅ Sent - confirmed on-chain (block ${receipt.blockNumber}).`
205
- : `❌ Transaction REVERTED - funds were NOT sent (gas was still spent).`;
206
- return {
207
- content: [{
208
- type: "text",
209
- text: [
210
- header,
211
- `${amount} ${token.toUpperCase()} → \`${toAddress}\``,
212
- `Tx Hash: \`${txHash}\``,
213
- `https://basescan.org/tx/${txHash}`,
214
- !receipt.mined ? `Check the link above before telling the user this succeeded or failed.` : "",
215
- ].filter(Boolean).join("\n"),
216
- }],
217
- isError: receipt.mined && !receipt.ok,
218
- };
219
- }
220
- // analyze_wallet was removed - it called POST /wallet/analyze, which was
221
- // never registered in app/convex/http.ts (only a dangling section-header
222
- // comment exists there). The tool was never in the exported DEFI_TOOLS
223
- // list either, so this case was already unreachable dead code - no Tool
224
- // registers the name "analyze_wallet" for the MCP dispatcher to route to.
225
- case "get_defi_yields": {
226
- const parsed = DefiYieldsSchema.safeParse(args ?? {});
227
- if (!parsed.success)
228
- return { content: [{ type: "text", text: `${parsed.error.issues[0].message}` }], isError: true };
229
- const { token, minApy = 1, limit = 20 } = parsed.data;
230
- let pools;
231
- try {
232
- const res = await fetch("https://yields.llama.fi/pools", { signal: AbortSignal.timeout(15000) });
233
- if (!res.ok)
234
- throw new Error(`HTTP ${res.status}`);
235
- const data = await res.json();
236
- pools = data.data ?? [];
237
- }
238
- catch (e) {
239
- return { content: [{ type: "text", text: `DeFiLlama fetch failed: ${e.message}` }], isError: true };
240
- }
241
- // Filter to Base chain
242
- let filtered = pools.filter((p) => p.chain === "Base");
243
- // Filter by token if specified
244
- if (token) {
245
- const upper = token.toUpperCase();
246
- filtered = filtered.filter((p) => (p.symbol ?? "").toUpperCase().includes(upper) ||
247
- (p.underlyingTokens ?? []).some((t) => t.toUpperCase().includes(upper)));
248
- }
249
- // Filter by minimum APY and remove outliers (>10000% are usually broken)
250
- filtered = filtered
251
- .filter((p) => (p.apy ?? 0) >= minApy && (p.apy ?? 0) <= 10000)
252
- .sort((a, b) => (b.apy ?? 0) - (a.apy ?? 0))
253
- .slice(0, limit);
254
- if (!filtered.length) {
255
- return {
256
- content: [{
257
- type: "text",
258
- text: [
259
- `## DeFi Yields on Base`,
260
- `No pools found${token ? ` for ${token.toUpperCase()}` : ""} with APY ≥ ${minApy}%.`,
261
- ``,
262
- `Try lowering \`minApy\` or removing the token filter.`,
263
- ].join("\n"),
264
- }],
265
- structuredContent: buildDefiYields(token, minApy, []),
266
- };
267
- }
268
- const fmt = (n) => n >= 1000000000 ? `$${(n / 1000000000).toFixed(1)}B`
269
- : n >= 1000000 ? `$${(n / 1000000).toFixed(1)}M`
270
- : n >= 1000 ? `$${(n / 1000).toFixed(0)}K`
271
- : `$${n.toFixed(0)}`;
272
- const lines = [
273
- `## DeFi Yields on Base${token ? ` - ${token.toUpperCase()}` : ""}`,
274
- `Top ${filtered.length} pools · APY ≥ ${minApy}% · Source: DeFiLlama`,
275
- ``,
276
- `| # | Pool | Protocol | APY | TVL |`,
277
- `|---|------|----------|-----|-----|`,
278
- ];
279
- filtered.forEach((p, i) => {
280
- const apy = (p.apy ?? 0).toFixed(1);
281
- const tvl = fmt(p.tvlUsd ?? 0);
282
- const name = (p.symbol ?? p.pool ?? "-").replace(/-/g, " ");
283
- const proj = p.project ?? "-";
284
- lines.push(`| ${i + 1} | ${name} | ${proj} | **${apy}%** | ${tvl} |`);
285
- });
286
- lines.push(``, `Use \`swap_tokens\` to position, then deposit via the protocol's UI. Always check smart contract risk before depositing.`);
287
- return { content: [{ type: "text", text: lines.join("\n") }], structuredContent: buildDefiYields(token, minApy, filtered) };
288
- }
289
- default:
290
- return null;
291
- }
292
- }
@@ -1,372 +0,0 @@
1
- "use strict";
2
- // Public-company fundamentals from primary sources.
3
- //
4
- // Financials come from SEC EDGAR XBRL — the numbers as filed in 10-Q/10-K, not
5
- // a vendor's re-typing of them. No API key, no rate-limit tier, and if a figure
6
- // here is wrong it is wrong in the filing itself.
7
- //
8
- // The tool computes what is mechanical (margins, YoY growth, quarter vs YTD)
9
- // and hands the result to the caller's model to interpret. It does not write
10
- // the analysis: the caller has the user's thesis, risk tolerance and context,
11
- // and is a stronger analyst than anything this module could embed.
12
- Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.EQUITY_TOOLS = void 0;
14
- exports.buildStockFundamentals = buildStockFundamentals;
15
- exports.handleEquityTool = handleEquityTool;
16
- const zod_1 = require("zod");
17
- // ── Structured output builder (schema in output-schemas.ts) ─────────────────
18
- function buildStockFundamentals(ticker, companyName, cik, quote, quarterly, annual) {
19
- return { ticker, companyName, cik, quote, quarterly, annual };
20
- }
21
- const SEC_UA = "Finch MCP research (contact: support@finchagentic.com)";
22
- const SEC_TICKERS = "https://www.sec.gov/files/company_tickers.json";
23
- const SEC_CONCEPT = (cik, tag) => `https://data.sec.gov/api/xbrl/companyconcept/CIK${cik}/us-gaap/${tag}.json`;
24
- /**
25
- * Concepts pulled per company. Order drives the report layout.
26
- *
27
- * Each concept lists several candidate XBRL tags because issuers do not agree
28
- * on which one to use, and a company can stop using one mid-history. Apple
29
- * abandoned `Revenues` at the ASC 606 transition and reports under
30
- * `RevenueFromContractWithCustomer…`; querying only `Revenues` returns its
31
- * pre-2019 figures, which then render as the current picture — stale by years,
32
- * and indistinguishable from correct output. The tag with the most recent data
33
- * wins, so a legacy series can never outrank a live one.
34
- */
35
- const CONCEPTS = [
36
- {
37
- key: "revenue",
38
- label: "Revenue",
39
- kind: "flow",
40
- tags: [
41
- "Revenues",
42
- "RevenueFromContractWithCustomerExcludingAssessedTax",
43
- "RevenueFromContractWithCustomerIncludingAssessedTax",
44
- "SalesRevenueNet",
45
- ],
46
- },
47
- { key: "netIncome", label: "Net income", kind: "flow", tags: ["NetIncomeLoss"] },
48
- { key: "opIncome", label: "Operating income", kind: "flow", tags: ["OperatingIncomeLoss"] },
49
- { key: "eps", label: "EPS (diluted)", kind: "pershare", tags: ["EarningsPerShareDiluted"] },
50
- { key: "assets", label: "Total assets", kind: "stock", tags: ["Assets"] },
51
- { key: "liabilities", label: "Total liabilities", kind: "stock", tags: ["Liabilities"] },
52
- {
53
- key: "equity",
54
- label: "Shareholders' equity",
55
- kind: "stock",
56
- tags: ["StockholdersEquity", "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest"],
57
- },
58
- {
59
- key: "cash",
60
- label: "Cash & equivalents",
61
- kind: "stock",
62
- tags: [
63
- "CashAndCashEquivalentsAtCarryingValue",
64
- "CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents",
65
- ],
66
- },
67
- ];
68
- exports.EQUITY_TOOLS = [
69
- {
70
- name: "stock_fundamentals",
71
- description: "Fetch a public company's financials straight from SEC EDGAR XBRL (as filed in 10-Q/10-K) " +
72
- "plus a live quote. Returns revenue, operating and net income, diluted EPS, balance sheet, " +
73
- "computed margins and YoY growth — with quarterly figures correctly separated from " +
74
- "year-to-date ones. US-listed issuers only. No API key needed. " +
75
- "You do the analysis: the numbers and a review rubric come back, not a written opinion.",
76
- inputSchema: {
77
- type: "object",
78
- properties: {
79
- ticker: { type: "string", description: "US ticker symbol, e.g. 'HOOD', 'COIN', 'AAPL'" },
80
- periods: { type: "number", description: "How many recent periods per metric (default 6, max 12)" },
81
- },
82
- required: ["ticker"],
83
- },
84
- },
85
- ];
86
- const Schema = zod_1.z.object({
87
- ticker: zod_1.z.string().min(1).max(10),
88
- periods: zod_1.z.number().int().min(1).max(12).optional(),
89
- });
90
- function fmtMoney(v) {
91
- const a = Math.abs(v);
92
- const sign = v < 0 ? "-" : "";
93
- if (a >= 1e9)
94
- return `${sign}$${(a / 1e9).toFixed(2)}B`;
95
- if (a >= 1e6)
96
- return `${sign}$${(a / 1e6).toFixed(0)}M`;
97
- if (a >= 1e3)
98
- return `${sign}$${(a / 1e3).toFixed(0)}K`;
99
- return `${sign}$${a.toFixed(2)}`;
100
- }
101
- /**
102
- * XBRL reports the same tag as both a quarterly and a cumulative figure for the
103
- * same period end. Treating them as one series silently triples a quarter, so
104
- * classify by the reported span before anything else touches the numbers.
105
- */
106
- function spanKind(f) {
107
- if (!f.start)
108
- return "point";
109
- const days = Math.round((Date.parse(f.end) - Date.parse(f.start)) / 86400000);
110
- if (days <= 110)
111
- return "quarter";
112
- if (days <= 300)
113
- return "ytd";
114
- return "annual";
115
- }
116
- async function secJson(url) {
117
- try {
118
- const res = await fetch(url, {
119
- headers: { "User-Agent": SEC_UA, Accept: "application/json" },
120
- signal: AbortSignal.timeout(20000),
121
- });
122
- if (!res.ok)
123
- return null;
124
- return await res.json();
125
- }
126
- catch {
127
- return null;
128
- }
129
- }
130
- async function resolveCik(ticker) {
131
- const data = await secJson(SEC_TICKERS);
132
- if (!data)
133
- return null;
134
- const up = ticker.trim().toUpperCase();
135
- const hit = Object.values(data).find((x) => String(x.ticker).toUpperCase() === up);
136
- if (!hit)
137
- return null;
138
- return { cik: String(hit.cik_str).padStart(10, "0"), name: hit.title };
139
- }
140
- /**
141
- * Live quote. Best-effort — fundamentals stand on their own without it.
142
- *
143
- * `meta.chartPreviousClose` is the close BEFORE THE REQUESTED RANGE BEGINS, not
144
- * the previous session. Reading it off a multi-day range reports a multi-day
145
- * move as a one-day move: on 2026-07-22 HOOD sat at $106.71 against a genuine
146
- * prior close of $106.36 (+0.33%), but a `range=5d` request returns $115.54 —
147
- * the close six sessions back — for a headline of -7.6% that never happened.
148
- *
149
- * `regularMarketPreviousClose` is absent from this endpoint, so the previous
150
- * close is taken from the daily bar series instead: walk back past today's
151
- * still-forming bar and use the last completed session. That is derived from
152
- * the data rather than from a field whose meaning shifts with the query.
153
- */
154
- async function liveQuote(ticker) {
155
- try {
156
- const res = await fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}?interval=1d&range=10d`, { headers: { "User-Agent": "Mozilla/5.0" }, signal: AbortSignal.timeout(12000) });
157
- if (!res.ok)
158
- return null;
159
- const j = await res.json();
160
- const r = j?.chart?.result?.[0];
161
- const m = r?.meta;
162
- if (!m?.regularMarketPrice)
163
- return null;
164
- const stamps = r?.timestamp ?? [];
165
- const closes = r?.indicators?.quote?.[0]?.close ?? [];
166
- const bars = stamps
167
- .map((t, i) => ({ day: new Date(t * 1000).toISOString().slice(0, 10), close: closes[i] }))
168
- .filter((b) => typeof b.close === "number");
169
- // The bar covering the quote's own timestamp is today's, still forming.
170
- const today = new Date((m.regularMarketTime ?? Date.now() / 1000) * 1000).toISOString().slice(0, 10);
171
- const completed = bars.filter((b) => b.day !== today);
172
- const prev = completed[completed.length - 1];
173
- if (!prev)
174
- return null;
175
- return { price: m.regularMarketPrice, prevClose: prev.close, prevDate: prev.day, currency: m.currency ?? "USD" };
176
- }
177
- catch {
178
- return null;
179
- }
180
- }
181
- async function handleEquityTool(name, args) {
182
- if (name !== "stock_fundamentals")
183
- return null;
184
- const parsed = Schema.safeParse(args);
185
- if (!parsed.success) {
186
- return { content: [{ type: "text", text: `${parsed.error.issues[0].message}` }], isError: true };
187
- }
188
- const ticker = parsed.data.ticker.trim().toUpperCase();
189
- const periods = parsed.data.periods ?? 6;
190
- const company = await resolveCik(ticker);
191
- if (!company) {
192
- return {
193
- content: [{
194
- type: "text",
195
- text: `No SEC filer found for **${ticker}**. This covers US-listed issuers that file with the SEC — ` +
196
- `foreign private issuers, private companies and ETFs may be absent. Check the symbol.`,
197
- }],
198
- isError: true,
199
- };
200
- }
201
- // SEC asks for no more than 10 requests a second, and the candidate tags push
202
- // the count past that if they all go out at once.
203
- const jobs = CONCEPTS.flatMap((c) => c.tags.map((tag) => ({ key: c.key, tag })));
204
- const raw = [];
205
- for (let i = 0; i < jobs.length; i += 6) {
206
- raw.push(...(await Promise.all(jobs.slice(i, i + 6).map((j) => secJson(SEC_CONCEPT(company.cik, j.tag))))));
207
- }
208
- const quote = await liveQuote(ticker);
209
- // Per concept, keep the candidate tag whose reported periods run latest.
210
- const chosen = new Map();
211
- jobs.forEach((j, i) => {
212
- const data = raw[i];
213
- if (!data?.units)
214
- return;
215
- const unitKey = Object.keys(data.units)[0];
216
- const facts = (data.units[unitKey] ?? []).filter((f) => f.form === "10-Q" || f.form === "10-K");
217
- if (!facts.length)
218
- return;
219
- const latest = facts.reduce((m, f) => (f.end > m ? f.end : m), "");
220
- const prev = chosen.get(j.key);
221
- if (!prev || latest > prev.latest)
222
- chosen.set(j.key, { tag: j.tag, facts, latest });
223
- });
224
- const lines = [
225
- `# ${company.name} (${ticker}) — as filed with the SEC`,
226
- ``,
227
- `CIK ${company.cik} · source: SEC EDGAR XBRL (10-Q / 10-K)`,
228
- ];
229
- if (quote) {
230
- const chg = ((quote.price - quote.prevClose) / quote.prevClose) * 100;
231
- lines.push(``,
232
- // The prior close is dated, so a stale or holiday-shifted comparison is
233
- // visible in the output rather than silently read as a one-day move.
234
- `**Live quote:** ${quote.currency} ${quote.price.toFixed(2)} ` +
235
- `(${chg >= 0 ? "+" : ""}${chg.toFixed(2)}% vs ${quote.prevDate} close ${quote.prevClose.toFixed(2)})`);
236
- }
237
- else {
238
- lines.push(``, `_Live quote unavailable — fundamentals below are unaffected._`);
239
- }
240
- const quarterly = {};
241
- const annual = {};
242
- const usedTags = [];
243
- CONCEPTS.forEach((c) => {
244
- const pick = chosen.get(c.key);
245
- if (!pick)
246
- return;
247
- usedTags.push(`${c.label} → \`${pick.tag}\``);
248
- const facts = pick.facts;
249
- // A period appears in more than one filing — the current 10-Q also carries
250
- // last year's comparative. Without deduping by period end the same quarter
251
- // is listed twice and the YoY lookup lands on a duplicate instead of the
252
- // year-ago quarter, silently reporting ~0% growth.
253
- const dedupe = (rows) => {
254
- const byEnd = new Map();
255
- for (const f of rows) {
256
- const prev = byEnd.get(f.end);
257
- // Prefer the original filing (earliest fy) so the label matches the period.
258
- if (!prev || (f.fy ?? 0) < (prev.fy ?? 0))
259
- byEnd.set(f.end, f);
260
- }
261
- return [...byEnd.values()].sort((a, b) => a.end.localeCompare(b.end));
262
- };
263
- if (c.kind === "stock") {
264
- annual[c.key] = dedupe(facts).slice(-periods);
265
- }
266
- else {
267
- quarterly[c.key] = dedupe(facts.filter((f) => spanKind(f) === "quarter")).slice(-periods);
268
- annual[c.key] = dedupe(facts.filter((f) => spanKind(f) === "annual")).slice(-periods);
269
- }
270
- });
271
- const render = (tag, label, kind, rows) => {
272
- if (!rows?.length)
273
- return;
274
- lines.push(``, `### ${label}`);
275
- for (const f of rows) {
276
- const v = kind === "pershare" ? `$${f.val.toFixed(2)}` : fmtMoney(f.val);
277
- lines.push(`- ${f.end} — **${v}**${f.fy ? ` _(${f.form} FY${f.fy}${f.fp && f.fp !== "FY" ? " " + f.fp : ""})_` : ""}`);
278
- }
279
- // YoY against the same quarter a year earlier, matched by DATE rather than
280
- // by position — periods are often missing, so counting back N rows can
281
- // silently compare against the wrong quarter.
282
- if (kind === "flow" && rows.length >= 2) {
283
- const latest = rows[rows.length - 1];
284
- const target = new Date(latest.end);
285
- target.setFullYear(target.getFullYear() - 1);
286
- const yearAgo = rows.find((r) => Math.abs(Date.parse(r.end) - target.getTime()) < 20 * 86400000);
287
- if (yearAgo?.val && yearAgo.end !== latest.end) {
288
- const g = ((latest.val - yearAgo.val) / Math.abs(yearAgo.val)) * 100;
289
- lines.push(`- **YoY (${yearAgo.end} → ${latest.end}): ${g >= 0 ? "+" : ""}${g.toFixed(1)}%**`);
290
- }
291
- }
292
- };
293
- lines.push(``, `---`, ``, `## Quarterly (each figure is a single quarter, not year-to-date)`);
294
- for (const c of CONCEPTS) {
295
- if (c.kind === "stock")
296
- continue;
297
- render(c.key, c.label, c.kind, quarterly[c.key]);
298
- }
299
- lines.push(``, `---`, ``, `## Annual & balance sheet`);
300
- for (const c of CONCEPTS)
301
- render(c.key, c.label + (c.kind === "stock" ? " (period end)" : " — annual"), c.kind, annual[c.key]);
302
- // Per-share figures are filed as-reported for their period and are NOT
303
- // restated for later splits. NVDA's annual EPS runs $11.93 → $2.94 across the
304
- // 10-for-1 of June 2024, which reads as a 75% earnings collapse in the same
305
- // year revenue grew 114%. The split is discoverable — it is an 8-K item 5.03 —
306
- // but only if the reader thinks to look, so the discontinuity is named here
307
- // instead of left as a trap. Detected by direction: a per-share series moving
308
- // hard against net income over the same periods is a share-count event, not
309
- // an earnings event.
310
- const splitWarnings = [];
311
- for (const [label, eps, profit] of [
312
- ["quarterly", quarterly["eps"], quarterly["netIncome"]],
313
- ["annual", annual["eps"], annual["netIncome"]],
314
- ]) {
315
- for (let i = 1; i < (eps?.length ?? 0); i++) {
316
- const [prev, cur] = [eps[i - 1], eps[i]];
317
- if (!prev.val || !cur.val || prev.val <= 0 || cur.val <= 0)
318
- continue;
319
- const epsChange = (cur.val - prev.val) / prev.val;
320
- const ni = profit?.find((f) => f.end === cur.end);
321
- const niPrev = profit?.find((f) => f.end === prev.end);
322
- if (!ni?.val || !niPrev?.val || niPrev.val <= 0)
323
- continue;
324
- const niChange = (ni.val - niPrev.val) / niPrev.val;
325
- // EPS down hard while profit held or grew (or the mirror image).
326
- if ((epsChange < -0.4 && niChange > -0.1) || (epsChange > 0.6 && niChange < 0.1)) {
327
- // Shares = income / EPS, so the implied share-count change is
328
- // (income ratio) x (EPS ratio inverted). The raw EPS ratio alone
329
- // conflates the split with the earnings move and understates it —
330
- // NVDA's 10-for-1 reads as 4:1 until the income growth is divided out.
331
- const shareRatio = (ni.val / niPrev.val) * (prev.val / cur.val);
332
- const asSplit = shareRatio >= 1.5
333
- ? `${shareRatio.toFixed(1)}-for-1`
334
- : shareRatio <= 0.67
335
- ? `1-for-${(1 / shareRatio).toFixed(1)} reverse`
336
- : null;
337
- splitWarnings.push(`- **${label} EPS ${prev.end} → ${cur.end}**: $${prev.val.toFixed(2)} → $${cur.val.toFixed(2)} ` +
338
- `(${(epsChange * 100).toFixed(0)}%) while net income moved ${(niChange * 100).toFixed(0)}%. ` +
339
- `Implied share count ×${shareRatio.toFixed(2)}${asSplit ? ` — consistent with a ${asSplit} split` : ""}, ` +
340
- `not an earnings change. Confirm with \`stock_events\` (8-K item 5.03) before comparing these two periods.`);
341
- }
342
- }
343
- }
344
- if (splitWarnings.length) {
345
- lines.push(``, `---`, ``, `## ⚠️ Per-share figures are not split-adjusted`, ``, ...splitWarnings, ``, `Revenue, income and balance-sheet lines are unaffected — only per-share values are.`);
346
- }
347
- // Margins, computed rather than asserted.
348
- const rev = quarterly["revenue"]?.slice(-1)[0];
349
- const ni = quarterly["netIncome"]?.slice(-1)[0];
350
- const oi = quarterly["opIncome"]?.slice(-1)[0];
351
- if (rev?.val) {
352
- lines.push(``, `---`, ``, `## Computed margins — latest quarter (${rev.end})`);
353
- if (oi)
354
- lines.push(`- Operating margin: **${((oi.val / rev.val) * 100).toFixed(1)}%**`);
355
- if (ni)
356
- lines.push(`- Net margin: **${((ni.val / rev.val) * 100).toFixed(1)}%**`);
357
- }
358
- lines.push(``, `---`, ``, `## How to analyse this`, ``, `**Trajectory** — is revenue growth accelerating or decelerating? Compare the last 4 quarters ` +
359
- `sequentially, then against the same quarter a year earlier. Say which, and by how much.`, ``, `**Quality of earnings** — is net income tracking operating income, or driven by items below ` +
360
- `the operating line? A widening gap deserves an explanation.`, ``, `**Balance sheet** — equity vs liabilities trend, and whether cash covers near-term obligations.`, ``, `**What these numbers cannot tell you** — guidance, competitive position, regulatory exposure, ` +
361
- `insider activity, and anything after the last filing date above. Say so explicitly rather ` +
362
- `than inferring it. For sentiment and post-filing developments, run \`deep_research\`.`, ``, `Cite the period end for every figure you use. Do not annualise a quarter without labelling it ` +
363
- `as your own extrapolation. If a metric is missing above, the company did not file that tag — ` +
364
- `do not substitute an estimate.`, ``,
365
- // Issuers tag the same concept differently, so naming the tag behind each
366
- // series makes the figures checkable against EDGAR rather than trusted.
367
- `_XBRL concepts used: ${usedTags.join(" · ")}_`, ``, `_Figures are as filed with the SEC. Not investment advice._`);
368
- return {
369
- content: [{ type: "text", text: lines.join("\n") }],
370
- structuredContent: buildStockFundamentals(ticker, company.name ?? null, company.cik ?? null, quote, quarterly, annual),
371
- };
372
- }