@symbols-cli/cli 0.0.1

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 (41) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +103 -0
  3. package/dist/auth/client.js +531 -0
  4. package/dist/auth/credentials.js +293 -0
  5. package/dist/auth/hosts.js +85 -0
  6. package/dist/auth/loopback.js +108 -0
  7. package/dist/auth/pkce.js +33 -0
  8. package/dist/auth/wire.js +40 -0
  9. package/dist/commands/arm.js +154 -0
  10. package/dist/commands/curl.js +101 -0
  11. package/dist/commands/doctor.js +217 -0
  12. package/dist/commands/login.js +113 -0
  13. package/dist/commands/logout.js +78 -0
  14. package/dist/commands/mcp.js +33 -0
  15. package/dist/commands/project.js +145 -0
  16. package/dist/commands/status.js +78 -0
  17. package/dist/commands/sync.js +94 -0
  18. package/dist/commands/uninstall.js +149 -0
  19. package/dist/commands/up.js +176 -0
  20. package/dist/commands/update.js +120 -0
  21. package/dist/commands/watch.js +155 -0
  22. package/dist/commands/whoami.js +103 -0
  23. package/dist/index.js +147 -0
  24. package/dist/mcp/scopes.js +215 -0
  25. package/dist/mcp/server.js +366 -0
  26. package/dist/mcp/tools.js +646 -0
  27. package/dist/skills/bundle.js +441 -0
  28. package/dist/skills/claude-md.js +135 -0
  29. package/dist/skills/install.js +188 -0
  30. package/dist/skills/settings-merge.js +107 -0
  31. package/dist/sync/api.js +380 -0
  32. package/dist/sync/diff.js +172 -0
  33. package/dist/sync/ledger.js +319 -0
  34. package/dist/sync/paths.js +447 -0
  35. package/dist/sync/protect.js +108 -0
  36. package/dist/sync/reconcile.js +870 -0
  37. package/dist/sync/watcher.js +206 -0
  38. package/dist/util/log.js +58 -0
  39. package/dist/util/platform.js +79 -0
  40. package/dist/util/version.js +24 -0
  41. package/package.json +44 -0
