@oxy-hq/cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,316 +0,0 @@
1
- #!/usr/bin/env node
2
- import { a as runJq, c as isExternalSurface, d as paramsToQuery, f as parseFields, i as searchRoutes, l as normalizePath, m as ExitCode, n as loadCatalog, o as parseJson, p as CliError, r as loadOpenApi, s as request, t as comparablePath, u as substitutePlaceholders } from "./main.mjs";
3
- import { existsSync, readFileSync } from "node:fs";
4
- import { dirname, resolve } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
9
-
10
- //#region src/commands/mcp.ts
11
- /**
12
- * `oxyc mcp` — the Oxy API as MCP tools, over stdio.
13
- *
14
- * NOT THE SAME THING AS `oxy mcp`. That one is workspace tooling: it takes a
15
- * local checkout and exposes the semantic model, automations and `.sql` files
16
- * in it. This one takes a TOKEN and exposes the deployment's HTTP API — the
17
- * same surface `oxyc api` reaches, for an agent that would rather call a tool
18
- * than shell out. Different input, different audience, no overlap.
19
- *
20
- * FOUR TOOLS, NOT SIX HUNDRED — the design decision that makes this affordable.
21
- *
22
- * The obvious shape is one tool per endpoint, and it is the wrong one: the API
23
- * has ~670 routes, an agent runtime ships every tool's JSON schema in every
24
- * request, and that is tens of kilobytes of context spent on each turn before
25
- * a single question is asked. It would also go stale on every deploy, since
26
- * the tool list would be baked into this package rather than read from the
27
- * deployment.
28
- *
29
- * So discovery stays a QUESTION the agent asks (`routes`, `schema`) rather
30
- * than a payload it carries, exactly as the CLI does it. Four tools cost a few
31
- * hundred bytes per turn and reach every endpoint, including ones added after
32
- * this package was published.
33
- *
34
- * Auth, target resolution and placeholder substitution are the CLI's — the
35
- * same `Context`, so a token cached by `oxyc login` works
36
- * here with no separate setup, and `{org}` / `{workspace}` resolve the same
37
- * way.
38
- */
39
- /**
40
- * Package version, reported in the MCP handshake.
41
- *
42
- * Read from `package.json` rather than restated, so a release bump cannot
43
- * leave the server announcing a version nobody shipped.
44
- */
45
- const VERSION = (() => {
46
- try {
47
- const here = dirname(fileURLToPath(import.meta.url));
48
- for (const candidate of [resolve(here, "..", "package.json"), resolve(here, "..", "..", "package.json")]) if (existsSync(candidate)) return JSON.parse(readFileSync(candidate, "utf8")).version ?? "0.0.0";
49
- } catch {}
50
- return "0.0.0";
51
- })();
52
- /**
53
- * A tool result. MCP wants content blocks; everything here is text, because
54
- * the caller is a language model and the payloads are JSON or markdown.
55
- */
56
- function text(body, isError = false) {
57
- return {
58
- content: [{
59
- type: "text",
60
- text: body
61
- }],
62
- isError
63
- };
64
- }
65
- const TOOLS = [
66
- {
67
- name: "oxy_routes",
68
- description: "List the API endpoints this Oxy deployment mounts, with what each one does. ALWAYS call this before oxy_request rather than guessing a path. Pass a filter (matched against method, path, surface and description) to narrow it — unfiltered is ~670 routes. Above 60 matches the descriptions are dropped and only method, path and credential come back, so narrow enough to stay under that when you need to know what an endpoint does; past ~400 it is refused outright.",
69
- inputSchema: {
70
- type: "object",
71
- properties: {
72
- filter: {
73
- type: "string",
74
- description: "Substring to narrow by, e.g. 'threads', 'sql', 'admin', 'semantic'."
75
- },
76
- all: {
77
- type: "boolean",
78
- description: "Include ide-only and worker-only routes. Off by default: those are mounted on one instance of the fleet, so a caller hitting the load balancer cannot reach them directly."
79
- }
80
- }
81
- }
82
- },
83
- {
84
- name: "oxy_schema",
85
- description: "The request and response schema for ONE endpoint, so a body can be built correctly. Covers the data plane (SQL, semantic query, and the org/workspace lookups); a blank result means undocumented, not nonexistent — use oxy_routes to confirm the endpoint exists.",
86
- inputSchema: {
87
- type: "object",
88
- properties: {
89
- path: {
90
- type: "string",
91
- description: "Endpoint path, e.g. '{workspace}/sql/query'."
92
- },
93
- method: {
94
- type: "string",
95
- description: "Narrow to one HTTP method."
96
- }
97
- },
98
- required: ["path"]
99
- }
100
- },
101
- {
102
- name: "oxy_request",
103
- description: "Make an authenticated request to the Oxy API. The credential is picked from the path (/api/** takes a bearer, /external/api/** an API key). Placeholders {org}, {workspace}, {project}, {customer} and {me} are substituted from context. Read freely; ask the human before any mutating request against production.",
104
- inputSchema: {
105
- type: "object",
106
- properties: {
107
- path: {
108
- type: "string",
109
- description: "Path relative to /api, e.g. '{org}/workspaces'."
110
- },
111
- method: {
112
- type: "string",
113
- description: "HTTP method. Defaults to GET, or POST with fields."
114
- },
115
- fields: {
116
- type: "object",
117
- description: "Request parameters. Sent as a JSON body on POST/PUT/PATCH, or as query parameters on GET/HEAD/DELETE. Values keep their JSON type."
118
- },
119
- jq: {
120
- type: "string",
121
- description: "A jq program to reduce the response before it is returned. Use it — a full list response is far more context than the two fields you need."
122
- }
123
- },
124
- required: ["path"]
125
- }
126
- },
127
- {
128
- name: "oxy_whoami",
129
- description: "Which deployment this is pointed at and who the token belongs to. Use it when a call returns 401/403, or a 200 with a null body — on this API an expired session answers 200 null rather than 401, and this tells the two apart.",
130
- inputSchema: {
131
- type: "object",
132
- properties: {}
133
- }
134
- }
135
- ];
136
- /**
137
- * Above this many matches, `oxy_routes` drops the descriptions.
138
- *
139
- * DEGRADES, IT DOES NOT REFUSE — the same shape as `oxyc routes`, which picks
140
- * `renderDetailed` under 40 matches and the compact method+path table above it
141
- * (`discover.ts`). The expensive field is `description`, not the row: a trimmed
142
- * row is a few tens of bytes, so several hundred still fit in a result an agent
143
- * can read, while the descriptions are what turn a broad match into hundreds of
144
- * KB of context spent before a question is asked.
145
- *
146
- * The threshold is on the COUNT, not on whether a `filter` was supplied — a
147
- * filter is not evidence of narrowing. `searchRoutes` matches a substring of
148
- * method, path, surface or description, so `"admin"`, `"workspace"` and
149
- * `"query"` are all filters a model reaches for in good faith that clear this.
150
- */
151
- const MAX_DESCRIBED_ROUTES = 60;
152
- /**
153
- * And above THIS many, there is nothing useful to return at all.
154
- *
155
- * Deliberately far above the threshold that drops descriptions: refusing is the
156
- * answer only when even the trimmed listing would be a context dump, which for
157
- * a deployment of ~670 routes means "you asked for effectively all of them".
158
- */
159
- const MAX_ROUTES_PER_RESULT = 400;
160
- /** `{ a: 1 }` → the `-f`/`-F` pairs `parseFields` understands. */
161
- function fieldPairs(fields) {
162
- if (!fields) return [];
163
- return Object.entries(fields).map(([k, v]) => `${k}=${JSON.stringify(v)}`);
164
- }
165
- /**
166
- * Check the arguments the schema says are required.
167
- *
168
- * `inputSchema.required` is ADVISORY on the low-level `Server` — it is handed
169
- * to the model, not enforced by the transport. Without this, `oxy_schema` with
170
- * no `path` matched every path and returned the whole OpenAPI document, and
171
- * `oxy_request` with none built a request to `/api/`.
172
- */
173
- function requireArgs(name, args, required) {
174
- const missing = required.filter((k) => {
175
- const v = args[k];
176
- return v === void 0 || v === null || typeof v === "string" && v.trim() === "";
177
- });
178
- if (missing.length > 0) throw new CliError(`${name} needs ${missing.join(", ")}`, { code: ExitCode.USAGE });
179
- }
180
- async function callTool(ctx, name, args) {
181
- switch (name) {
182
- case "oxy_routes": {
183
- const catalog = await loadCatalog({
184
- target: ctx.target(),
185
- bearer: ctx.maybeBearer()
186
- });
187
- const filter = args.filter?.trim();
188
- let matches = searchRoutes(catalog, filter || void 0);
189
- if (!args.all) matches = matches.filter((r) => r.role === "fleet-ok");
190
- const staleNote = catalog.stale ? "NOTE: this route table came from a stale local cache; the deployment was unreachable.\n\n" : "";
191
- const asked = filter ? ` ${JSON.stringify(filter)}` : "";
192
- if (matches.length > MAX_ROUTES_PER_RESULT) return text(`${staleNote}${matches.length} routes match${asked}, which is effectively the whole deployment. Call oxy_routes again with a narrower \`filter\` (matched against method, path, surface and description), e.g. "threads", "sql", "semantic", "admin".`, true);
193
- if (matches.length === 0) return text(`${staleNote}No route matches ${JSON.stringify(filter ?? "")}. Try a broader filter, or pass all=true to include ide-only and worker-only mounts.`, true);
194
- const described = matches.length <= MAX_DESCRIBED_ROUTES;
195
- const trimmed = matches.map((r) => described ? {
196
- method: r.method,
197
- path: r.path,
198
- credential: r.credential,
199
- description: r.description
200
- } : {
201
- method: r.method,
202
- path: r.path,
203
- credential: r.credential
204
- });
205
- return text(`${staleNote}${described ? "" : `NOTE: ${matches.length} routes match${asked} — too many to describe, so each row is method, path and credential WITHOUT its description. Narrow the \`filter\` to ${MAX_DESCRIBED_ROUTES} or fewer matches to get the descriptions back.\n\n`}${JSON.stringify(trimmed)}`);
206
- }
207
- case "oxy_schema": {
208
- requireArgs("oxy_schema", args, ["path"]);
209
- const doc = await loadOpenApi({
210
- target: ctx.target(),
211
- bearer: ctx.maybeBearer()
212
- });
213
- const wanted = String(args.path ?? "");
214
- const method = args.method;
215
- const paths = Object.entries(doc.paths ?? {});
216
- const exact = paths.filter(([p]) => comparablePath(p) === comparablePath(wanted));
217
- const hit = exact.length > 0 ? exact : paths.filter(([p]) => comparablePath(p).includes(comparablePath(wanted)));
218
- if (hit.length === 0) return text(`No documented schema for ${wanted}. The OpenAPI document covers the data plane only — call oxy_routes with a filter to confirm the endpoint exists.`, true);
219
- const selected = Object.fromEntries(hit.map(([p, ops]) => [p, method ? Object.fromEntries(Object.entries(ops).filter(([verb]) => verb.toLowerCase() === method.toLowerCase())) : ops]));
220
- return text(JSON.stringify({
221
- paths: selected,
222
- components: doc.components
223
- }, null, 2));
224
- }
225
- case "oxy_request": {
226
- requireArgs("oxy_request", args, ["path"]);
227
- const raw = String(args.path ?? "");
228
- const method = String(args.method ?? "").toUpperCase() || void 0;
229
- const fields = parseFields([], fieldPairs(args.fields));
230
- const verb = method ?? (fields.present ? "POST" : "GET");
231
- const carriesBody = ![
232
- "GET",
233
- "HEAD",
234
- "DELETE"
235
- ].includes(verb);
236
- let path = substitutePlaceholders(normalizePath(raw), ctx.placeholders());
237
- let body;
238
- if (fields.present) {
239
- if (carriesBody) body = JSON.stringify(fields.params);
240
- else {
241
- const query = paramsToQuery(fields.params);
242
- if (query) path += (path.includes("?") ? "&" : "?") + query;
243
- }
244
- }
245
- const apiKey = ctx.apiKey();
246
- const external = isExternalSurface(path);
247
- const response = await request({
248
- target: ctx.target(),
249
- path,
250
- method: verb,
251
- body,
252
- bearer: external && apiKey ? ctx.maybeBearer() : ctx.bearer(),
253
- apiKey
254
- });
255
- if (response.status < 200 || response.status >= 300) return text(`HTTP ${response.status} ${response.statusText}\n${response.body}`, true);
256
- if (response.body.trim() === "null") return text("null\n\n(NOTE: a 200 with a null body can mean an expired session on this API, not 'no such thing'. Call oxy_whoami to tell them apart.)");
257
- const jq = args.jq;
258
- if (jq) return text(runJq(response.body, jq));
259
- return text(response.body);
260
- }
261
- case "oxy_whoami": {
262
- const target = ctx.target();
263
- const response = await request({
264
- target,
265
- path: "/api/user",
266
- method: "GET",
267
- bearer: ctx.bearer()
268
- });
269
- const payload = parseJson(response.body);
270
- if (payload === null || payload === void 0) return text(`target: ${target}\nThe token no longer resolves to a user — the session has expired. Ask the human to run \`oxyc login\`.`, true);
271
- return text(JSON.stringify({
272
- target,
273
- user: payload
274
- }, null, 2));
275
- }
276
- default: return text(`unknown tool: ${name}`, true);
277
- }
278
- }
279
- /**
280
- * Serve MCP on stdio until the client disconnects.
281
- *
282
- * STDIO IS THE WHOLE TRANSPORT, deliberately. An HTTP/SSE server would need a
283
- * port, a lifetime and an auth story of its own; stdio is what every agent
284
- * runtime already launches, and the process inherits the caller's environment
285
- * — which is where the token and the target come from.
286
- *
287
- * NOTHING MAY BE WRITTEN TO STDOUT except protocol frames. `log.info` and
288
- * friends already go to stderr, which is why this can share the CLI's own
289
- * plumbing without corrupting the stream.
290
- */
291
- async function runMcp(ctx) {
292
- const server = new Server({
293
- name: "oxyc",
294
- version: VERSION
295
- }, { capabilities: { tools: {} } });
296
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
297
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
298
- try {
299
- return await callTool(ctx, req.params.name, req.params.arguments ?? {});
300
- } catch (cause) {
301
- const message = cause.message ?? String(cause);
302
- const { hint, remedy } = cause;
303
- const extra = [hint, remedy].filter((line) => line !== void 0 && line !== "").join("\n\n");
304
- return text(extra ? `${message}\n\n${extra}` : message, true);
305
- }
306
- });
307
- await server.connect(new StdioServerTransport());
308
- await new Promise((resolve) => {
309
- process.stdin.on("close", resolve);
310
- server.onclose = resolve;
311
- });
312
- }
313
-
314
- //#endregion
315
- export { runMcp };
316
- //# sourceMappingURL=mcp-CIsyezzU.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"mcp-CIsyezzU.mjs","names":[],"sources":["../src/commands/mcp.ts"],"sourcesContent":["/**\n * `oxyc mcp` — the Oxy API as MCP tools, over stdio.\n *\n * NOT THE SAME THING AS `oxy mcp`. That one is workspace tooling: it takes a\n * local checkout and exposes the semantic model, automations and `.sql` files\n * in it. This one takes a TOKEN and exposes the deployment's HTTP API — the\n * same surface `oxyc api` reaches, for an agent that would rather call a tool\n * than shell out. Different input, different audience, no overlap.\n *\n * FOUR TOOLS, NOT SIX HUNDRED — the design decision that makes this affordable.\n *\n * The obvious shape is one tool per endpoint, and it is the wrong one: the API\n * has ~670 routes, an agent runtime ships every tool's JSON schema in every\n * request, and that is tens of kilobytes of context spent on each turn before\n * a single question is asked. It would also go stale on every deploy, since\n * the tool list would be baked into this package rather than read from the\n * deployment.\n *\n * So discovery stays a QUESTION the agent asks (`routes`, `schema`) rather\n * than a payload it carries, exactly as the CLI does it. Four tools cost a few\n * hundred bytes per turn and reach every endpoint, including ones added after\n * this package was published.\n *\n * Auth, target resolution and placeholder substitution are the CLI's — the\n * same `Context`, so a token cached by `oxyc login` works\n * here with no separate setup, and `{org}` / `{workspace}` resolve the same\n * way.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { dirname, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { CallToolRequestSchema, ListToolsRequestSchema } from \"@modelcontextprotocol/sdk/types.js\";\nimport { loadCatalog, loadOpenApi, searchRoutes } from \"../api/catalog.js\";\nimport { paramsToQuery, parseFields } from \"../api/fields.js\";\nimport { runJq } from \"../api/output.js\";\nimport { isExternalSurface, normalizePath, substitutePlaceholders } from \"../api/paths.js\";\nimport { parseJson, request } from \"../api/request.js\";\nimport type { Context } from \"../context/resolve.js\";\nimport { CliError, ExitCode } from \"../util/errors.js\";\nimport { comparablePath } from \"./discover.js\";\n\n/**\n * Package version, reported in the MCP handshake.\n *\n * Read from `package.json` rather than restated, so a release bump cannot\n * leave the server announcing a version nobody shipped.\n */\nconst VERSION: string = (() => {\n try {\n const here = dirname(fileURLToPath(import.meta.url));\n for (const candidate of [\n resolve(here, \"..\", \"package.json\"),\n resolve(here, \"..\", \"..\", \"package.json\")\n ]) {\n if (existsSync(candidate)) {\n return (\n (JSON.parse(readFileSync(candidate, \"utf8\")) as { version?: string }).version ?? \"0.0.0\"\n );\n }\n }\n } catch {\n // A packaging shape we did not anticipate. The handshake needs a string,\n // not an accurate one.\n }\n return \"0.0.0\";\n})();\n\n/**\n * A tool result. MCP wants content blocks; everything here is text, because\n * the caller is a language model and the payloads are JSON or markdown.\n */\nfunction text(body: string, isError = false) {\n return { content: [{ type: \"text\" as const, text: body }], isError };\n}\n\nconst TOOLS = [\n {\n name: \"oxy_routes\",\n description:\n \"List the API endpoints this Oxy deployment mounts, with what each one does. \" +\n \"ALWAYS call this before oxy_request rather than guessing a path. \" +\n \"Pass a filter (matched against method, path, surface and description) to narrow it — \" +\n \"unfiltered is ~670 routes. Above 60 matches the descriptions are dropped and only \" +\n \"method, path and credential come back, so narrow enough to stay under that when you \" +\n \"need to know what an endpoint does; past ~400 it is refused outright.\",\n inputSchema: {\n type: \"object\",\n properties: {\n filter: {\n type: \"string\",\n description: \"Substring to narrow by, e.g. 'threads', 'sql', 'admin', 'semantic'.\"\n },\n all: {\n type: \"boolean\",\n description:\n \"Include ide-only and worker-only routes. Off by default: those are mounted on one \" +\n \"instance of the fleet, so a caller hitting the load balancer cannot reach them directly.\"\n }\n }\n }\n },\n {\n name: \"oxy_schema\",\n description:\n \"The request and response schema for ONE endpoint, so a body can be built correctly. \" +\n \"Covers the data plane (SQL, semantic query, and the org/workspace lookups); a blank \" +\n \"result means undocumented, not nonexistent — use oxy_routes to confirm the endpoint exists.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"Endpoint path, e.g. '{workspace}/sql/query'.\" },\n method: { type: \"string\", description: \"Narrow to one HTTP method.\" }\n },\n required: [\"path\"]\n }\n },\n {\n name: \"oxy_request\",\n description:\n \"Make an authenticated request to the Oxy API. The credential is picked from the path \" +\n \"(/api/** takes a bearer, /external/api/** an API key). Placeholders {org}, {workspace}, \" +\n \"{project}, {customer} and {me} are substituted from context. \" +\n \"Read freely; ask the human before any mutating request against production.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"Path relative to /api, e.g. '{org}/workspaces'.\" },\n method: {\n type: \"string\",\n description: \"HTTP method. Defaults to GET, or POST with fields.\"\n },\n fields: {\n type: \"object\",\n description:\n \"Request parameters. Sent as a JSON body on POST/PUT/PATCH, or as query parameters \" +\n \"on GET/HEAD/DELETE. Values keep their JSON type.\"\n },\n jq: {\n type: \"string\",\n description:\n \"A jq program to reduce the response before it is returned. Use it — a full list \" +\n \"response is far more context than the two fields you need.\"\n }\n },\n required: [\"path\"]\n }\n },\n {\n name: \"oxy_whoami\",\n description:\n \"Which deployment this is pointed at and who the token belongs to. \" +\n \"Use it when a call returns 401/403, or a 200 with a null body — on this API an expired \" +\n \"session answers 200 null rather than 401, and this tells the two apart.\",\n inputSchema: { type: \"object\", properties: {} }\n }\n];\n\n/**\n * Above this many matches, `oxy_routes` drops the descriptions.\n *\n * DEGRADES, IT DOES NOT REFUSE — the same shape as `oxyc routes`, which picks\n * `renderDetailed` under 40 matches and the compact method+path table above it\n * (`discover.ts`). The expensive field is `description`, not the row: a trimmed\n * row is a few tens of bytes, so several hundred still fit in a result an agent\n * can read, while the descriptions are what turn a broad match into hundreds of\n * KB of context spent before a question is asked.\n *\n * The threshold is on the COUNT, not on whether a `filter` was supplied — a\n * filter is not evidence of narrowing. `searchRoutes` matches a substring of\n * method, path, surface or description, so `\"admin\"`, `\"workspace\"` and\n * `\"query\"` are all filters a model reaches for in good faith that clear this.\n */\nconst MAX_DESCRIBED_ROUTES = 60;\n\n/**\n * And above THIS many, there is nothing useful to return at all.\n *\n * Deliberately far above the threshold that drops descriptions: refusing is the\n * answer only when even the trimmed listing would be a context dump, which for\n * a deployment of ~670 routes means \"you asked for effectively all of them\".\n */\nconst MAX_ROUTES_PER_RESULT = 400;\n\n/** `{ a: 1 }` → the `-f`/`-F` pairs `parseFields` understands. */\nfunction fieldPairs(fields: Record<string, unknown> | undefined): string[] {\n if (!fields) return [];\n return Object.entries(fields).map(([k, v]) => `${k}=${JSON.stringify(v)}`);\n}\n\n/**\n * Check the arguments the schema says are required.\n *\n * `inputSchema.required` is ADVISORY on the low-level `Server` — it is handed\n * to the model, not enforced by the transport. Without this, `oxy_schema` with\n * no `path` matched every path and returned the whole OpenAPI document, and\n * `oxy_request` with none built a request to `/api/`.\n */\nfunction requireArgs(name: string, args: Record<string, unknown>, required: string[]): void {\n const missing = required.filter((k) => {\n const v = args[k];\n return v === undefined || v === null || (typeof v === \"string\" && v.trim() === \"\");\n });\n if (missing.length > 0) {\n throw new CliError(`${name} needs ${missing.join(\", \")}`, { code: ExitCode.USAGE });\n }\n}\n\nasync function callTool(ctx: Context, name: string, args: Record<string, unknown>) {\n switch (name) {\n case \"oxy_routes\": {\n const catalog = await loadCatalog({ target: ctx.target(), bearer: ctx.maybeBearer() });\n const filter = (args.filter as string | undefined)?.trim();\n let matches = searchRoutes(catalog, filter || undefined);\n if (!args.all) matches = matches.filter((r) => r.role === \"fleet-ok\");\n\n // A cache stale enough to be wrong is worth saying INSIDE the result:\n // `loadCatalog` warns on stderr, which an MCP client never shows anyone.\n const staleNote = catalog.stale\n ? \"NOTE: this route table came from a stale local cache; the deployment was unreachable.\\n\\n\"\n : \"\";\n\n const asked = filter ? ` ${JSON.stringify(filter)}` : \"\";\n\n // Only a match set too large even to LIST is refused. Below that the\n // result degrades instead, because a filter like \"admin\" or \"query\" can\n // clear the description threshold in good faith and \"guess something\n // narrower\" is a dead end for a model with no way to see what it hit.\n if (matches.length > MAX_ROUTES_PER_RESULT) {\n return text(\n `${staleNote}${matches.length} routes match${asked}, which is effectively the whole ` +\n `deployment. Call oxy_routes again with a narrower \\`filter\\` (matched against ` +\n `method, path, surface and description), e.g. \"threads\", \"sql\", \"semantic\", \"admin\".`,\n true\n );\n }\n\n if (matches.length === 0) {\n // An empty array reads as \"this deployment has no such endpoint\",\n // which is usually false — the filter was wrong, or the route is\n // ide-only and hidden by default.\n return text(\n `${staleNote}No route matches ${JSON.stringify(filter ?? \"\")}. Try a broader filter, ` +\n `or pass all=true to include ide-only and worker-only mounts.`,\n true\n );\n }\n // Trimmed to the fields that help a caller build a request. The full\n // record carries the handler path and mount notes, which are for a\n // maintainer reading source, not for an agent composing a call.\n const described = matches.length <= MAX_DESCRIBED_ROUTES;\n // `credential` SURVIVES THE TRIM. It is what separates the bearer mount\n // from the `/external/api` API-key one, so a caller composing a request\n // needs it in either form — and `renderCompact`, the CLI shape this\n // mirrors, keeps it as a per-surface heading. It is also not the\n // expensive field: ~25 bytes a row, ~10 KB across the whole ceiling,\n // against descriptions that run to hundreds of KB.\n const trimmed = matches.map((r) =>\n described\n ? { method: r.method, path: r.path, credential: r.credential, description: r.description }\n : { method: r.method, path: r.path, credential: r.credential }\n );\n // The note says the listing is trimmed AND how to get the descriptions\n // back. Without it a model reads a description-less row as an endpoint\n // that has no documentation, rather than one it did not ask narrowly\n // enough to see.\n const trimNote = described\n ? \"\"\n : `NOTE: ${matches.length} routes match${asked} — too many to describe, so each row is ` +\n `method, path and credential WITHOUT its description. Narrow the \\`filter\\` to ` +\n `${MAX_DESCRIBED_ROUTES} or fewer matches to get the descriptions back.\\n\\n`;\n return text(`${staleNote}${trimNote}${JSON.stringify(trimmed)}`);\n }\n\n case \"oxy_schema\": {\n requireArgs(\"oxy_schema\", args, [\"path\"]);\n const doc = (await loadOpenApi({ target: ctx.target(), bearer: ctx.maybeBearer() })) as {\n paths?: Record<string, Record<string, unknown>>;\n components?: unknown;\n };\n const wanted = String(args.path ?? \"\");\n const method = args.method as string | undefined;\n // `comparablePath` is imported from `discover.ts` rather than copied:\n // a second reduction of the same two spellings is a second thing to keep\n // in step, and the copy here was untested. Exact first, then substring —\n // the order `runSchema` uses, so the two commands answer alike.\n const paths = Object.entries(doc.paths ?? {});\n const exact = paths.filter(([p]) => comparablePath(p) === comparablePath(wanted));\n const hit =\n exact.length > 0\n ? exact\n : paths.filter(([p]) => comparablePath(p).includes(comparablePath(wanted)));\n if (hit.length === 0) {\n return text(\n `No documented schema for ${wanted}. The OpenAPI document covers the data plane only — ` +\n `call oxy_routes with a filter to confirm the endpoint exists.`,\n true\n );\n }\n const selected = Object.fromEntries(\n hit.map(([p, ops]) => [\n p,\n method\n ? Object.fromEntries(\n Object.entries(ops).filter(([verb]) => verb.toLowerCase() === method.toLowerCase())\n )\n : ops\n ])\n );\n return text(JSON.stringify({ paths: selected, components: doc.components }, null, 2));\n }\n\n case \"oxy_request\": {\n requireArgs(\"oxy_request\", args, [\"path\"]);\n const raw = String(args.path ?? \"\");\n const method = String(args.method ?? \"\").toUpperCase() || undefined;\n const fields = parseFields([], fieldPairs(args.fields as Record<string, unknown>));\n const verb = method ?? (fields.present ? \"POST\" : \"GET\");\n const carriesBody = ![\"GET\", \"HEAD\", \"DELETE\"].includes(verb);\n\n let path = substitutePlaceholders(normalizePath(raw), ctx.placeholders());\n let body: string | undefined;\n if (fields.present) {\n if (carriesBody) body = JSON.stringify(fields.params);\n else {\n const query = paramsToQuery(fields.params);\n if (query) path += (path.includes(\"?\") ? \"&\" : \"?\") + query;\n }\n }\n\n const apiKey = ctx.apiKey();\n const external = isExternalSurface(path);\n const response = await request({\n target: ctx.target(),\n path,\n method: verb,\n body,\n bearer: external && apiKey ? ctx.maybeBearer() : ctx.bearer(),\n apiKey\n });\n\n if (response.status < 200 || response.status >= 300) {\n // The STATUS and the body, not a thrown error: the model can act on\n // \"403, here is what the server said\" and cannot act on a transport\n // exception. It is marked `isError` so the runtime shows it as one.\n return text(`HTTP ${response.status} ${response.statusText}\\n${response.body}`, true);\n }\n\n if (response.body.trim() === \"null\") {\n return text(\n \"null\\n\\n(NOTE: a 200 with a null body can mean an expired session on this API, \" +\n \"not 'no such thing'. Call oxy_whoami to tell them apart.)\"\n );\n }\n\n const jq = args.jq as string | undefined;\n if (jq) return text(runJq(response.body, jq));\n return text(response.body);\n }\n\n case \"oxy_whoami\": {\n const target = ctx.target();\n const response = await request({\n target,\n path: \"/api/user\",\n method: \"GET\",\n bearer: ctx.bearer()\n });\n const payload = parseJson(response.body);\n if (payload === null || payload === undefined) {\n return text(\n `target: ${target}\\nThe token no longer resolves to a user — the session has expired. ` +\n `Ask the human to run \\`oxyc login\\`.`,\n true\n );\n }\n return text(JSON.stringify({ target, user: payload }, null, 2));\n }\n\n default:\n return text(`unknown tool: ${name}`, true);\n }\n}\n\n/**\n * Serve MCP on stdio until the client disconnects.\n *\n * STDIO IS THE WHOLE TRANSPORT, deliberately. An HTTP/SSE server would need a\n * port, a lifetime and an auth story of its own; stdio is what every agent\n * runtime already launches, and the process inherits the caller's environment\n * — which is where the token and the target come from.\n *\n * NOTHING MAY BE WRITTEN TO STDOUT except protocol frames. `log.info` and\n * friends already go to stderr, which is why this can share the CLI's own\n * plumbing without corrupting the stream.\n */\nexport async function runMcp(ctx: Context): Promise<void> {\n const server = new Server({ name: \"oxyc\", version: VERSION }, { capabilities: { tools: {} } });\n\n server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));\n\n server.setRequestHandler(CallToolRequestSchema, async (req) => {\n try {\n return await callTool(\n ctx,\n req.params.name,\n (req.params.arguments ?? {}) as Record<string, unknown>\n );\n } catch (cause) {\n // A throw here would kill the session. The model can act on a message;\n // it cannot act on a dead transport.\n const message = (cause as Error).message ?? String(cause);\n // BOTH CHANNELS. A model gets one string, so there is no marker to\n // distinguish them — but reading `hint` alone drops the one line that\n // says what to DO, which is the half a model can act on.\n const { hint, remedy } = cause as { hint?: string; remedy?: string };\n // Blank line between them, the way every other renderer of these two\n // separates them — a model given two consecutive imperatives with no\n // boundary loses the distinction the second field exists to make.\n // `!== undefined` for the narrowing TS gives it, and a length check\n // because an empty string would open the block with a blank line —\n // `filter(Boolean)` covered that and cost the narrowing, so both.\n const extra = [hint, remedy].filter((line) => line !== undefined && line !== \"\").join(\"\\n\\n\");\n return text(extra ? `${message}\\n\\n${extra}` : message, true);\n }\n });\n\n await server.connect(new StdioServerTransport());\n\n // `connect` returns once the transport is wired; the process must stay up\n // until the client closes stdin.\n await new Promise<void>((resolve) => {\n process.stdin.on(\"close\", resolve);\n server.onclose = resolve;\n });\n}\n"],"mappings":"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"}