@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.
- package/LICENSE +8 -0
- package/README.md +103 -0
- package/dist/auth/client.js +531 -0
- package/dist/auth/credentials.js +293 -0
- package/dist/auth/hosts.js +85 -0
- package/dist/auth/loopback.js +108 -0
- package/dist/auth/pkce.js +33 -0
- package/dist/auth/wire.js +40 -0
- package/dist/commands/arm.js +154 -0
- package/dist/commands/curl.js +101 -0
- package/dist/commands/doctor.js +217 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logout.js +78 -0
- package/dist/commands/mcp.js +33 -0
- package/dist/commands/project.js +145 -0
- package/dist/commands/status.js +78 -0
- package/dist/commands/sync.js +94 -0
- package/dist/commands/uninstall.js +149 -0
- package/dist/commands/up.js +176 -0
- package/dist/commands/update.js +120 -0
- package/dist/commands/watch.js +155 -0
- package/dist/commands/whoami.js +103 -0
- package/dist/index.js +147 -0
- package/dist/mcp/scopes.js +215 -0
- package/dist/mcp/server.js +366 -0
- package/dist/mcp/tools.js +646 -0
- package/dist/skills/bundle.js +441 -0
- package/dist/skills/claude-md.js +135 -0
- package/dist/skills/install.js +188 -0
- package/dist/skills/settings-merge.js +107 -0
- package/dist/sync/api.js +380 -0
- package/dist/sync/diff.js +172 -0
- package/dist/sync/ledger.js +319 -0
- package/dist/sync/paths.js +447 -0
- package/dist/sync/protect.js +108 -0
- package/dist/sync/reconcile.js +870 -0
- package/dist/sync/watcher.js +206 -0
- package/dist/util/log.js +58 -0
- package/dist/util/platform.js +79 -0
- package/dist/util/version.js +24 -0
- 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
|
+
}
|