@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,646 @@
|
|
|
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 Symbols MCP tool surface — a TypeScript port of
|
|
6
|
+
// `docker/odin-runtime/symbols-mcp/__main__.py` (751 lines, Python + `mcp` 2.x).
|
|
7
|
+
//
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// WHAT CHANGED IN THE PORT, AND WHY
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// 1. **Tools are DATA, not decorated functions.** The Python file spread eleven
|
|
13
|
+
// `@app.tool` decorators over one `_dispatch` chain, and needed a Python AST
|
|
14
|
+
// pass (`scripts/check_mcp_scopes.py`) to recover which paths it could reach.
|
|
15
|
+
// Here every tool is a row in `TOOLS` that DECLARES its route templates, and
|
|
16
|
+
// `buildPath` (mcp/scopes.ts) is the only path constructor in the module. The
|
|
17
|
+
// declaration is not a description of the request — it IS the request, so
|
|
18
|
+
// they cannot drift, and the checker reads one literal per route.
|
|
19
|
+
//
|
|
20
|
+
// 2. **The path is validated twice.** `templateOk` runs against the CONCRETE
|
|
21
|
+
// path for every tool, not just the dynamic `api_get` door. It costs nothing
|
|
22
|
+
// and it means a tool argument carrying `/` or `..` is refused here rather
|
|
23
|
+
// than reaching the wire. (The Python version interpolated `args['symbol']`
|
|
24
|
+
// unencoded.)
|
|
25
|
+
//
|
|
26
|
+
// 3. **THE v1 TOOL SURFACE IS READ-ONLY — the CREDENTIAL IS NOT.**
|
|
27
|
+
// ⚠ Do not read this as "the CLI cannot destroy anything". The device
|
|
28
|
+
// credential carries four DELETE scopes, including
|
|
29
|
+
// `DELETE /api/notebooks/{id}`, because `symbols project rm` needs them —
|
|
30
|
+
// and `symbols curl` honours `-X`. What is read-only is the list of TOOLS
|
|
31
|
+
// below. A P6 agent wrote the stronger claim into a skill, verified it, and
|
|
32
|
+
// found it false; `symbols whoami` now prints the destructive scopes
|
|
33
|
+
// separately so the distinction is visible rather than asserted.
|
|
34
|
+
// `place_order`, `cancel_order`, `deploy_regime` and
|
|
35
|
+
// `backtest_regime` are NOT ported. See `P5_SEAM` below.
|
|
36
|
+
//
|
|
37
|
+
// ⚠ Descriptions are the agent's ONLY instructions for these tools, and several
|
|
38
|
+
// carry a safety rule. Carried across near-verbatim; where one described the
|
|
39
|
+
// container it was rewritten rather than softened, because a description that
|
|
40
|
+
// misdescribes a laptop is worse than one that is merely terse.
|
|
41
|
+
import { request, ApiError, NotLoggedInError } from "../auth/client.js";
|
|
42
|
+
import { SCOPED_PATHS, allowedFamilies, buildPath, splitRoute, templateOk } from "./scopes.js";
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
// P5_SEAM — the write tools, deliberately absent
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
//
|
|
47
|
+
// The Python server ships four tools this file does not:
|
|
48
|
+
//
|
|
49
|
+
// place_order POST /api/odin-code/brokerage/orders
|
|
50
|
+
// cancel_order DELETE /api/odin-code/brokerage/orders/{client_order_id}
|
|
51
|
+
// deploy_regime POST /api/odin-code/regimes
|
|
52
|
+
// backtest_regime POST /api/odin-code/regimes/{regime_id}/backtest
|
|
53
|
+
//
|
|
54
|
+
// All four ride the SHORT-LIVED write credential, which the container re-mints
|
|
55
|
+
// only while the WebSocket is open — i.e. while a human is watching the tile.
|
|
56
|
+
// On a laptop `claude` sits in a detached tmux for days, so a timer-driven
|
|
57
|
+
// refresher would be a PERMANENTLY-ARMED order credential: strictly worse than
|
|
58
|
+
// the container, not equivalent to it.
|
|
59
|
+
//
|
|
60
|
+
// P5 adds `symbols arm`, the browser-consent window, the per-window cap and the
|
|
61
|
+
// device+window audit rows, and only then these tools. Adding one here without
|
|
62
|
+
// that window is not "getting ahead" — it is removing the control.
|
|
63
|
+
//
|
|
64
|
+
// `check_mcp_scopes.py` enforces the seam: a non-GET `route:` in this file while
|
|
65
|
+
// `WRITE_PATHS` is empty fails the check.
|
|
66
|
+
export const P5_SEAM = Object.freeze({
|
|
67
|
+
toolsDeferredToP5: ["place_order", "cancel_order", "deploy_regime", "backtest_regime"],
|
|
68
|
+
reason: "needs the armed window (symbols arm) — see plan §Money, P0 decision 2026-08-30",
|
|
69
|
+
});
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
// Enumerations derived from the generated scope list
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
/**
|
|
74
|
+
* The Unusual Whales endpoints, DERIVED from `SCOPED_PATHS`.
|
|
75
|
+
*
|
|
76
|
+
* The Python file hard-coded these twenty names beside a scope list that also
|
|
77
|
+
* held them — two places to add the twenty-first, and the flow family sat
|
|
78
|
+
* unreachable for exactly that kind of reason once already. Deriving means the
|
|
79
|
+
* tool's enum, the route it builds, and the server's allowlist are one fact.
|
|
80
|
+
*/
|
|
81
|
+
export const FLOW_ENDPOINTS = SCOPED_PATHS.filter((p) => p.startsWith("/api/flow/") && !p.includes("{"))
|
|
82
|
+
.map((p) => p.slice("/api/flow/".length))
|
|
83
|
+
.sort();
|
|
84
|
+
/**
|
|
85
|
+
* Fundamentals: a caller-facing `kind` mapped to the route that serves it.
|
|
86
|
+
*
|
|
87
|
+
* The kind NAMES are a UX choice and stay hand-written; the TEMPLATES are
|
|
88
|
+
* checked against `SCOPED_PATHS` at module load, so a route rename fails loudly
|
|
89
|
+
* at startup instead of 404ing per call.
|
|
90
|
+
*/
|
|
91
|
+
export const FUNDAMENTALS = Object.freeze({
|
|
92
|
+
profile: "/api/company/profile/{symbol}",
|
|
93
|
+
earnings: "/api/earnings/{symbol}",
|
|
94
|
+
earnings_uw: "/api/earnings/{symbol}/uw",
|
|
95
|
+
ratios: "/api/ratios/{symbol}",
|
|
96
|
+
statements: "/api/financial-statements/{symbol}",
|
|
97
|
+
});
|
|
98
|
+
export const FUNDAMENTAL_KINDS = Object.keys(FUNDAMENTALS).sort();
|
|
99
|
+
/** Startup invariant: every hand-written template above is really in scope. */
|
|
100
|
+
export function assertDerivedTablesAreSound() {
|
|
101
|
+
if (FLOW_ENDPOINTS.length === 0) {
|
|
102
|
+
throw new Error("no /api/flow/* routes in SCOPED_PATHS — get_flow would advertise an empty " +
|
|
103
|
+
"enum. Regenerate scopes.ts (python3 scripts/check_mcp_scopes.py --write).");
|
|
104
|
+
}
|
|
105
|
+
const scoped = new Set(SCOPED_PATHS);
|
|
106
|
+
for (const [kind, template] of Object.entries(FUNDAMENTALS)) {
|
|
107
|
+
if (!scoped.has(template)) {
|
|
108
|
+
throw new Error(`get_fundamentals kind '${kind}' maps to '${template}', which is not a ` +
|
|
109
|
+
`CLI-token GET scope. Either the route was renamed or the scope was ` +
|
|
110
|
+
`dropped; both make the tool 403/404 silently.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
// Argument validation
|
|
116
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
//
|
|
118
|
+
// The low-level MCP server does not validate `arguments` against a tool's
|
|
119
|
+
// declared `inputSchema` — it hands them through as received. So the schema the
|
|
120
|
+
// agent sees would be decorative unless something enforced it, and "decorative
|
|
121
|
+
// schema" is how a symbol argument becomes a path segment.
|
|
122
|
+
//
|
|
123
|
+
// This is deliberately NOT a general JSON Schema engine. It covers exactly what
|
|
124
|
+
// these twelve tools declare, and refuses anything it cannot check.
|
|
125
|
+
export class ToolArgumentError extends Error {
|
|
126
|
+
constructor(message) {
|
|
127
|
+
super(message);
|
|
128
|
+
this.name = "ToolArgumentError";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function validateArgs(spec, raw) {
|
|
132
|
+
const schema = spec.inputSchema;
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const key of Object.keys(raw)) {
|
|
135
|
+
if (!(key in schema.properties)) {
|
|
136
|
+
throw new ToolArgumentError(`${spec.name}: unknown argument '${key}'. Accepted: ` +
|
|
137
|
+
`${Object.keys(schema.properties).join(", ") || "(none)"}.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
141
|
+
// `null` reads as "omitted", matching the Python dispatcher, which stripped
|
|
142
|
+
// None values so a defaulted optional was indistinguishable from an unset
|
|
143
|
+
// one — otherwise an explicit null becomes an empty query param.
|
|
144
|
+
const value = raw[key];
|
|
145
|
+
if (value === undefined || value === null)
|
|
146
|
+
continue;
|
|
147
|
+
switch (prop.type) {
|
|
148
|
+
case "string": {
|
|
149
|
+
if (typeof value !== "string") {
|
|
150
|
+
throw new ToolArgumentError(`${spec.name}: '${key}' must be a string`);
|
|
151
|
+
}
|
|
152
|
+
if (prop.enum && !prop.enum.includes(value)) {
|
|
153
|
+
throw new ToolArgumentError(`${spec.name}: '${key}' must be one of: ${prop.enum.join(", ")} (got '${value}')`);
|
|
154
|
+
}
|
|
155
|
+
out[key] = value;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case "integer":
|
|
159
|
+
case "number": {
|
|
160
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
161
|
+
if (!Number.isFinite(n)) {
|
|
162
|
+
throw new ToolArgumentError(`${spec.name}: '${key}' must be a number`);
|
|
163
|
+
}
|
|
164
|
+
if (prop.type === "integer" && !Number.isInteger(n)) {
|
|
165
|
+
throw new ToolArgumentError(`${spec.name}: '${key}' must be a whole number`);
|
|
166
|
+
}
|
|
167
|
+
if (prop.minimum !== undefined && n < prop.minimum) {
|
|
168
|
+
throw new ToolArgumentError(`${spec.name}: '${key}' must be >= ${prop.minimum}`);
|
|
169
|
+
}
|
|
170
|
+
if (prop.maximum !== undefined && n > prop.maximum) {
|
|
171
|
+
throw new ToolArgumentError(`${spec.name}: '${key}' must be <= ${prop.maximum}`);
|
|
172
|
+
}
|
|
173
|
+
out[key] = n;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case "boolean": {
|
|
177
|
+
if (typeof value !== "boolean") {
|
|
178
|
+
throw new ToolArgumentError(`${spec.name}: '${key}' must be true or false`);
|
|
179
|
+
}
|
|
180
|
+
out[key] = value;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case "object": {
|
|
184
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
185
|
+
throw new ToolArgumentError(`${spec.name}: '${key}' must be an object of query params`);
|
|
186
|
+
}
|
|
187
|
+
out[key] = value;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case "array": {
|
|
191
|
+
if (!Array.isArray(value)) {
|
|
192
|
+
throw new ToolArgumentError(`${spec.name}: '${key}' must be an array`);
|
|
193
|
+
}
|
|
194
|
+
out[key] = value;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const key of schema.required ?? []) {
|
|
200
|
+
if (out[key] === undefined) {
|
|
201
|
+
throw new ToolArgumentError(`${spec.name}: '${key}' is required`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
207
|
+
// Small helpers used by the specs
|
|
208
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
209
|
+
function str(args, key) {
|
|
210
|
+
const v = args[key];
|
|
211
|
+
if (typeof v !== "string")
|
|
212
|
+
throw new ToolArgumentError(`'${key}' is required`);
|
|
213
|
+
return v;
|
|
214
|
+
}
|
|
215
|
+
function upper(args, key) {
|
|
216
|
+
return str(args, key).toUpperCase();
|
|
217
|
+
}
|
|
218
|
+
/** Copy only the keys the caller actually supplied, under their wire names. */
|
|
219
|
+
function pick(args, mapping) {
|
|
220
|
+
const out = {};
|
|
221
|
+
for (const [argKey, wireKey] of Object.entries(mapping)) {
|
|
222
|
+
const v = args[argKey];
|
|
223
|
+
if (v === undefined || v === null)
|
|
224
|
+
continue;
|
|
225
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
226
|
+
out[wireKey] = v;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
/** A free-form `params` object from the agent, flattened for a query string. */
|
|
232
|
+
function queryFrom(value) {
|
|
233
|
+
if (value === undefined || value === null)
|
|
234
|
+
return {};
|
|
235
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
236
|
+
throw new ToolArgumentError("'params' must be an object, e.g. {\"ticker\": \"SPY\"}");
|
|
237
|
+
}
|
|
238
|
+
const out = {};
|
|
239
|
+
for (const [k, v] of Object.entries(value)) {
|
|
240
|
+
if (v === undefined || v === null)
|
|
241
|
+
continue;
|
|
242
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
243
|
+
out[k] = v;
|
|
244
|
+
}
|
|
245
|
+
else if (Array.isArray(v) && v.every((x) => ["string", "number", "boolean"].includes(typeof x))) {
|
|
246
|
+
out[k] = v;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
throw new ToolArgumentError(`params.${k} must be a string, number, boolean or a list of those`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
const SYMBOL = { type: "string", description: "Ticker, e.g. AAPL" };
|
|
255
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
256
|
+
// The tools
|
|
257
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
258
|
+
export const TOOLS = [
|
|
259
|
+
{
|
|
260
|
+
name: "get_quote",
|
|
261
|
+
description: "Get a real-time quote for a stock symbol (price, change, volume).",
|
|
262
|
+
inputSchema: {
|
|
263
|
+
type: "object",
|
|
264
|
+
properties: { symbol: SYMBOL },
|
|
265
|
+
required: ["symbol"],
|
|
266
|
+
additionalProperties: false,
|
|
267
|
+
},
|
|
268
|
+
routes: ["GET /api/quote/{symbol}"],
|
|
269
|
+
plan: (a) => ({ route: "GET /api/quote/{symbol}", params: { symbol: upper(a, "symbol") } }),
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "get_bars",
|
|
273
|
+
description: "OHLCV bars for a ticker. Use `interval` for the bar size and either " +
|
|
274
|
+
"`range_code` (e.g. 1M, 6M, 1Y) or an explicit start_date/end_date pair " +
|
|
275
|
+
"(YYYY-MM-DD).",
|
|
276
|
+
inputSchema: {
|
|
277
|
+
type: "object",
|
|
278
|
+
properties: {
|
|
279
|
+
ticker: SYMBOL,
|
|
280
|
+
interval: { type: "string", description: "Bar size, e.g. 1Day, 1Hour, 1Min", default: "1Day" },
|
|
281
|
+
range_code: { type: "string", description: "Relative window, e.g. 1M, 6M, 1Y" },
|
|
282
|
+
start_date: { type: "string", description: "YYYY-MM-DD (sent as `from`)" },
|
|
283
|
+
end_date: { type: "string", description: "YYYY-MM-DD (sent as `to`)" },
|
|
284
|
+
limit: { type: "integer", description: "Max bars (1-50000)", minimum: 1, maximum: 50000 },
|
|
285
|
+
},
|
|
286
|
+
required: ["ticker"],
|
|
287
|
+
additionalProperties: false,
|
|
288
|
+
},
|
|
289
|
+
routes: ["GET /api/chart/pricing-data"],
|
|
290
|
+
// The API's parameters are `from` and `to`. The Python tool exposed
|
|
291
|
+
// start_date/end_date because `from` cannot be a Python parameter name; the
|
|
292
|
+
// names are kept here so the two servers present the same tool to the agent.
|
|
293
|
+
plan: (a) => ({
|
|
294
|
+
route: "GET /api/chart/pricing-data",
|
|
295
|
+
query: {
|
|
296
|
+
ticker: upper(a, "ticker"),
|
|
297
|
+
interval: typeof a["interval"] === "string" ? a["interval"] : "1Day",
|
|
298
|
+
...pick(a, { range_code: "range_code", start_date: "from", end_date: "to", limit: "limit" }),
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: "get_option_series",
|
|
304
|
+
description: "Price series for ONE option contract, by OCC symbol. " +
|
|
305
|
+
"NOTE: there is deliberately no full-chain tool - building a chain " +
|
|
306
|
+
"opens a live subscription per expiration, which is why " +
|
|
307
|
+
"/api/options/{symbol} is denied to this credential.",
|
|
308
|
+
inputSchema: {
|
|
309
|
+
type: "object",
|
|
310
|
+
properties: {
|
|
311
|
+
occ_symbol: { type: "string", description: "OCC symbol, e.g. SPY241220C00500000" },
|
|
312
|
+
interval: { type: "string", default: "1Day" },
|
|
313
|
+
range_code: { type: "string" },
|
|
314
|
+
},
|
|
315
|
+
required: ["occ_symbol"],
|
|
316
|
+
additionalProperties: false,
|
|
317
|
+
},
|
|
318
|
+
routes: ["GET /api/options/chart-pricing"],
|
|
319
|
+
plan: (a) => ({
|
|
320
|
+
route: "GET /api/options/chart-pricing",
|
|
321
|
+
query: {
|
|
322
|
+
occ_symbol: upper(a, "occ_symbol"),
|
|
323
|
+
interval: typeof a["interval"] === "string" ? a["interval"] : "1Day",
|
|
324
|
+
...pick(a, { range_code: "range_code" }),
|
|
325
|
+
},
|
|
326
|
+
}),
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
name: "get_flow",
|
|
330
|
+
description: "Unusual Whales options-flow and market-structure data. One of: " +
|
|
331
|
+
FLOW_ENDPOINTS.join(", ") +
|
|
332
|
+
'. `params` is a dict of query params, e.g. {"ticker": "SPY"}.',
|
|
333
|
+
inputSchema: {
|
|
334
|
+
type: "object",
|
|
335
|
+
properties: {
|
|
336
|
+
endpoint: { type: "string", enum: FLOW_ENDPOINTS },
|
|
337
|
+
params: { type: "object", description: 'Query params, e.g. {"ticker": "SPY"}' },
|
|
338
|
+
},
|
|
339
|
+
required: ["endpoint"],
|
|
340
|
+
additionalProperties: false,
|
|
341
|
+
},
|
|
342
|
+
// Twenty concrete routes, not one `/api/flow/{endpoint}` template. The Rust
|
|
343
|
+
// list enumerates them individually, so enumerating them here keeps the
|
|
344
|
+
// checker's comparison exact rather than shape-based.
|
|
345
|
+
routes: FLOW_ENDPOINTS.map((e) => `GET /api/flow/${e}`),
|
|
346
|
+
plan: (a) => ({
|
|
347
|
+
route: `GET /api/flow/${str(a, "endpoint")}`,
|
|
348
|
+
query: queryFrom(a["params"]),
|
|
349
|
+
}),
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: "get_fundamentals",
|
|
353
|
+
description: "Company fundamentals. `kind` is one of: " + FUNDAMENTAL_KINDS.join(", ") + ".",
|
|
354
|
+
inputSchema: {
|
|
355
|
+
type: "object",
|
|
356
|
+
properties: {
|
|
357
|
+
kind: { type: "string", enum: FUNDAMENTAL_KINDS },
|
|
358
|
+
symbol: SYMBOL,
|
|
359
|
+
},
|
|
360
|
+
required: ["kind", "symbol"],
|
|
361
|
+
additionalProperties: false,
|
|
362
|
+
},
|
|
363
|
+
routes: [
|
|
364
|
+
"GET /api/company/profile/{symbol}",
|
|
365
|
+
"GET /api/earnings/{symbol}",
|
|
366
|
+
"GET /api/earnings/{symbol}/uw",
|
|
367
|
+
"GET /api/ratios/{symbol}",
|
|
368
|
+
"GET /api/financial-statements/{symbol}",
|
|
369
|
+
],
|
|
370
|
+
plan: (a) => {
|
|
371
|
+
const template = FUNDAMENTALS[str(a, "kind")];
|
|
372
|
+
if (!template) {
|
|
373
|
+
throw new ToolArgumentError(`unknown kind. One of: ${FUNDAMENTAL_KINDS.join(", ")}`);
|
|
374
|
+
}
|
|
375
|
+
return { route: `GET ${template}`, params: { symbol: upper(a, "symbol") } };
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
name: "search_research",
|
|
380
|
+
description: "Search the Symbols research corpus (papers, repos, resources).",
|
|
381
|
+
inputSchema: {
|
|
382
|
+
type: "object",
|
|
383
|
+
properties: {
|
|
384
|
+
q: { type: "string", description: "Free-text query" },
|
|
385
|
+
tags: { type: "string", description: "Comma-separated tag filter" },
|
|
386
|
+
limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
|
|
387
|
+
},
|
|
388
|
+
required: ["q"],
|
|
389
|
+
additionalProperties: false,
|
|
390
|
+
},
|
|
391
|
+
routes: ["GET /api/research/papers/search"],
|
|
392
|
+
plan: (a) => ({
|
|
393
|
+
route: "GET /api/research/papers/search",
|
|
394
|
+
query: {
|
|
395
|
+
q: str(a, "q"),
|
|
396
|
+
limit: typeof a["limit"] === "number" ? a["limit"] : 10,
|
|
397
|
+
...pick(a, { tags: "tags" }),
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: "list_regimes",
|
|
403
|
+
description: "List the current user's deployed trading regimes.",
|
|
404
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
405
|
+
routes: ["GET /api/regimes"],
|
|
406
|
+
plan: () => ({ route: "GET /api/regimes" }),
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: "get_regime",
|
|
410
|
+
description: "One regime with its executions, trades and stats.",
|
|
411
|
+
inputSchema: {
|
|
412
|
+
type: "object",
|
|
413
|
+
properties: { regime_id: { type: "string" } },
|
|
414
|
+
required: ["regime_id"],
|
|
415
|
+
additionalProperties: false,
|
|
416
|
+
},
|
|
417
|
+
routes: ["GET /api/regimes/{regime_id}/with-stats"],
|
|
418
|
+
plan: (a) => ({
|
|
419
|
+
route: "GET /api/regimes/{regime_id}/with-stats",
|
|
420
|
+
params: { regime_id: str(a, "regime_id") },
|
|
421
|
+
}),
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
name: "get_backtest_result",
|
|
425
|
+
description: "Fetch a backtest (or any execution) with its trades and status by " +
|
|
426
|
+
"execution id. A running backtest returns status='running' with no " +
|
|
427
|
+
"trades yet - poll until it is 'success' or 'error'. " +
|
|
428
|
+
"NOTE: this CLI can READ a run but cannot START one; deploy and backtest " +
|
|
429
|
+
"are done from the app.",
|
|
430
|
+
inputSchema: {
|
|
431
|
+
type: "object",
|
|
432
|
+
properties: { execution_id: { type: "string" } },
|
|
433
|
+
required: ["execution_id"],
|
|
434
|
+
additionalProperties: false,
|
|
435
|
+
},
|
|
436
|
+
routes: ["GET /api/regime-executions/{execution_id}/with-trades"],
|
|
437
|
+
plan: (a) => ({
|
|
438
|
+
route: "GET /api/regime-executions/{execution_id}/with-trades",
|
|
439
|
+
params: { execution_id: str(a, "execution_id") },
|
|
440
|
+
}),
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: "get_brokerage",
|
|
444
|
+
description: "The user's Alpaca account: buying power, equity, open positions, " +
|
|
445
|
+
"working orders - plus `policy`, which states the account's current " +
|
|
446
|
+
"agent-trading limits (the notional cap and the order budgets). " +
|
|
447
|
+
"Use it to SIZE and EXPLAIN a trade idea. " +
|
|
448
|
+
"⚠ This CLI cannot place or cancel orders - there is no such tool, by " +
|
|
449
|
+
"design. Orders are placed by the user in the Symbols app. " +
|
|
450
|
+
"Returns 403 if the user has not enabled brokerage access in " +
|
|
451
|
+
"Settings -> Connections.",
|
|
452
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
453
|
+
routes: ["GET /api/odin-code/brokerage/snapshot"],
|
|
454
|
+
plan: () => ({ route: "GET /api/odin-code/brokerage/snapshot" }),
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
name: "get_trade_history",
|
|
458
|
+
description: "How the user has actually been TRADING, as opposed to what they hold " +
|
|
459
|
+
"right now: individual fills, closed orders, and the account equity " +
|
|
460
|
+
"curve over the same window. This is the tool for questions like " +
|
|
461
|
+
"'how did I do on my last few trades'. " +
|
|
462
|
+
"`days` defaults to 30 (max 365); `limit` is rows per section. " +
|
|
463
|
+
"Note `portfolio_history` is account equity over time, so it also moves " +
|
|
464
|
+
"with deposits and withdrawals - derive realised P&L from `fills`.",
|
|
465
|
+
inputSchema: {
|
|
466
|
+
type: "object",
|
|
467
|
+
properties: {
|
|
468
|
+
days: { type: "integer", minimum: 1, maximum: 365, default: 30 },
|
|
469
|
+
limit: { type: "integer", minimum: 1, maximum: 1000, default: 100 },
|
|
470
|
+
},
|
|
471
|
+
additionalProperties: false,
|
|
472
|
+
},
|
|
473
|
+
routes: ["GET /api/odin-code/brokerage/history"],
|
|
474
|
+
plan: (a) => ({
|
|
475
|
+
route: "GET /api/odin-code/brokerage/history",
|
|
476
|
+
query: {
|
|
477
|
+
days: typeof a["days"] === "number" ? a["days"] : 30,
|
|
478
|
+
limit: typeof a["limit"] === "number" ? a["limit"] : 100,
|
|
479
|
+
},
|
|
480
|
+
}),
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
name: "api_get",
|
|
484
|
+
description: "Generic READ of any allow-listed Symbols API path — levels, " +
|
|
485
|
+
"sparklines, batch quotes, scan catalog/presets/sectors, research " +
|
|
486
|
+
"tags/graph/stats/document, regime trades and executions, feed " +
|
|
487
|
+
"status, tracked tickers, and your notebook/project files. Pass a " +
|
|
488
|
+
"CONCRETE path (e.g. /api/levels/AAPL) and query params separately. " +
|
|
489
|
+
"GET only; paths off the allow-list are refused with the permitted " +
|
|
490
|
+
"families listed. Prefer the dedicated tools where one exists - they " +
|
|
491
|
+
"document their params; this door is for everything scoped but not " +
|
|
492
|
+
"wrapped.",
|
|
493
|
+
inputSchema: {
|
|
494
|
+
type: "object",
|
|
495
|
+
properties: {
|
|
496
|
+
path: {
|
|
497
|
+
type: "string",
|
|
498
|
+
description: "Concrete API path, e.g. /api/levels/AAPL - no query string",
|
|
499
|
+
},
|
|
500
|
+
params: { type: "object", description: "Query params, passed separately" },
|
|
501
|
+
},
|
|
502
|
+
required: ["path"],
|
|
503
|
+
additionalProperties: false,
|
|
504
|
+
},
|
|
505
|
+
// No declared route: this is the ONE door where the caller chooses the path.
|
|
506
|
+
// It is not unchecked — `dispatch` runs `templateOk` against the whole of
|
|
507
|
+
// SCOPED_PATHS, which is the same list the checker proves equals the Rust
|
|
508
|
+
// allowlist. So a path that passes here is exactly as checked as a literal.
|
|
509
|
+
routes: [],
|
|
510
|
+
dynamic: true,
|
|
511
|
+
plan: (a) => ({ dynamicPath: str(a, "path"), query: queryFrom(a["params"]) }),
|
|
512
|
+
},
|
|
513
|
+
];
|
|
514
|
+
export const TOOL_COUNT = TOOLS.length;
|
|
515
|
+
/**
|
|
516
|
+
* The count the server refuses to start without.
|
|
517
|
+
*
|
|
518
|
+
* ⚠ Not decoration. The Python server died AT IMPORT when `mcp` 2.0 removed the
|
|
519
|
+
* 1.x decorators; it kept answering the MCP handshake, registered nothing, and
|
|
520
|
+
* every Symbols tool silently vanished for the agent. A server that starts
|
|
521
|
+
* healthy and exposes zero tools is the worst failure mode this component has,
|
|
522
|
+
* because it is indistinguishable from "the user has no Symbols access".
|
|
523
|
+
*
|
|
524
|
+
* Bumping this number is how you add a tool. That is the point.
|
|
525
|
+
*/
|
|
526
|
+
export const EXPECTED_TOOL_COUNT = 12;
|
|
527
|
+
export function toolByName(name) {
|
|
528
|
+
return TOOLS.find((t) => t.name === name);
|
|
529
|
+
}
|
|
530
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
531
|
+
// Dispatch
|
|
532
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
533
|
+
/** How long a single tool call may wait on the API. Matches the Python client. */
|
|
534
|
+
export const TOOL_TIMEOUT_MS = 15_000;
|
|
535
|
+
/**
|
|
536
|
+
* Turn a validated argument set into the exact request this tool will make.
|
|
537
|
+
*
|
|
538
|
+
* Exported and pure, so a test can assert every tool's declared route against
|
|
539
|
+
* the bytes it produces without a network. That round-trip is what makes the
|
|
540
|
+
* `routes:` declarations a contract rather than a comment.
|
|
541
|
+
*/
|
|
542
|
+
export function resolveRequest(spec, args) {
|
|
543
|
+
const planned = spec.plan(args);
|
|
544
|
+
let method;
|
|
545
|
+
let path;
|
|
546
|
+
if ("dynamicPath" in planned) {
|
|
547
|
+
if (spec.dynamic !== true) {
|
|
548
|
+
// Unreachable through TOOLS as written; kept because the day someone adds
|
|
549
|
+
// a second dynamic planner without the flag, this is the difference
|
|
550
|
+
// between a refusal and an unchecked caller-controlled path.
|
|
551
|
+
throw new Error(`${spec.name} returned a dynamic path but is not the dynamic door`);
|
|
552
|
+
}
|
|
553
|
+
method = "GET";
|
|
554
|
+
path = planned.dynamicPath;
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
if (!spec.routes.includes(planned.route)) {
|
|
558
|
+
throw new Error(`${spec.name} planned route '${planned.route}', which it does not declare. ` +
|
|
559
|
+
`Declared: ${spec.routes.join(", ") || "(none)"}. The declaration is what ` +
|
|
560
|
+
`check_mcp_scopes.py verifies, so an undeclared route is an unchecked one.`);
|
|
561
|
+
}
|
|
562
|
+
const { method: m, template } = splitRoute(planned.route);
|
|
563
|
+
method = m;
|
|
564
|
+
path = buildPath(template, planned.params ?? {});
|
|
565
|
+
}
|
|
566
|
+
if (method !== "GET") {
|
|
567
|
+
// v1 is read-only. See P5_SEAM.
|
|
568
|
+
throw new Error(`${spec.name} planned a ${method}, but this CLI ships read tools only in v1. ` +
|
|
569
|
+
`Write tools land in P5 behind the armed window.`);
|
|
570
|
+
}
|
|
571
|
+
if (!templateOk(path)) {
|
|
572
|
+
throw new ScopeRefusal(path);
|
|
573
|
+
}
|
|
574
|
+
const qs = toQueryString(planned.query ?? {});
|
|
575
|
+
return { method, path: qs ? `${path}?${qs}` : path };
|
|
576
|
+
}
|
|
577
|
+
export class ScopeRefusal extends Error {
|
|
578
|
+
attempted;
|
|
579
|
+
constructor(attempted) {
|
|
580
|
+
super(`Path '${attempted}' is not on the read allow-list.`);
|
|
581
|
+
this.attempted = attempted;
|
|
582
|
+
this.name = "ScopeRefusal";
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function toQueryString(query) {
|
|
586
|
+
const sp = new URLSearchParams();
|
|
587
|
+
for (const [k, v] of Object.entries(query)) {
|
|
588
|
+
if (Array.isArray(v)) {
|
|
589
|
+
for (const item of v)
|
|
590
|
+
sp.append(k, String(item));
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
sp.append(k, String(v));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return sp.toString();
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Run one tool and return the value the agent sees.
|
|
600
|
+
*
|
|
601
|
+
* ⚠ A TOOL MUST ANSWER, NOT THROW. Carried from the Python server: an exception
|
|
602
|
+
* escaping here would surface to the agent as a transport failure, which reads
|
|
603
|
+
* as "this tool is broken" — the same indistinguishable-from-dead state the
|
|
604
|
+
* scope drift produced. Every refusal is returned as data with an explanation
|
|
605
|
+
* the agent can act on.
|
|
606
|
+
*/
|
|
607
|
+
export async function callTool(name, rawArgs) {
|
|
608
|
+
const spec = toolByName(name);
|
|
609
|
+
if (!spec)
|
|
610
|
+
return { error: `Unknown tool: ${name}` };
|
|
611
|
+
let resolved;
|
|
612
|
+
try {
|
|
613
|
+
resolved = resolveRequest(spec, validateArgs(spec, rawArgs));
|
|
614
|
+
}
|
|
615
|
+
catch (err) {
|
|
616
|
+
if (err instanceof ScopeRefusal) {
|
|
617
|
+
return {
|
|
618
|
+
error: err.message,
|
|
619
|
+
hint: "Concrete path, no query string — pass params separately.",
|
|
620
|
+
allowed_families: allowedFamilies(),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
const res = await request(resolved.path, {
|
|
627
|
+
method: resolved.method,
|
|
628
|
+
signal: AbortSignal.timeout(TOOL_TIMEOUT_MS),
|
|
629
|
+
});
|
|
630
|
+
return res.body;
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
if (err instanceof NotLoggedInError) {
|
|
634
|
+
return { error: "Not signed in. Run `symbols login` in a terminal, then retry." };
|
|
635
|
+
}
|
|
636
|
+
if (err instanceof ApiError) {
|
|
637
|
+
// A 403 on a brokerage read is a real answer — the user has not opted in —
|
|
638
|
+
// so say which door was closed rather than flattening it to a status code.
|
|
639
|
+
const extra = err.status === 403 && resolved.path.startsWith("/api/odin-code/brokerage/")
|
|
640
|
+
? " (enable brokerage access in Settings -> Connections)"
|
|
641
|
+
: "";
|
|
642
|
+
return { error: `HTTP ${err.status}: ${err.message}${extra}` };
|
|
643
|
+
}
|
|
644
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
645
|
+
}
|
|
646
|
+
}
|