@@ -0,0 +1,215 @@
1
+ // Copyright (c) 2025 Symbols LLC. All rights reserved.
2
+ //
3
+ // This source code is proprietary and confidential. Unauthorized copying,
4
+ // distribution, modification, or use of this file, via any medium, is strictly prohibited.
5
+ // The read surface the Symbols MCP server may reach, and the matcher that
6
+ // enforces it before anything goes on the wire.
7
+ //
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ // ⚠ THE INCIDENT THIS FILE EXISTS TO PREVENT
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // The Python MCP server (`docker/odin-runtime/symbols-mcp/__main__.py`) kept a
12
+ // hand-maintained `SCOPED_PATHS`. On 2026-07-22 the Rust scope list was narrowed
13
+ // and this list was not, so for THREE WEEKS half the advertised tools returned
14
+ // 403/404 — which, from the agent's side, is indistinguishable from "the Symbols
15
+ // tools are not connected". Nobody noticed, because nothing compared the two.
16
+ //
17
+ // So the list below is NOT hand-written. Everything between the GENERATED
18
+ // markers is emitted by:
19
+ //
20
+ // python3 scripts/check_mcp_scopes.py --write
21
+ //
22
+ // from `apps/server/kernel/shell_token.rs` — specifically the GET half of
23
+ // `SHELL_TOKEN_SCOPES` ∪ `CLI_EXTRA_SCOPES`, which is exactly the read surface
24
+ // `cli_scope_allows` permits a CLI token. The same script, run without `--write`,
25
+ // FAILS when the two disagree in either direction, and it is publish-blocking.
26
+ //
27
+ // Deriving rather than curating is the point. Any hand-picked subset — however
28
+ // sensible — re-opens the drift class, because a subset has no mechanical
29
+ // definition and therefore no mechanical check.
30
+ //
31
+ // ⚠ `nest("/api", …)` strips the prefix server-side, so every template here
32
+ // carries `/api/...`. A template written without it authorises nothing and 403s
33
+ // silently while both lists still look self-consistent.
34
+ /* eslint-disable */
35
+ // ─── GENERATED: SCOPED_PATHS (do not edit by hand) ───────────────────────────
36
+ /**
37
+ * Every route template a CLI token may GET.
38
+ *
39
+ * Derived: `{ t | (GET, t) ∈ SHELL_TOKEN_SCOPES ∪ CLI_EXTRA_SCOPES }`.
40
+ */
41
+ export const SCOPED_PATHS = [
42
+ "/api/quote/{symbol}",
43
+ "/api/quotes",
44
+ "/api/chart/pricing-data",
45
+ "/api/chart/sparklines",
46
+ "/api/options/chart-pricing",
47
+ "/api/levels/{ticker}",
48
+ "/api/scan/catalog",
49
+ "/api/scan/presets",
50
+ "/api/scan/sectors",
51
+ "/api/research/papers/search",
52
+ "/api/research/papers/tags",
53
+ "/api/research/papers/graph",
54
+ "/api/research/papers/topic-graph",
55
+ "/api/research/papers/stats",
56
+ "/api/research/papers/{resource_id}",
57
+ "/api/flow/alerts",
58
+ "/api/flow/analysts",
59
+ "/api/flow/congress",
60
+ "/api/flow/darkpool",
61
+ "/api/flow/economic-calendar",
62
+ "/api/flow/fda-calendar",
63
+ "/api/flow/gex",
64
+ "/api/flow/gex-aggregate",
65
+ "/api/flow/gex-expiry",
66
+ "/api/flow/headlines",
67
+ "/api/flow/insider",
68
+ "/api/flow/market-tide",
69
+ "/api/flow/max-pain",
70
+ "/api/flow/oi-change",
71
+ "/api/flow/options-volume",
72
+ "/api/flow/sector-etfs",
73
+ "/api/flow/spot-exposures",
74
+ "/api/flow/vol-realized",
75
+ "/api/flow/vol-stats",
76
+ "/api/flow/vol-term",
77
+ "/api/company/profile/{symbol}",
78
+ "/api/earnings/{symbol}",
79
+ "/api/earnings/{symbol}/uw",
80
+ "/api/ratios/{symbol}",
81
+ "/api/financial-statements/{symbol}",
82
+ "/api/regimes",
83
+ "/api/regimes/stats",
84
+ "/api/regimes/by-symbol/{symbol}",
85
+ "/api/regimes/{regime_id}",
86
+ "/api/regimes/{regime_id}/with-stats",
87
+ "/api/regimes/{regime_id}/health",
88
+ "/api/regime-trades",
89
+ "/api/regime-trades/{trade_id}",
90
+ "/api/regime-trades/regime/{regime_id}",
91
+ "/api/regime-trades/regime/{regime_id}/open",
92
+ "/api/regime-trades/regime/{regime_id}/stats",
93
+ "/api/regime-executions/{execution_id}",
94
+ "/api/regime-executions/{execution_id}/with-trades",
95
+ "/api/regime-executions/regime/{regime_id}",
96
+ "/api/regime-executions/regime/{regime_id}/latest",
97
+ "/api/regime-executions/regime/{regime_id}/running",
98
+ "/api/regime-executions/regime/{regime_id}/stats",
99
+ "/api/feeds/status",
100
+ "/api/data-pipeline/tickers",
101
+ "/api/odin-code/brokerage/snapshot",
102
+ "/api/odin-code/brokerage/history",
103
+ "/api/notebooks/{notebook_id}/files",
104
+ "/api/notebooks/{notebook_id}/files/tree",
105
+ "/api/notebooks/{notebook_id}/files/bulk-content",
106
+ "/api/notebooks/{notebook_id}/files/combined",
107
+ "/api/notebooks/{notebook_id}/files/download",
108
+ "/api/notebooks/{notebook_id}/files/artifacts",
109
+ "/api/notebooks/files/{file_id}",
110
+ "/api/notebooks",
111
+ "/api/notebooks/search",
112
+ "/api/cli/arm/status",
113
+ "/api/cli/projects",
114
+ "/api/cli/bundle/manifest",
115
+ "/api/cli/bundle.tgz",
116
+ "/api/auth/cli/session",
117
+ ];
118
+ // ─── END GENERATED: SCOPED_PATHS ─────────────────────────────────────────────
119
+ // ─── GENERATED: WRITE_PATHS (do not edit by hand) ────────────────────────────
120
+ /**
121
+ * Paths reached with the SHORT-LIVED write credential (POST/DELETE).
122
+ *
123
+ * ⚠ **EMPTY IN v1, DELIBERATELY.** `place_order`, `cancel_order`,
124
+ * `deploy_regime` and `backtest_regime` are NOT ported — settled in P0 so this
125
+ * phase would know whether it was porting 12, 14 or 16 tools. They ship in
126
+ * **P5**, together with the armed window (`symbols arm`) that is the only thing
127
+ * making an autonomous order credential on a laptop defensible.
128
+ *
129
+ * The list, the checker's write arm, and the disjointness assertion all stay
130
+ * live rather than being deleted, so P5 fills a seam that is already verified
131
+ * instead of re-deriving one. See `mcp/tools.ts` — `P5_SEAM`.
132
+ */
133
+ export const WRITE_PATHS = [];
134
+ // ─── END GENERATED: WRITE_PATHS ──────────────────────────────────────────────
135
+ /* eslint-enable */
136
+ /**
137
+ * Split `"GET /api/quote/{symbol}"` into its method and template.
138
+ *
139
+ * Every tool declares its route in this one wire form, so a tool's declared
140
+ * surface and the string the checker reads are the same characters.
141
+ */
142
+ export function splitRoute(route) {
143
+ const i = route.indexOf(" ");
144
+ if (i <= 0)
145
+ throw new Error(`malformed route '${route}' — expected "METHOD /api/..."`);
146
+ return { method: route.slice(0, i), template: route.slice(i + 1) };
147
+ }
148
+ /**
149
+ * Does a CONCRETE path match one of the `SCOPED_PATHS` templates?
150
+ *
151
+ * Segment-wise: `{anything}` matches exactly one non-empty segment. Mirrors the
152
+ * server's own `template_matches` closely enough for a pre-flight check — the
153
+ * server remains the authority; this turns a would-be silent 403 into an
154
+ * actionable error before the wire.
155
+ *
156
+ * ⚠ Stricter than the Python original in one place, on purpose: `.` and `..` are
157
+ * refused as path segments. `{symbol}` happily matched `..` there, so
158
+ * `/api/quote/..` passed the allow-list and then normalised to `/api/` at the
159
+ * fetch layer — a tool argument that walked out of its own route. Nothing was
160
+ * exploitable behind it, but "the allow-list said yes to a traversal" is not a
161
+ * property worth carrying forward.
162
+ */
163
+ export function templateOk(path) {
164
+ if (path.includes("?") || path.includes("#"))
165
+ return false;
166
+ if (!path.startsWith("/api/"))
167
+ return false;
168
+ const parts = path.split("/");
169
+ // parts[0] is "" from the leading slash; every later segment must be a real,
170
+ // non-relative name.
171
+ for (const seg of parts.slice(1)) {
172
+ if (seg === "" || seg === "." || seg === "..")
173
+ return false;
174
+ }
175
+ return SCOPED_PATHS.some((tmpl) => {
176
+ const t = tmpl.split("/");
177
+ if (t.length !== parts.length)
178
+ return false;
179
+ return t.every((seg, i) => {
180
+ const part = parts[i];
181
+ if (seg.startsWith("{") && seg.endsWith("}"))
182
+ return part !== "";
183
+ return seg === part;
184
+ });
185
+ });
186
+ }
187
+ /** The `/api/<family>` prefixes, for an actionable refusal message. */
188
+ export function allowedFamilies() {
189
+ return [...new Set(SCOPED_PATHS.map((t) => t.split("/").slice(0, 3).join("/")))].sort();
190
+ }
191
+ /**
192
+ * Substitute path parameters into a route template.
193
+ *
194
+ * ⚠ This is the ONLY place a request path is built, and that is the design.
195
+ * Each tool declares a template that the checker reads and that this function
196
+ * fills — so a tool's declared route and the bytes on the wire cannot diverge.
197
+ * The Python original had a template list on one side and hand-written f-strings
198
+ * on the other, and needed an AST pass to keep them honest.
199
+ *
200
+ * Values are percent-encoded. The Python version interpolated `args['symbol']`
201
+ * raw, so a `/` in a tool argument added path segments.
202
+ */
203
+ export function buildPath(template, params = {}) {
204
+ const out = template.replace(/\{([^}]+)\}/g, (_m, name) => {
205
+ const v = params[name];
206
+ if (v === undefined || v === "") {
207
+ throw new Error(`route '${template}' needs a value for '{${name}}'`);
208
+ }
209
+ return encodeURIComponent(v);
210
+ });
211
+ if (out.includes("{") || out.includes("}")) {
212
+ throw new Error(`route '${template}' still has unfilled parameters after substitution`);
213
+ }
214
+ return out;
215
+ }
@@ -0,0 +1,366 @@
1
+ // Copyright (c) 2025 Symbols LLC. All rights reserved.
2
+ //
3
+ // This source code is proprietary and confidential. Unauthorized copying,
4
+ // distribution, modification, or use of this file, via any medium, is strictly prohibited.
5
+ // `symbols mcp` — the stdio MCP server.
6
+ //
7
+ // Port of `docker/odin-runtime/symbols-mcp/__main__.py`. The tool surface lives
8
+ // in `tools.ts`; the allow-list in `scopes.ts`; this file is the transport, the
9
+ // registration, and the checks that make a failure LOUD.
10
+ //
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ // ⚠ THE FAILURE MODE THIS FILE IS BUILT AROUND
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // The Python server died AT IMPORT when the image moved to `mcp` 2.0, which had
15
+ // dropped the 1.x decorators:
16
+ //
17
+ // AttributeError: 'Server' object has no attribute 'list_tools'
18
+ //
19
+ // The process never served a byte. `claude mcp list` said "Failed to connect",
20
+ // and to the agent that is indistinguishable from "this user has no Symbols
21
+ // access" — so it simply stopped using the tools and nobody filed a bug.
22
+ //
23
+ // A tool server that starts and registers NOTHING is worse than one that
24
+ // crashes, because a crash is visible. Three guards, in order:
25
+ //
26
+ // 1. `loadSdk()` — dynamic import inside a try, so an SDK that cannot load, or
27
+ // that no longer exports what we call, produces a named diagnostic instead
28
+ // of an unhandled rejection.
29
+ // 2. `assertSurface()` — every SDK symbol we use is checked for existence and
30
+ // callability BEFORE any of it is used. This is the check that would have
31
+ // caught the 1.x -> 2.x break at the door.
32
+ // 3. `EXPECTED_TOOL_COUNT` — the tool list is asserted non-empty, unique, and
33
+ // exactly the expected size before the transport is connected. Adding a
34
+ // tool means bumping a number; that is deliberate.
35
+ //
36
+ // The in-process round trip (a real MCP client over `InMemoryTransport` calling
37
+ // `tools/list` and `tools/call`) lives in `apps/cli/test/mcp-server.test.mjs`,
38
+ // where it can afford to be exhaustive.
39
+ //
40
+ // ⚠ STDOUT IS THE PROTOCOL. Not a single byte of logging may go there — a stray
41
+ // `console.log` corrupts the JSON-RPC stream and looks like a client bug. Every
42
+ // diagnostic in this file writes to stderr.
43
+ import { EXPECTED_TOOL_COUNT, TOOLS, assertDerivedTablesAreSound, callTool, } from "./tools.js";
44
+ import { CLI_VERSION } from "../util/version.js";
45
+ import { enterProtocolMode, eprint } from "../util/log.js";
46
+ const SERVER_NAME = "symbols";
47
+ /** Diagnostics go to stderr, always. See the stdout warning above. */
48
+ function warn(line) {
49
+ eprint(`symbols-mcp: ${line}\n`);
50
+ }
51
+ export class McpStartupError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = "McpStartupError";
55
+ }
56
+ }
57
+ /**
58
+ * Import the MCP SDK, converting any failure into a named, actionable error.
59
+ *
60
+ * A top-level static import cannot be caught, so a broken or missing SDK would
61
+ * kill the process with a module-resolution stack — technically visible, but not
62
+ * to whoever has to work out why their tools disappeared.
63
+ */
64
+ export async function loadSdk() {
65
+ try {
66
+ const [server, stdio, types] = await Promise.all([
67
+ import("@modelcontextprotocol/sdk/server/index.js"),
68
+ import("@modelcontextprotocol/sdk/server/stdio.js"),
69
+ import("@modelcontextprotocol/sdk/types.js"),
70
+ ]);
71
+ return {
72
+ Server: server.Server,
73
+ StdioServerTransport: stdio.StdioServerTransport,
74
+ ListToolsRequestSchema: types.ListToolsRequestSchema,
75
+ CallToolRequestSchema: types.CallToolRequestSchema,
76
+ };
77
+ }
78
+ catch (err) {
79
+ throw new McpStartupError(`could not load @modelcontextprotocol/sdk (${err instanceof Error ? err.message : String(err)}). ` +
80
+ `Reinstall the CLI: npm i -g @symbols-cli/cli`);
81
+ }
82
+ }
83
+ /**
84
+ * Assert the SDK still exports what this file calls.
85
+ *
86
+ * This is the guard for the exact 1.x -> 2.x class of break: the package loads,
87
+ * the constructor exists, and one method we depend on has quietly gone. Checking
88
+ * before use turns that into a startup refusal naming the missing symbol.
89
+ */
90
+ export function assertSurface(sdk) {
91
+ const missing = [];
92
+ if (typeof sdk.Server !== "function")
93
+ missing.push("Server (constructor)");
94
+ if (typeof sdk.StdioServerTransport !== "function")
95
+ missing.push("StdioServerTransport (constructor)");
96
+ if (typeof sdk.Server?.prototype?.setRequestHandler !== "function") {
97
+ missing.push("Server.prototype.setRequestHandler");
98
+ }
99
+ if (typeof sdk.Server?.prototype?.connect !== "function") {
100
+ missing.push("Server.prototype.connect");
101
+ }
102
+ for (const [name, schema] of [
103
+ ["ListToolsRequestSchema", sdk.ListToolsRequestSchema],
104
+ ["CallToolRequestSchema", sdk.CallToolRequestSchema],
105
+ ]) {
106
+ if (!schema || typeof schema !== "object")
107
+ missing.push(name);
108
+ }
109
+ if (missing.length > 0) {
110
+ throw new McpStartupError(`the installed @modelcontextprotocol/sdk does not export: ${missing.join(", ")}. ` +
111
+ `This is the failure that silently removed every Symbols tool once before — ` +
112
+ `refusing to start rather than serving an empty tool list.`);
113
+ }
114
+ }
115
+ /** The `tools/list` payload, built once and asserted before anything connects. */
116
+ export function listToolsPayload(specs = TOOLS) {
117
+ return specs.map((t) => ({
118
+ name: t.name,
119
+ description: t.description,
120
+ inputSchema: t.inputSchema,
121
+ }));
122
+ }
123
+ /**
124
+ * Refuse to serve a tool list that is empty, duplicated, or the wrong size.
125
+ *
126
+ * The size check is the one that matters: it makes "registered nothing" a
127
+ * startup failure with a message, instead of a healthy-looking server with no
128
+ * capabilities.
129
+ */
130
+ export function assertRegistration(tools) {
131
+ if (tools.length === 0) {
132
+ throw new McpStartupError("the tool table is EMPTY. Refusing to start — a connected server with no " +
133
+ "tools is indistinguishable from a healthy one, and that is how the " +
134
+ "Symbols tools vanished for three weeks.");
135
+ }
136
+ if (tools.length !== EXPECTED_TOOL_COUNT) {
137
+ throw new McpStartupError(`expected ${EXPECTED_TOOL_COUNT} tools, built ${tools.length}. ` +
138
+ `If a tool was added or removed on purpose, update EXPECTED_TOOL_COUNT ` +
139
+ `in mcp/tools.ts in the same change.`);
140
+ }
141
+ const names = new Set();
142
+ for (const t of tools) {
143
+ if (!t.name)
144
+ throw new McpStartupError("a tool has an empty name");
145
+ if (names.has(t.name))
146
+ throw new McpStartupError(`duplicate tool name '${t.name}'`);
147
+ names.add(t.name);
148
+ if (!t.description)
149
+ throw new McpStartupError(`tool '${t.name}' has no description`);
150
+ if (!t.inputSchema)
151
+ throw new McpStartupError(`tool '${t.name}' has no inputSchema`);
152
+ }
153
+ }
154
+ /**
155
+ * Build a fully-registered server WITHOUT connecting it.
156
+ *
157
+ * Split from `serve()` so the test can drive the very same object over an
158
+ * in-memory transport — the round trip then proves the real registration path,
159
+ * not a parallel one written for the test.
160
+ */
161
+ export async function buildServer(sdk) {
162
+ const resolved = sdk ?? (await loadSdk());
163
+ assertSurface(resolved);
164
+ assertDerivedTablesAreSound();
165
+ const tools = listToolsPayload();
166
+ assertRegistration(tools);
167
+ const server = new resolved.Server({ name: SERVER_NAME, version: CLI_VERSION }, { capabilities: { tools: {} } });
168
+ server.setRequestHandler(resolved.ListToolsRequestSchema, async () => ({ tools }));
169
+ server.setRequestHandler(resolved.CallToolRequestSchema, async (req) => {
170
+ const name = req.params.name;
171
+ const args = (req.params.arguments ?? {});
172
+ // `callTool` is written to answer rather than throw; this catch is the belt
173
+ // for anything genuinely unexpected. A tool that raises out of the handler
174
+ // reads to the agent as a broken connection, not a failed call.
175
+ let result;
176
+ try {
177
+ result = await callTool(name, args);
178
+ }
179
+ catch (err) {
180
+ warn(`tool ${name} raised: ${err instanceof Error ? err.message : String(err)}`);
181
+ result = { error: err instanceof Error ? err.message : String(err) };
182
+ }
183
+ // The Python server rendered every result as indented JSON text, and the
184
+ // agent's prompts are tuned to that shape. `default=str` there tolerated
185
+ // non-serialisable values; the JS equivalent is a replacer, because a throw
186
+ // from JSON.stringify would take the whole call down.
187
+ return { content: [{ type: "text", text: await capResult(renderResult(result)) }] };
188
+ });
189
+ return { server, tools };
190
+ }
191
+ /**
192
+ * The largest tool result that may go straight into the agent's context.
193
+ *
194
+ * Matches `sync/api.ts`'s `MAX_FILE_BYTES`, deliberately: that is already the
195
+ * size this system considers "a whole file", so it is a defensible line rather
196
+ * than an invented one.
197
+ */
198
+ export const MAX_RESULT_BYTES = 1_000_000;
199
+ /** How much of an oversized result the agent still sees inline. */
200
+ const HEAD_BYTES = 8_000;
201
+ /**
202
+ * Bound what a tool call can put into the agent's context.
203
+ *
204
+ * ⚠ NOTHING BOUNDED THIS. `MAX_FILE_BYTES` caps what SYNC pushes, but a tool
205
+ * result had no cap at all, and `api_get` is an explicit escape hatch. One wide
206
+ * call was buffered whole here, passed whole to the agent, and written whole to
207
+ * the transcript — degrading every later turn in that session PERMANENTLY,
208
+ * because context does not shrink.
209
+ *
210
+ * The shape is borrowed from Claude Code, measured on this machine: its
211
+ * transcript for this very session is 83 MB across 20,723 records and still
212
+ * appends instantly, because the big payloads live in a sibling `tool-results/`
213
+ * directory rather than in the hot file. Same idea here — spill the payload,
214
+ * keep a bounded head in context, and hand back a path.
215
+ *
216
+ * ⚠ THE TRUNCATION IS ANNOUNCED, ALWAYS. A silent truncation is worse than a
217
+ * slow response: the agent then reasons about a prefix believing it has the
218
+ * whole thing, and produces confidently wrong work. Saying so costs one line.
219
+ */
220
+ export async function capResult(text) {
221
+ const bytes = Buffer.byteLength(text, "utf8");
222
+ if (bytes <= MAX_RESULT_BYTES)
223
+ return text;
224
+ const head = Buffer.from(text, "utf8").subarray(0, HEAD_BYTES).toString("utf8");
225
+ let where = "(could not be written to disk)";
226
+ try {
227
+ const { promises: fs } = await import("node:fs");
228
+ const { join } = await import("node:path");
229
+ const { randomUUID } = await import("node:crypto");
230
+ const { symbolsHome } = await import("../util/platform.js");
231
+ const dir = join(symbolsHome(), "tool-results");
232
+ await fs.mkdir(dir, { recursive: true });
233
+ const file = join(dir, `${randomUUID()}.json`);
234
+ await fs.writeFile(file, text, { mode: 0o600 });
235
+ where = file;
236
+ }
237
+ catch {
238
+ // A spill failure must not fail the tool call. A truncated-but-announced
239
+ // result is still useful; losing the call outright is not.
240
+ }
241
+ return (`⚠ TRUNCATED — this result was ${bytes} bytes, over the ${MAX_RESULT_BYTES}-byte limit.\n` +
242
+ `The full payload was written to:\n ${where}\n\n` +
243
+ `Read it with a file tool, or narrow the request (a date range, fewer symbols,\n` +
244
+ `a specific field) so the answer fits. What follows is the first ${HEAD_BYTES}\n` +
245
+ `bytes ONLY — do not treat it as the complete result.\n\n` +
246
+ head);
247
+ }
248
+ /**
249
+ * JSON, indented, and never able to throw.
250
+ *
251
+ * ⚠ A7 — THE CYCLE/BIGINT REPLACER WAS DEAD CODE WITH A COST. Nothing reachable
252
+ * here can contain either: every value is `JSON.parse(text)` from the API or a
253
+ * plain `{error}` literal built in `tools.ts`. Neither can produce a cycle or a
254
+ * BigInt, so the `WeakSet` walked every node of every result for nothing.
255
+ *
256
+ * Measured on a legal maximum-size `get_bars` (50 000 bars, 6 022 347 bytes):
257
+ * with the replacer 43.6 ms, without it 14.4 ms. That ~29 ms is synchronous and
258
+ * buffered, so it stalled every CONCURRENT tool call in the process too.
259
+ *
260
+ * `indent: 2` STAYS. It is documented as deliberate — the agent's prompts are
261
+ * tuned to that shape — so dropping it would be a prompt-behaviour change
262
+ * wearing a performance costume. The `try/catch` stays for the same reason it
263
+ * always did: a throw must degrade to an error object, not kill the server.
264
+ */
265
+ export function renderResult(value) {
266
+ try {
267
+ // The fast path, taken for every real result.
268
+ return JSON.stringify(value, null, 2) ?? "null";
269
+ }
270
+ catch {
271
+ // ⚠ NOT REACHED BY ANYTHING TODAY — and kept anyway, because "unreachable"
272
+ // is an argument about callers, and callers change. A test pins this shape,
273
+ // which is how the first version of this optimisation was caught silently
274
+ // downgrading a cycle to an error object.
275
+ //
276
+ // Paying the WeakSet only here means the happy path is 14.4 ms on a 6 MB
277
+ // result instead of 43.6 ms, and the graceful degradation is unchanged.
278
+ const seen = new WeakSet();
279
+ try {
280
+ return (JSON.stringify(value, (_k, v) => {
281
+ if (typeof v === "bigint")
282
+ return v.toString();
283
+ if (typeof v === "object" && v !== null) {
284
+ if (seen.has(v))
285
+ return "[circular]";
286
+ seen.add(v);
287
+ }
288
+ return v;
289
+ }, 2) ?? "null");
290
+ }
291
+ catch (err) {
292
+ return JSON.stringify({ error: `unserialisable result: ${String(err)}` });
293
+ }
294
+ }
295
+ }
296
+ /**
297
+ * Hold the TCP+TLS connection open between tool calls. **MCP ONLY.**
298
+ *
299
+ * Node's global `fetch` uses undici with no dispatcher configured, so every
300
+ * tool call pays a fresh TCP handshake plus a TLS handshake. Measured against
301
+ * prod on a 253 ms link that is ~475-510 ms of the round trip — but state it as
302
+ * **~2 x RTT**, not a flat number: for a user near nyc1 at 15 ms it is ~30 ms.
303
+ *
304
+ * ⚠ DELIBERATELY NOT GLOBAL. For a one-shot command this buys nothing (the
305
+ * process makes one request and exits) and only delays exit. It is installed
306
+ * here, in the long-lived server, and nowhere else.
307
+ *
308
+ * ⚠ NO HEARTBEAT. A keep-alive PING timer was proposed and is refused: it would
309
+ * be a permanent outbound request from every user's laptop, for the life of the
310
+ * session, aimed at one droplet that already runs a 60/min per-IP limiter.
311
+ * `keepAliveTimeout` is set BELOW the measured 65 s server cliff instead, so a
312
+ * socket is retired by us rather than found dead by a request.
313
+ *
314
+ * ⚠ CONNECTION COUNT IS SIZED AGAINST A1. `setGlobalDispatcher` is
315
+ * process-global and the agent can call `api_get` freely; too small a pool lets
316
+ * a flood of tool calls block the credential rotation into the "another
317
+ * `symbols` process is refreshing" refusal. 8 leaves headroom beside the
318
+ * single-flight mint.
319
+ */
320
+ async function installKeepAlive() {
321
+ try {
322
+ const undici = (await import("undici"));
323
+ const agent = new undici.Agent({
324
+ keepAliveTimeout: 50_000, // under the measured 65 s cliff
325
+ keepAliveMaxTimeout: 50_000,
326
+ connections: 8,
327
+ });
328
+ undici.setGlobalDispatcher(agent);
329
+ // ⚠ ASSERT IT TOOK EFFECT. The injection rides
330
+ // `Symbol.for('undici.globalDispatcher.N')` — an undocumented, VERSION-TAGGED
331
+ // contract that has already been bumped once. If Node's bundled copy moves
332
+ // to a tag this undici does not write, `setGlobalDispatcher` becomes a
333
+ // silent no-op: nothing throws, nothing logs, and the latency quietly
334
+ // returns. A warning is the difference between a regression and a mystery.
335
+ const installed = undici.getGlobalDispatcher?.();
336
+ if (installed !== agent) {
337
+ warn("keep-alive dispatcher did NOT take effect — every tool call will pay a " +
338
+ "fresh TCP+TLS handshake. This usually means Node's bundled undici and " +
339
+ "the installed one disagree on the global-dispatcher symbol version.");
340
+ }
341
+ }
342
+ catch (err) {
343
+ // Never fatal. A slower server beats a server that will not start.
344
+ warn(`could not install the keep-alive dispatcher: ${err.message}`);
345
+ }
346
+ }
347
+ /** Start the stdio server. Resolves when the transport closes. */
348
+ export async function serve() {
349
+ // ⚠ CLAIM STDOUT BEFORE ANYTHING CAN WRITE TO IT. From here on `print()`
350
+ // redirects to stderr, so a diagnostic added to any helper this process
351
+ // reaches becomes a misplaced log line instead of a corrupted JSON-RPC
352
+ // stream. The warning at the top of this file states the rule; this enforces
353
+ // it for code that never read the warning.
354
+ enterProtocolMode();
355
+ await installKeepAlive();
356
+ const sdk = await loadSdk();
357
+ const { server, tools } = await buildServer(sdk);
358
+ const transport = new sdk.StdioServerTransport();
359
+ await server.connect(transport);
360
+ warn(`serving ${tools.length} tools over stdio (cli ${CLI_VERSION})`);
361
+ await new Promise((resolve) => {
362
+ transport.onclose = () => resolve();
363
+ process.on("SIGINT", () => resolve());
364
+ process.on("SIGTERM", () => resolve());
365
+ });
366
+ }