@typeship-ax/mcp 0.6.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.
Files changed (73) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +43 -0
  3. package/api.json +5163 -0
  4. package/api.md +512 -0
  5. package/dist/core/http.d.ts +303 -0
  6. package/dist/core/http.d.ts.map +1 -0
  7. package/dist/core/http.js +770 -0
  8. package/dist/core/pagination.d.ts +51 -0
  9. package/dist/core/pagination.d.ts.map +1 -0
  10. package/dist/core/pagination.js +154 -0
  11. package/dist/dates.d.ts +33 -0
  12. package/dist/dates.d.ts.map +1 -0
  13. package/dist/dates.js +136 -0
  14. package/dist/errors.d.ts +81 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/errors.js +103 -0
  17. package/dist/index.d.ts +92 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +86 -0
  20. package/dist/mcp-protocol.d.ts +453 -0
  21. package/dist/mcp-protocol.d.ts.map +1 -0
  22. package/dist/mcp-protocol.js +1262 -0
  23. package/dist/mcp.d.ts +5 -0
  24. package/dist/mcp.d.ts.map +1 -0
  25. package/dist/mcp.js +449 -0
  26. package/dist/ops.d.ts +115 -0
  27. package/dist/ops.d.ts.map +1 -0
  28. package/dist/ops.js +79 -0
  29. package/dist/resources/account.d.ts +18 -0
  30. package/dist/resources/account.d.ts.map +1 -0
  31. package/dist/resources/account.js +26 -0
  32. package/dist/resources/api-keys.d.ts +37 -0
  33. package/dist/resources/api-keys.d.ts.map +1 -0
  34. package/dist/resources/api-keys.js +67 -0
  35. package/dist/resources/generate.d.ts +25 -0
  36. package/dist/resources/generate.d.ts.map +1 -0
  37. package/dist/resources/generate.js +41 -0
  38. package/dist/resources/generations.d.ts +31 -0
  39. package/dist/resources/generations.d.ts.map +1 -0
  40. package/dist/resources/generations.js +56 -0
  41. package/dist/resources/projects.d.ts +110 -0
  42. package/dist/resources/projects.d.ts.map +1 -0
  43. package/dist/resources/projects.js +220 -0
  44. package/dist/resources/spec-revisions.d.ts +47 -0
  45. package/dist/resources/spec-revisions.d.ts.map +1 -0
  46. package/dist/resources/spec-revisions.js +90 -0
  47. package/dist/schemas.d.ts +6 -0
  48. package/dist/schemas.d.ts.map +1 -0
  49. package/dist/schemas.js +88 -0
  50. package/dist/types.d.ts +759 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +37 -0
  53. package/dist/worker.d.ts +5 -0
  54. package/dist/worker.d.ts.map +1 -0
  55. package/dist/worker.js +12 -0
  56. package/package.json +45 -0
  57. package/src/core/http.ts +1008 -0
  58. package/src/core/pagination.ts +195 -0
  59. package/src/dates.ts +126 -0
  60. package/src/errors.ts +117 -0
  61. package/src/index.ts +153 -0
  62. package/src/mcp-protocol.ts +1451 -0
  63. package/src/mcp.ts +448 -0
  64. package/src/ops.ts +174 -0
  65. package/src/resources/account.ts +43 -0
  66. package/src/resources/api-keys.ts +105 -0
  67. package/src/resources/generate.ts +69 -0
  68. package/src/resources/generations.ts +100 -0
  69. package/src/resources/projects.ts +391 -0
  70. package/src/resources/spec-revisions.ts +150 -0
  71. package/src/schemas.ts +90 -0
  72. package/src/types.ts +825 -0
  73. package/src/worker.ts +13 -0
@@ -0,0 +1,1262 @@
1
+ /**
2
+ * MCP 2026-07-28 for a tools-only server. Generated by typeship — https://typeship.dev
3
+ *
4
+ * The protocol layer shared by the MCP server in every generated package and
5
+ * by typeship's hosted endpoint: JSON-RPC shape checks, the per-request _meta
6
+ * rules, Streamable HTTP header validation, result wrapping, and the tool
7
+ * surface (per-operation tools or the three-tool "meta" shape, plus the
8
+ * search_docs / read_docs pair). Also the agent-facing contract of a tool
9
+ * call: argument validation and coercion against the tool's input schema
10
+ * (relative dates via ./dates), field projection, a size cap on results,
11
+ * and error results that carry a stable code and next steps. Transport,
12
+ * credentials, and how a tool call reaches the API stay with the caller.
13
+ * No external dependencies.
14
+ *
15
+ * Spec: https://modelcontextprotocol.io/specification/2026-07-28
16
+ */
17
+ import { dateKindOf, relativeDate } from "./dates.js";
18
+ export const MCP_PROTOCOL_VERSION = "2026-07-28";
19
+ /** Revisions served. Legacy (initialize-handshake) revisions are not; an
20
+ * initialize request gets an error naming this list, as the spec asks of
21
+ * modern-only servers. */
22
+ export const SUPPORTED_PROTOCOL_VERSIONS = [MCP_PROTOCOL_VERSION];
23
+ export const META_VERSION = "io.modelcontextprotocol/protocolVersion";
24
+ export const META_CLIENT_CAPS = "io.modelcontextprotocol/clientCapabilities";
25
+ export const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
26
+ /** Results longer than this (in characters of JSON text) are cut down to
27
+ * whole items or whole keys with a note saying what was left out and how to
28
+ * ask for less. Roughly 16k tokens: under every major client's own cap, so
29
+ * the agent sees this explanation instead of a mid-JSON chop. */
30
+ export const DEFAULT_MAX_RESULT_CHARS = 64_000;
31
+ /** Does the operation take a file (multipart form or raw binary body)? */
32
+ export function isUploadOp(op) {
33
+ return op.bodyKind === "multipart" || op.bodyKind === "binary";
34
+ }
35
+ /** Ops that can be MCP tools. A tool result is one value, not a stream, so
36
+ * event streams stay CLI/SDK-only everywhere. Uploads need a local file:
37
+ * they are tools on a server running on the agent's machine (stdio, or a
38
+ * locally launched HTTP server), where a file argument is a path the server
39
+ * reads, and not on a remote endpoint, which can't see the agent's disk. */
40
+ export function mcpExposed(op, options = {}) {
41
+ if (op.sse === true)
42
+ return false;
43
+ return options.uploads === true || !isUploadOp(op);
44
+ }
45
+ // ---- JSON-RPC shape + protocol metadata --------------------------------------
46
+ export const rpcError = (id, code, message, status, data) => ({
47
+ message: { jsonrpc: "2.0", id: id ?? null, error: { code, message, ...(data !== undefined ? { data } : {}) } },
48
+ status,
49
+ });
50
+ /** Shape check before anything else: batches (removed in 2025-06-18) and
51
+ * non-objects get an error instead of a dead server. */
52
+ export function asJsonRpc(message) {
53
+ if (Array.isArray(message)) {
54
+ return rpcError(null, -32600, "JSON-RPC batching is not supported; send one message per request.", 400);
55
+ }
56
+ const isObject = message !== null && typeof message === "object";
57
+ const method = isObject ? message.method : undefined;
58
+ if (!isObject || typeof method !== "string") {
59
+ return rpcError(null, -32600, "Invalid Request: expected a JSON-RPC 2.0 message with a string method.", 400);
60
+ }
61
+ return message;
62
+ }
63
+ export function isRpcOutcome(value) {
64
+ return "status" in value;
65
+ }
66
+ /** Per-request protocol metadata (2026-07-28 basic/_meta): protocolVersion
67
+ * and clientCapabilities are REQUIRED on every request. Missing -> -32602
68
+ * (400 on HTTP); unsupported version -> -32022 listing what we speak. */
69
+ export function checkRequestMeta(request) {
70
+ const meta = (request.params?._meta ?? undefined);
71
+ const version = meta?.[META_VERSION];
72
+ const caps = meta?.[META_CLIENT_CAPS];
73
+ if (typeof version !== "string") {
74
+ return rpcError(request.id, -32602, "Invalid params: params._meta[\"" + META_VERSION + "\"] is required on every request (MCP " + MCP_PROTOCOL_VERSION + ").", 400);
75
+ }
76
+ if (!SUPPORTED_PROTOCOL_VERSIONS.includes(version)) {
77
+ return rpcError(request.id, -32022, "Unsupported protocol version", 400, { supported: SUPPORTED_PROTOCOL_VERSIONS, requested: version });
78
+ }
79
+ if (caps === null || typeof caps !== "object" || Array.isArray(caps)) {
80
+ return rpcError(request.id, -32602, "Invalid params: params._meta[\"" + META_CLIENT_CAPS + "\"] is required on every request (MCP " + MCP_PROTOCOL_VERSION + ").", 400);
81
+ }
82
+ return { version };
83
+ }
84
+ /** Streamable HTTP header values may arrive as =?base64?...?= when not
85
+ * header-safe. atob + TextDecoder: works in Node and worker runtimes alike. */
86
+ export function decodeHeaderValue(value) {
87
+ if (value.startsWith("=?base64?") && value.endsWith("?=")) {
88
+ try {
89
+ return new TextDecoder().decode(Uint8Array.from(atob(value.slice(9, -2)), (c) => c.charCodeAt(0)));
90
+ }
91
+ catch {
92
+ return value;
93
+ }
94
+ }
95
+ return value;
96
+ }
97
+ /** Streamable HTTP mirrors body fields into headers so intermediaries can
98
+ * route without parsing; a server that reads the body MUST check they
99
+ * agree (400 + -32020 HeaderMismatch), including when they're missing.
100
+ * Notifications carry no header requirements; call this for requests. */
101
+ export function checkRequestHeaders(headers, message) {
102
+ const id = message.id;
103
+ const version = headers.get("mcp-protocol-version");
104
+ const bodyVersion = message.params?._meta?.[META_VERSION];
105
+ if (version === null)
106
+ return rpcError(id, -32020, "Header mismatch: MCP-Protocol-Version header is required", 400);
107
+ if (typeof bodyVersion === "string" && version !== bodyVersion) {
108
+ return rpcError(id, -32020, "Header mismatch: MCP-Protocol-Version header '" + version + "' does not match body value '" + bodyVersion + "'", 400);
109
+ }
110
+ const method = headers.get("mcp-method");
111
+ if (method === null)
112
+ return rpcError(id, -32020, "Header mismatch: Mcp-Method header is required", 400);
113
+ if (method !== message.method) {
114
+ return rpcError(id, -32020, "Header mismatch: Mcp-Method header '" + method + "' does not match body value '" + message.method + "'", 400);
115
+ }
116
+ if (message.method === "tools/call") {
117
+ const nameHeader = headers.get("mcp-name");
118
+ const bodyName = message.params?.name;
119
+ if (nameHeader === null)
120
+ return rpcError(id, -32020, "Header mismatch: Mcp-Name header is required for tools/call", 400);
121
+ if (typeof bodyName === "string" && decodeHeaderValue(nameHeader) !== bodyName) {
122
+ return rpcError(id, -32020, "Header mismatch: Mcp-Name header '" + nameHeader + "' does not match body value '" + bodyName + "'", 400);
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+ /** Requests whose client cancelled them (stdio notifications/cancelled):
128
+ * the spec says the server MUST NOT send further messages for them. */
129
+ const cancelled = new Set();
130
+ /** True (and forgets the id) when a response must be suppressed. */
131
+ export function takeCancelled(id) {
132
+ return (typeof id === "string" || typeof id === "number") && cancelled.delete(id);
133
+ }
134
+ // ---- dispatch ------------------------------------------------------------------
135
+ export async function handleRpc(server, incoming) {
136
+ const parsed = asJsonRpc(incoming);
137
+ if (isRpcOutcome(parsed))
138
+ return parsed;
139
+ const request = parsed;
140
+ const id = request.id;
141
+ // Notifications: no id, no reply. Only notifications/cancelled means anything here.
142
+ if (id === undefined || id === null) {
143
+ if (request.method === "notifications/cancelled") {
144
+ const target = request.params?.requestId;
145
+ if (typeof target === "string" || typeof target === "number")
146
+ cancelled.add(target);
147
+ }
148
+ return { message: null, status: 202 };
149
+ }
150
+ // Legacy clients open with initialize; name what we do speak so their one
151
+ // visible error is actionable (spec: modern-only servers SHOULD do this).
152
+ if (request.method === "initialize") {
153
+ return rpcError(id, -32601, "This server speaks MCP " + SUPPORTED_PROTOCOL_VERSIONS.join(", ") + " only (stateless, no initialize handshake). Upgrade the client, or send requests with params._meta[\"" + META_VERSION + "\"].", 400, { supported: SUPPORTED_PROTOCOL_VERSIONS });
154
+ }
155
+ const meta = checkRequestMeta(request);
156
+ if ("status" in meta)
157
+ return meta;
158
+ const complete = (result) => ({
159
+ message: { jsonrpc: "2.0", id, result: { resultType: "complete", ...result, _meta: { [META_SERVER_INFO]: server.serverInfo } } },
160
+ status: 200,
161
+ });
162
+ const cacheScope = server.cacheScope ?? "public";
163
+ switch (request.method) {
164
+ case "server/discover":
165
+ return complete({
166
+ supportedVersions: SUPPORTED_PROTOCOL_VERSIONS,
167
+ capabilities: { tools: {} },
168
+ ...(server.instructions ? { instructions: server.instructions } : {}),
169
+ ttlMs: server.toolsTtlMs,
170
+ cacheScope,
171
+ });
172
+ case "tools/list":
173
+ return complete({ tools: server.listTools(), ttlMs: server.toolsTtlMs, cacheScope });
174
+ case "tools/call": {
175
+ const name = request.params?.name;
176
+ if (typeof name !== "string")
177
+ return rpcError(id, -32602, "tools/call requires a tool name", 400);
178
+ const args = (request.params?.arguments ?? {});
179
+ if (args === null || typeof args !== "object" || Array.isArray(args)) {
180
+ return rpcError(id, -32602, "tools/call arguments must be an object", 400);
181
+ }
182
+ const denied = server.beforeToolCall ? await server.beforeToolCall(name) : null;
183
+ if (denied)
184
+ return denied;
185
+ const started = Date.now();
186
+ const outcome = await server.callTool(name, args);
187
+ if (outcome === undefined)
188
+ return rpcError(id, -32602, "Unknown tool: " + name, 400);
189
+ if (server.afterToolCall)
190
+ await server.afterToolCall(name, outcome, Date.now() - started);
191
+ // A tool that declares an outputSchema MUST return structuredContent,
192
+ // and clients enforce it: a successful result without it fails the
193
+ // whole call. So every successful object result carries it, projected
194
+ // or cut or not — the emitted outputSchema is loosened to match (no
195
+ // `required`, no closed objects). Arrays and strings cannot be
196
+ // structuredContent (the spec wants an object) and such tools declare
197
+ // no outputSchema; errors keep their JSON in the text.
198
+ const structured = outcome.structured;
199
+ return complete({
200
+ content: outcome.content ?? [{ type: "text", text: outcome.text }],
201
+ ...(!outcome.isError && structured !== undefined && structured !== null && typeof structured === "object" && !Array.isArray(structured)
202
+ ? { structuredContent: structured }
203
+ : {}),
204
+ isError: outcome.isError,
205
+ });
206
+ }
207
+ default:
208
+ return rpcError(id, -32601, "Method not found: " + request.method, 404);
209
+ }
210
+ }
211
+ // ---- tool surface ---------------------------------------------------------------
212
+ /** POST operations the IR named as reads: POST /search, POST /query, and
213
+ * friends are read-only in every way that matters to a client deciding
214
+ * whether to ask before calling. */
215
+ const READ_METHOD_NAME = /^(list|search|query|get|find|count|lookup|retrieve|fetch|read|check|preview|validate|describe)(?![a-z])/i;
216
+ /** Mutation names that destroy or disable what they touch. */
217
+ const DESTRUCTIVE_NAME = /^(delete|remove|destroy|purge|archive|cancel|revoke|disable|deactivate|terminate|close|expire|reject)(?![a-z])/i;
218
+ /** Is this operation a read (a GET/HEAD, a GraphQL query, or a POST the
219
+ * spec named like a search)? Drives readOnlyHint and the read-only surface. */
220
+ export function isReadOperation(op) {
221
+ const m = op.httpMethod.toUpperCase();
222
+ if (op.graphql)
223
+ return op.graphql.kind === "query";
224
+ if (m === "GET" || m === "HEAD")
225
+ return true;
226
+ return m === "POST" && op.method !== undefined && READ_METHOD_NAME.test(op.method);
227
+ }
228
+ /** Tool annotations (spec: hints, untrusted by clients). Clients such as
229
+ * Claude Code use readOnlyHint to auto-approve reads and destructiveHint to
230
+ * confirm writes that overwrite or remove. The server talks to one known
231
+ * API, not the open world, so openWorldHint is false. */
232
+ export function annotationsFor(op) {
233
+ const m = op.httpMethod.toUpperCase();
234
+ const classified = operationSafety(op);
235
+ const readOnly = classified === "read";
236
+ const destructive = classified === "destructive";
237
+ const idempotent = readOnly || m === "PUT" || m === "DELETE";
238
+ return {
239
+ title: op.summary ?? op.tool,
240
+ readOnlyHint: readOnly,
241
+ destructiveHint: destructive,
242
+ idempotentHint: idempotent,
243
+ openWorldHint: false,
244
+ };
245
+ }
246
+ /** One effect vocabulary for generated references, CLI confirmation, and
247
+ * MCP annotations. Stored on the operation manifest so every renderer says
248
+ * the same thing even when the original operation name was unusual. */
249
+ export function operationSafety(op) {
250
+ if (op.safety)
251
+ return op.safety;
252
+ if (isReadOperation(op))
253
+ return "read";
254
+ const method = op.httpMethod.toUpperCase();
255
+ const name = op.method ?? "";
256
+ if (op.graphql)
257
+ return DESTRUCTIVE_NAME.test(name) ? "destructive" : "write";
258
+ return method === "DELETE" || DESTRUCTIVE_NAME.test(name)
259
+ ? "destructive"
260
+ : "write";
261
+ }
262
+ /** A deterministic, schema-valid-enough example for documentation and
263
+ * agent calls. Prefer facts supplied by the API author, then conservative
264
+ * values based on formats and field names. Only required object fields are
265
+ * included, keeping examples useful instead of manufacturing giant bodies. */
266
+ export function exampleFromSchema(schema, field = "value", depth = 0) {
267
+ if (!schema || typeof schema !== "object" || depth > 8)
268
+ return null;
269
+ const node = schema;
270
+ if (node.const !== undefined)
271
+ return node.const;
272
+ if (node.example !== undefined)
273
+ return node.example;
274
+ if (Array.isArray(node.examples) && node.examples.length > 0)
275
+ return node.examples[0];
276
+ if (node.default !== undefined)
277
+ return node.default;
278
+ if (Array.isArray(node.enum) && node.enum.length > 0)
279
+ return node.enum.find((v) => v !== null) ?? node.enum[0];
280
+ const variants = (Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : null);
281
+ if (variants) {
282
+ const useful = variants.find((v) => v && typeof v === "object" && v.type !== "null") ?? variants[0];
283
+ return exampleFromSchema(useful, field, depth + 1);
284
+ }
285
+ const type = Array.isArray(node.type) ? node.type.find((v) => v !== "null") : node.type;
286
+ if (type === "object" || node.properties || node.additionalProperties) {
287
+ const properties = (node.properties ?? {});
288
+ const required = new Set(Array.isArray(node.required) ? node.required.filter((v) => typeof v === "string") : []);
289
+ // At the operation root, optional really means optional: the most honest
290
+ // runnable example is `{}`. Inside a required object, one representative
291
+ // optional field still makes an otherwise empty nested shape legible.
292
+ const names = required.size > 0 ? [...required] : depth === 0 ? [] : Object.keys(properties).slice(0, 1);
293
+ const value = {};
294
+ for (const name of names) {
295
+ if (properties[name] !== undefined)
296
+ value[name] = exampleFromSchema(properties[name], name, depth + 1);
297
+ }
298
+ if (Object.keys(value).length === 0 && node.additionalProperties && typeof node.additionalProperties === "object") {
299
+ value.key = exampleFromSchema(node.additionalProperties, "key", depth + 1);
300
+ }
301
+ return value;
302
+ }
303
+ if (type === "array" || node.items) {
304
+ const count = typeof node.minItems === "number" && node.minItems > 1 ? Math.min(node.minItems, 3) : 1;
305
+ return Array.from({ length: count }, () => exampleFromSchema(node.items, field, depth + 1));
306
+ }
307
+ if (type === "integer" || type === "number") {
308
+ if (typeof node.minimum === "number")
309
+ return node.minimum;
310
+ if (typeof node.exclusiveMinimum === "number")
311
+ return node.exclusiveMinimum + 1;
312
+ return 1;
313
+ }
314
+ if (type === "boolean")
315
+ return true;
316
+ if (type === "string" || type === undefined) {
317
+ const format = typeof node.format === "string" ? node.format : "";
318
+ const lower = field.toLowerCase();
319
+ let value = format === "date-time" ? "2026-01-15T12:00:00Z"
320
+ : format === "date" ? "2026-01-15"
321
+ : format === "email" || lower.includes("email") ? "person@example.com"
322
+ : (format === "uri" || format === "url" || lower.endsWith("url")) && (lower.includes("webhook") || lower.includes("callback")) ? "https://example.com/webhook"
323
+ : format === "uri" || format === "url" || lower.endsWith("url") ? "https://example.com"
324
+ : format === "uuid" ? "00000000-0000-4000-8000-000000000000"
325
+ : lower.includes("repository") || lower === "repo" ? "acme/api"
326
+ : lower.includes("path") ? "openapi.yaml"
327
+ : lower.includes("version") ? "1.0.0"
328
+ : /(^|_)id$|Id$/.test(field) ? (lower === "id" ? "id" : field.replace(/[_-]?id$/i, "")) + "_123"
329
+ : lower.includes("name") ? "example"
330
+ : "value";
331
+ const min = typeof node.minLength === "number" ? node.minLength : 0;
332
+ while (value.length < min)
333
+ value += "x";
334
+ if (typeof node.maxLength === "number")
335
+ value = value.slice(0, node.maxLength);
336
+ return value;
337
+ }
338
+ return null;
339
+ }
340
+ export function exampleArgumentsFromSchema(inputSchema) {
341
+ const value = exampleFromSchema(inputSchema, "arguments");
342
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
343
+ }
344
+ /** The `fields` argument every tool takes unless the API already has one:
345
+ * dotted paths to keep in the result (per item for paginated tools). */
346
+ export const FIELDS_ARGUMENT = "fields";
347
+ function fieldsArgumentSchema(op) {
348
+ return {
349
+ type: "array",
350
+ items: { type: "string" },
351
+ description: "Result keys to keep, as dotted paths" + (op.paginated ? ", applied to each item" : "") + ' (e.g. ["id","name"]). Omit for the whole result. Keeps responses small.',
352
+ };
353
+ }
354
+ /** The input schema a tool advertises: the operation's own arguments plus
355
+ * `select` (GraphQL selection override) and `fields` (result projection)
356
+ * when those names are free. */
357
+ export function toolInputSchema(op) {
358
+ const base = op.inputSchema.properties;
359
+ const properties = { ...(base ?? {}) };
360
+ if (op.select && properties.select === undefined) {
361
+ properties.select = { type: "string", description: 'GraphQL selection set override, e.g. "{ id name }"' };
362
+ }
363
+ if (properties[FIELDS_ARGUMENT] === undefined)
364
+ properties[FIELDS_ARGUMENT] = fieldsArgumentSchema(op);
365
+ return { ...op.inputSchema, properties };
366
+ }
367
+ /** True when the tool's own schema has a `fields` parameter, so the
368
+ * projection argument is the API's, not ours. */
369
+ export function hasOwnFieldsParam(op) {
370
+ return op.inputSchema.properties?.[FIELDS_ARGUMENT] !== undefined;
371
+ }
372
+ /** One tool per operation, as advertised by tools/list. */
373
+ export function operationTool(op) {
374
+ return {
375
+ name: op.tool,
376
+ title: op.summary ?? op.tool,
377
+ description: op.toolDescription,
378
+ annotations: annotationsFor(op),
379
+ inputSchema: toolInputSchema(op),
380
+ ...(op.outputSchema ? { outputSchema: op.outputSchema } : {}),
381
+ };
382
+ }
383
+ /** Reference matches per search_docs page. */
384
+ export const SEARCH_PAGE_SIZE = 15;
385
+ export const SEARCH_DOCS_TOOL = {
386
+ name: "search_docs",
387
+ description: "Search this API's reference (operations, parameters) and, when a docs site is configured, its guides. Best matches first; page through with page.",
388
+ inputSchema: { type: "object", properties: { query: { type: "string" }, page: { type: "integer", minimum: 1, description: "Page of reference matches (15 per page), default 1" } }, required: ["query"] },
389
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
390
+ };
391
+ export const READ_DOCS_TOOL = {
392
+ name: "read_docs",
393
+ description: 'Read a documentation page: an operation reference (a tool name, or dotted "resource.method") or a docs-site guide page by name or URL.',
394
+ inputSchema: { type: "object", properties: { page: { type: "string" } }, required: ["page"] },
395
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
396
+ };
397
+ export const EXECUTE_TOOL = {
398
+ name: "execute",
399
+ description: "Execute an API operation by name. Discover it with search_docs, then read_docs <operation> for its full schema, example, and safety classification. Destructive operations require confirm: true.",
400
+ inputSchema: {
401
+ type: "object",
402
+ properties: {
403
+ operation: { type: "string", description: "Operation tool name, e.g. accounts_create" },
404
+ arguments: { type: "object", description: "Operation arguments keyed by parameter name" },
405
+ confirm: { type: "boolean", description: "Required and must be true for destructive operations. Omit for reads and ordinary writes." },
406
+ },
407
+ required: ["operation"],
408
+ },
409
+ annotations: { openWorldHint: false },
410
+ };
411
+ /** The operations a server serves under these options: the callable set,
412
+ * not just the listed one, so a hidden write is not reachable by name or
413
+ * through execute either. Deterministic (spec) order. */
414
+ export function visibleOps(ops, options = {}) {
415
+ let out = ops.filter((op) => mcpExposed(op, { uploads: options.uploads }));
416
+ if (options.readOnly)
417
+ out = out.filter(isReadOperation);
418
+ if (options.include && options.include.length > 0) {
419
+ const wanted = new Set(options.include.map((s) => s.trim().toLowerCase()).filter(Boolean));
420
+ out = out.filter((op) => wanted.has(op.tool.toLowerCase()) ||
421
+ (op.resource !== undefined && wanted.has(op.resource.toLowerCase())) ||
422
+ (op.resource !== undefined && op.method !== undefined && wanted.has((op.resource + "." + op.method).toLowerCase())));
423
+ }
424
+ return out;
425
+ }
426
+ /** Parse a comma-separated include list (from an env var or a flag). */
427
+ export function parseIncludeList(value) {
428
+ const list = (value ?? "").split(",").map((s) => s.trim()).filter(Boolean);
429
+ return list.length > 0 ? list : undefined;
430
+ }
431
+ /**
432
+ * The tool list for a mode: every operation plus the docs pair, or the
433
+ * three-tool "meta" shape (search_docs, read_docs, execute) that keeps huge
434
+ * APIs from flooding an agent's context. Deterministic order.
435
+ */
436
+ export function toolDefinitions(ops, mode) {
437
+ if (mode === "meta") {
438
+ return [
439
+ { ...SEARCH_DOCS_TOOL, description: "Search this API's " + ops.length + " operations and, when a docs site is configured, its guides. Start here to find the operation you need." },
440
+ { ...READ_DOCS_TOOL, description: "Read an operation's full reference (arguments, schemas, authentication, safety and example) by tool name, or a docs-site guide page." },
441
+ EXECUTE_TOOL,
442
+ ];
443
+ }
444
+ return [...ops.map(operationTool), SEARCH_DOCS_TOOL, READ_DOCS_TOOL];
445
+ }
446
+ /** How big a tools/list is, as agents pay for it. Characters of JSON and a
447
+ * rough token count (one token per four characters, the usual estimate for
448
+ * JSON). Generation and `<bin> mcp` report it. */
449
+ export function toolsListSize(tools) {
450
+ const chars = JSON.stringify(tools).length;
451
+ return { tools: tools.length, chars, approxTokens: Math.round(chars / 4) };
452
+ }
453
+ /** Keep automatic per-operation discovery under roughly 10k tokens. The
454
+ * schema, not merely the operation count, determines what an agent pays. */
455
+ export const MCP_AUTO_MAX_TOOLS_LIST_CHARS = 40_000;
456
+ export function resolveToolMode(ops, requested = "auto") {
457
+ if (requested === "operations" || requested === "meta") {
458
+ return { mode: requested, ...toolsListSize(toolDefinitions(ops, requested)) };
459
+ }
460
+ const operations = toolsListSize(toolDefinitions(ops, "operations"));
461
+ const mode = ops.length > 100 || operations.chars > MCP_AUTO_MAX_TOOLS_LIST_CHARS ? "meta" : "operations";
462
+ return mode === "operations"
463
+ ? { mode, ...operations }
464
+ : { mode, ...toolsListSize(toolDefinitions(ops, mode)) };
465
+ }
466
+ /** Resolve an operation by tool name or dotted resource.method. */
467
+ export function findOperation(ops, wanted) {
468
+ return ops.find((o) => o.tool === wanted)
469
+ ?? ops.find((o) => o.tool.toLowerCase() === wanted.toLowerCase().replace(/\./g, "_"));
470
+ }
471
+ /** Names of the arguments the caller must supply, given the op's params. */
472
+ export function missingArguments(op, args) {
473
+ return op.params.filter((p) => p.required && args[p.name] === undefined).map((p) => p.name);
474
+ }
475
+ /** The server/discover instructions: what the tools are, how arguments and
476
+ * results behave, where credentials come from, plus whatever the project
477
+ * adds. One paragraph; agents read it once per session. */
478
+ export function serverInstructions(input) {
479
+ const parts = [];
480
+ parts.push(input.mode === "meta"
481
+ ? input.title + " as MCP tools: search_docs, read_docs and execute over " + input.toolCount + " operations. Start with search_docs to find an operation, read_docs <tool> for its full argument reference, then execute it by name. Destructive operations require confirm: true on execute."
482
+ : input.title + " as MCP tools: one tool per operation (" + input.toolCount + "), plus search_docs to find operations and read_docs <tool> for an operation's full argument reference.");
483
+ parts.push("Arguments use the API's wire names; an unknown, mistyped or missing argument returns an isError result listing each problem (nothing is dropped silently), and obvious forms are coerced (\"true\" to boolean, \"3\" to number, enum case).");
484
+ parts.push("Paginated tools return items, hasMore and nextPage (the exact arguments for the following page). Pass fields (dotted paths) to keep only the result keys you need; oversized results are cut to whole items or keys with a truncated note saying how to ask for less.");
485
+ parts.push("Errors carry error, code, message, status, the API's body and next_steps.");
486
+ if (input.authHint)
487
+ parts.push(input.authHint.trim().replace(/[.]?$/, "."));
488
+ if (input.identityTool)
489
+ parts.push("Call " + input.identityTool + " first to learn which account the credential belongs to.");
490
+ if (input.uploads)
491
+ parts.push("Upload operations take a local file path for each file argument; this server reads the file and sends it. Binary responses are saved to disk and the result names the path; images come back as an image block.");
492
+ if (input.readOnly) {
493
+ parts.push("This server is read-only: " + (input.hiddenWrites ? input.hiddenWrites + " write operations are not available here." : "write operations are not available here."));
494
+ }
495
+ if (input.custom && input.custom.trim())
496
+ parts.push(input.custom.trim());
497
+ return parts.join(" ");
498
+ }
499
+ const normalizeName = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
500
+ function editDistance(a, b) {
501
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
502
+ for (let i = 1; i <= a.length; i++) {
503
+ let diag = prev[0];
504
+ prev[0] = i;
505
+ for (let j = 1; j <= b.length; j++) {
506
+ const tmp = prev[j];
507
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
508
+ diag = tmp;
509
+ }
510
+ }
511
+ return prev[b.length];
512
+ }
513
+ /** The closest accepted name: same letters ignoring case/punctuation first,
514
+ * then a small edit distance. Undefined when nothing is close. */
515
+ export function closestName(name, known) {
516
+ const exact = known.filter((k) => normalizeName(k) === normalizeName(name));
517
+ if (exact.length === 1)
518
+ return exact[0];
519
+ if (exact.length > 1)
520
+ return undefined;
521
+ let best;
522
+ for (const k of known) {
523
+ const d = editDistance(name.toLowerCase(), k.toLowerCase());
524
+ if (d <= Math.max(1, Math.floor(k.length / 4)) && (best === undefined || d < best.d))
525
+ best = { name: k, d };
526
+ }
527
+ return best?.name;
528
+ }
529
+ function schemaTypes(schema) {
530
+ const t = schema.type;
531
+ if (typeof t === "string")
532
+ return [t];
533
+ if (Array.isArray(t))
534
+ return t.filter((x) => typeof x === "string");
535
+ return [];
536
+ }
537
+ /**
538
+ * Coerce one value toward its schema when the intent is unambiguous: the
539
+ * strings agents produce for booleans and numbers, a JSON string for an
540
+ * object or array, a scalar for a one-element array, an enum member in the
541
+ * wrong case. Returns the value to send, or a message when it can't be made
542
+ * to fit. Untyped schemas (unions, anything) pass through.
543
+ */
544
+ export function coerceValue(value, schema) {
545
+ if (value === null || value === undefined)
546
+ return { value };
547
+ // Date-shaped arguments take relative forms (-P7D, 7 days ago, today),
548
+ // resolved here so the API sees an absolute value.
549
+ const dateKind = dateKindOf(schema.format);
550
+ if (dateKind && typeof value === "string") {
551
+ const resolved = relativeDate(value, dateKind);
552
+ if (resolved && "error" in resolved)
553
+ return { error: resolved.error };
554
+ if (resolved)
555
+ value = resolved.value;
556
+ }
557
+ const types = schemaTypes(schema);
558
+ const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
559
+ const accepts = (t) => types.length === 0 || types.includes(t);
560
+ const kind = Array.isArray(value) ? "array" : typeof value;
561
+ let out = value;
562
+ if (types.length > 0) {
563
+ if (kind === "boolean" && !accepts("boolean")) {
564
+ if (accepts("string"))
565
+ out = String(value);
566
+ else
567
+ return { error: "expected " + types.join(" or ") + ", got boolean" };
568
+ }
569
+ else if (kind === "number" && !accepts("number") && !accepts("integer")) {
570
+ if (accepts("string"))
571
+ out = String(value);
572
+ else if (accepts("array"))
573
+ out = [value];
574
+ else
575
+ return { error: "expected " + types.join(" or ") + ", got number" };
576
+ }
577
+ else if (kind === "number" && accepts("integer") && !accepts("number") && !Number.isInteger(value)) {
578
+ return { error: "expected an integer, got " + String(value) };
579
+ }
580
+ else if (kind === "string" && !accepts("string")) {
581
+ const s = value.trim();
582
+ if (accepts("boolean") && /^(true|false|yes|no|1|0)$/i.test(s))
583
+ out = /^(true|yes|1)$/i.test(s);
584
+ else if ((accepts("integer") || accepts("number")) && s !== "" && !Number.isNaN(Number(s))) {
585
+ const n = Number(s);
586
+ if (accepts("integer") && !accepts("number") && !Number.isInteger(n))
587
+ return { error: "expected an integer, got \"" + s + "\"" };
588
+ out = n;
589
+ }
590
+ else if ((accepts("object") || accepts("array")) && /^[[{]/.test(s)) {
591
+ try {
592
+ const parsed = JSON.parse(s);
593
+ const parsedKind = Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed;
594
+ if (!accepts(parsedKind))
595
+ return { error: "expected " + types.join(" or ") + ", got a JSON " + parsedKind + " in a string" };
596
+ out = parsed;
597
+ }
598
+ catch {
599
+ return { error: "expected " + types.join(" or ") + ", got a string that is not valid JSON" };
600
+ }
601
+ }
602
+ else if (accepts("array")) {
603
+ out = [value];
604
+ }
605
+ else {
606
+ return { error: "expected " + types.join(" or ") + ", got string" };
607
+ }
608
+ }
609
+ else if (kind === "object" && !accepts("object")) {
610
+ if (accepts("array"))
611
+ out = [value];
612
+ else
613
+ return { error: "expected " + types.join(" or ") + ", got object" };
614
+ }
615
+ else if (kind === "array" && !accepts("array")) {
616
+ return { error: "expected " + types.join(" or ") + ", got array" };
617
+ }
618
+ }
619
+ // Array items: coerce each against the items schema when it has one.
620
+ if (Array.isArray(out) && schema.items && typeof schema.items === "object" && !Array.isArray(schema.items)) {
621
+ const itemSchema = schema.items;
622
+ const items = [];
623
+ for (let i = 0; i < out.length; i++) {
624
+ const r = coerceValue(out[i], itemSchema);
625
+ if ("error" in r)
626
+ return { error: "item " + i + ": " + r.error };
627
+ items.push(r.value);
628
+ }
629
+ out = items;
630
+ }
631
+ if (enumValues && typeof out === "string" && !enumValues.includes(out)) {
632
+ const match = enumValues.filter((e) => typeof e === "string" && e.toLowerCase() === out.toLowerCase());
633
+ if (match.length === 1)
634
+ out = match[0];
635
+ else
636
+ return { error: "must be one of " + enumValues.map((e) => JSON.stringify(e)).join(", ") + ", got " + JSON.stringify(out) };
637
+ }
638
+ return { value: out };
639
+ }
640
+ function parseFields(value) {
641
+ const raw = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : null;
642
+ if (raw === null)
643
+ return { error: "expected an array of field paths, e.g. [\"id\",\"name\"]" };
644
+ const paths = [];
645
+ for (const entry of raw) {
646
+ if (typeof entry !== "string")
647
+ return { error: "expected an array of strings" };
648
+ const path = entry.trim();
649
+ if (path !== "")
650
+ paths.push(path.split("."));
651
+ }
652
+ return paths;
653
+ }
654
+ /**
655
+ * Check a tool call's arguments against the tool's input schema before
656
+ * anything reaches the API. Unknown names are matched to the accepted one
657
+ * when only case or punctuation differs (accountId -> account_id) and
658
+ * rejected with a suggestion otherwise; values are coerced where the
659
+ * intent is clear and rejected where it isn't; required arguments must be
660
+ * present. Every problem is reported at once, as one isError result, so a
661
+ * single round trip fixes the call. Nothing is dropped silently.
662
+ */
663
+ export function prepareCall(op, rawArgs, options = {}) {
664
+ const schema = toolInputSchema(op);
665
+ const properties = (schema.properties ?? {});
666
+ const known = Object.keys(properties);
667
+ const issues = [];
668
+ const args = {};
669
+ for (const [name, value] of Object.entries(rawArgs)) {
670
+ if (value === undefined)
671
+ continue;
672
+ if (properties[name] !== undefined) {
673
+ args[name] = value;
674
+ continue;
675
+ }
676
+ const close = closestName(name, known);
677
+ if (close !== undefined && normalizeName(close) === normalizeName(name) && rawArgs[close] === undefined) {
678
+ args[close] = value;
679
+ continue;
680
+ }
681
+ const accepted = known.length <= 20 ? " Accepted: " + known.join(", ") + "." : " read_docs \"" + op.tool + "\" lists the " + known.length + " accepted arguments.";
682
+ issues.push({
683
+ code: "UNKNOWN_ARGUMENT",
684
+ argument: name,
685
+ message: "Unknown argument \"" + name + "\"" + (close !== undefined ? "; did you mean \"" + close + "\"?" : ".") + accepted,
686
+ });
687
+ }
688
+ let fields = null;
689
+ if (!hasOwnFieldsParam(op) && args[FIELDS_ARGUMENT] !== undefined) {
690
+ const parsed = parseFields(args[FIELDS_ARGUMENT]);
691
+ if ("error" in parsed)
692
+ issues.push({ code: "INVALID_ARGUMENT", argument: FIELDS_ARGUMENT, message: "fields: " + parsed.error });
693
+ else
694
+ fields = parsed.length > 0 ? parsed : null;
695
+ delete args[FIELDS_ARGUMENT];
696
+ }
697
+ for (const [name, value] of Object.entries(args)) {
698
+ const propSchema = properties[name];
699
+ if (!propSchema)
700
+ continue;
701
+ const r = coerceValue(value, propSchema);
702
+ if ("error" in r)
703
+ issues.push({ code: "INVALID_ARGUMENT", argument: name, message: name + ": " + r.error });
704
+ else
705
+ args[name] = r.value;
706
+ }
707
+ const required = Array.isArray(schema.required) ? schema.required : [];
708
+ for (const name of required) {
709
+ if (args[name] === undefined) {
710
+ issues.push({ code: "MISSING_ARGUMENT", argument: name, message: "Missing required argument \"" + name + "\"" + (properties[name]?.description ? ": " + String(properties[name].description).split("\n")[0] : ".") });
711
+ }
712
+ }
713
+ if (issues.length > 0)
714
+ return { ok: false, outcome: argumentsError(op, issues) };
715
+ return { ok: true, call: { args, fields, maxChars: options.maxChars ?? DEFAULT_MAX_RESULT_CHARS } };
716
+ }
717
+ /** The isError result for bad arguments: one stable code, one issue per
718
+ * argument, and the way out. */
719
+ export function argumentsError(op, issues) {
720
+ const structured = {
721
+ error: "InvalidArguments",
722
+ code: "INVALID_ARGUMENTS",
723
+ message: issues.length + (issues.length === 1 ? " problem" : " problems") + " with the arguments to " + op.tool + "; nothing was sent to the API.",
724
+ issues,
725
+ next_steps: [
726
+ "Fix the arguments listed in issues and call " + op.tool + " again.",
727
+ "read_docs {\"page\": \"" + op.tool + "\"} lists every argument with its type and whether it is required.",
728
+ ],
729
+ };
730
+ return { text: JSON.stringify(structured), isError: true, structured };
731
+ }
732
+ // ---- results: projection, size cap, envelopes ------------------------------------
733
+ /** Keep only `paths` of a value: arrays item by item, objects by dotted
734
+ * path; scalars untouched. Same rule as the CLI's --fields. */
735
+ export function projectFields(value, paths) {
736
+ if (paths === null)
737
+ return value;
738
+ if (Array.isArray(value))
739
+ return value.map((v) => projectFields(v, paths));
740
+ if (value === null || typeof value !== "object")
741
+ return value;
742
+ const out = {};
743
+ for (const path of paths) {
744
+ let cursor = value;
745
+ for (const key of path) {
746
+ if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) {
747
+ cursor = undefined;
748
+ break;
749
+ }
750
+ cursor = cursor[key];
751
+ }
752
+ if (cursor === undefined)
753
+ continue;
754
+ let target = out;
755
+ for (const key of path.slice(0, -1)) {
756
+ const next = target[key];
757
+ if (next === undefined || next === null || typeof next !== "object" || Array.isArray(next))
758
+ target[key] = {};
759
+ target = target[key];
760
+ }
761
+ target[path[path.length - 1]] = cursor;
762
+ }
763
+ return out;
764
+ }
765
+ const fieldsHint = (perItem) => "Pass fields (dotted paths" + (perItem ? ", applied per item" : "") + ") to keep only the keys you need.";
766
+ /** How many leading items fit under `budget` characters once serialized
767
+ * compactly with commas between them. At least one. */
768
+ function itemsThatFit(items, budget) {
769
+ let used = 0;
770
+ let k = 0;
771
+ for (const item of items) {
772
+ const size = JSON.stringify(item).length + 1;
773
+ if (k > 0 && used + size > budget)
774
+ break;
775
+ used += size;
776
+ k++;
777
+ }
778
+ return Math.max(1, Math.min(k, items.length));
779
+ }
780
+ /** The result shape for a paginated tool: one page, a continuation signal,
781
+ * and the exact arguments that fetch the next page. Over the size cap, the
782
+ * page is cut to whole items with a `truncated` note; offset and
783
+ * last-id styles resume at the cut, cursor and page styles say what the
784
+ * caller must do instead. */
785
+ export function pageOutcome(items, nextPage, options = {}) {
786
+ const fields = options.fields ?? null;
787
+ const maxChars = options.maxChars ?? DEFAULT_MAX_RESULT_CHARS;
788
+ const shown = projectFields(items, fields);
789
+ const full = { items: shown, hasMore: nextPage !== null, ...(nextPage !== null ? { nextPage } : {}) };
790
+ const text = JSON.stringify(full);
791
+ if (text.length <= maxChars) {
792
+ return { text, isError: false, structured: full };
793
+ }
794
+ const overhead = JSON.stringify({ items: [], hasMore: true, nextPage: nextPage ?? {}, truncated: { omitted: 0, of: 0, reason: "x".repeat(160), next_steps: ["x".repeat(220), "x".repeat(120)] } }).length;
795
+ const k = itemsThatFit(shown, Math.max(0, maxChars - overhead));
796
+ const omitted = shown.length - k;
797
+ const pg = options.pagination;
798
+ const args = options.args ?? {};
799
+ let resume = null;
800
+ if (pg?.style === "offset" && pg.offsetParam) {
801
+ const start = Number(args[pg.offsetParam]) || 0;
802
+ resume = { ...(nextPage ?? args), [pg.offsetParam]: start + k };
803
+ }
804
+ else if (pg?.style === "cursorFromLastId" && pg.cursorParam && pg.idField) {
805
+ const last = items[k - 1]?.[pg.idField];
806
+ if (last !== undefined && last !== null)
807
+ resume = { ...(nextPage ?? args), [pg.cursorParam]: last };
808
+ }
809
+ const steps = [];
810
+ if (resume !== null) {
811
+ steps.push("nextPage resumes at the first omitted item, so following it loses nothing.");
812
+ }
813
+ else {
814
+ steps.push("nextPage continues after this whole page, so the omitted items are skipped by it; call again with the same arguments" + (pg?.limitParam ? " and " + pg.limitParam + "=" + k : " and a smaller page size") + " to see them.");
815
+ }
816
+ steps.push(fieldsHint(true));
817
+ const structured = {
818
+ items: shown.slice(0, k),
819
+ hasMore: resume !== null ? true : nextPage !== null,
820
+ ...(resume !== null ? { nextPage: resume } : nextPage !== null ? { nextPage } : {}),
821
+ truncated: {
822
+ omitted,
823
+ of: shown.length,
824
+ reason: "This page is " + text.length.toLocaleString("en-US") + " characters; results are capped at " + maxChars.toLocaleString("en-US") + ". The first " + k + " of " + shown.length + " items are shown.",
825
+ next_steps: steps,
826
+ },
827
+ };
828
+ // Projected or cut, the page is still an object the loosened outputSchema
829
+ // admits, so it is structuredContent either way.
830
+ return { text: JSON.stringify(structured), isError: false, structured };
831
+ }
832
+ /** Placeholder for a value cut from an oversized object result. */
833
+ const omittedMarker = (key, chars) => "[omitted: " + chars.toLocaleString("en-US") + " characters. Pass fields=[\"" + key + "\"] to fetch this key alone.]";
834
+ export function dataOutcome(data, options = {}) {
835
+ const fields = options.fields ?? null;
836
+ const maxChars = options.maxChars ?? DEFAULT_MAX_RESULT_CHARS;
837
+ const value = data === undefined || data === null ? { ok: true } : projectFields(data, fields);
838
+ if (typeof value === "string") {
839
+ if (value.length <= maxChars)
840
+ return { text: value, isError: false, structured: value };
841
+ return { text: value.slice(0, maxChars) + "\n\n[truncated: " + (value.length - maxChars).toLocaleString("en-US") + " more characters; results are capped at " + maxChars.toLocaleString("en-US") + ".]", isError: false };
842
+ }
843
+ const text = JSON.stringify(value);
844
+ if (text.length <= maxChars) {
845
+ return { text, isError: false, structured: value };
846
+ }
847
+ if (Array.isArray(value)) {
848
+ const k = itemsThatFit(value, maxChars - 400);
849
+ const note = "\n\n[truncated: showing " + k + " of " + value.length + " items; the result is " + text.length.toLocaleString("en-US") + " characters and results are capped at " + maxChars.toLocaleString("en-US") + ". " + fieldsHint(true) + "]";
850
+ return { text: JSON.stringify(value.slice(0, k)) + note, isError: false };
851
+ }
852
+ if (typeof value === "object") {
853
+ // Drop the largest top-level values first until it fits. A dropped key
854
+ // is removed rather than replaced with a marker: a string where the
855
+ // schema promised an object would fail the client's validation of
856
+ // structuredContent. The `truncated` note names each omitted key and the
857
+ // argument that fetches it alone.
858
+ const entries = Object.entries(value).map(([key, v]) => ({ key, v, size: JSON.stringify(v)?.length ?? 4 }));
859
+ const bySize = [...entries].sort((a, b) => b.size - a.size);
860
+ const cut = new Map();
861
+ let size = text.length;
862
+ for (const e of bySize) {
863
+ if (size <= maxChars)
864
+ break;
865
+ cut.set(e.key, e.size);
866
+ size -= e.size;
867
+ }
868
+ const out = {};
869
+ for (const e of entries)
870
+ if (!cut.has(e.key))
871
+ out[e.key] = e.v;
872
+ out.truncated = {
873
+ omitted_keys: [...cut.keys()],
874
+ omitted: Object.fromEntries([...cut].map(([key, chars]) => [key, omittedMarker(key, chars)])),
875
+ reason: "The result is " + text.length.toLocaleString("en-US") + " characters; results are capped at " + maxChars.toLocaleString("en-US") + ".",
876
+ next_steps: [fieldsHint(false)],
877
+ };
878
+ return { text: JSON.stringify(out), isError: false, structured: out };
879
+ }
880
+ return { text, isError: false, structured: value };
881
+ }
882
+ // ---- binary results -------------------------------------------------------------
883
+ /** Images up to this many bytes come back as an image content block the
884
+ * model can look at; larger ones follow the binary path. */
885
+ export const MAX_IMAGE_BYTES = 4_000_000;
886
+ /** Without a place to save, a binary up to this size is embedded as a
887
+ * base64 resource block; beyond it, the result describes the file. */
888
+ export const MAX_EMBED_BYTES = 1_000_000;
889
+ function toBase64(bytes) {
890
+ const B = globalThis.Buffer;
891
+ if (B)
892
+ return B.from(bytes).toString("base64");
893
+ let binary = "";
894
+ for (let i = 0; i < bytes.length; i += 0x8000)
895
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
896
+ return btoa(binary);
897
+ }
898
+ const EXTENSIONS = {
899
+ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/svg+xml": "svg",
900
+ "application/pdf": "pdf", "application/zip": "zip", "application/gzip": "gz", "text/csv": "csv",
901
+ "application/json": "json", "text/plain": "txt", "application/octet-stream": "bin",
902
+ };
903
+ /** A file extension for a media type (bin when unknown). */
904
+ export function extensionFor(mediaType) {
905
+ const base = mediaType.split(";")[0].trim().toLowerCase();
906
+ return EXTENSIONS[base] ?? base.split("/")[1]?.replace(/[^a-z0-9]+/g, "") ?? "bin";
907
+ }
908
+ /**
909
+ * A binary response as the agent should get it: an image block for images
910
+ * the model can look at, a saved file (path, type, size) when the server
911
+ * can write one, an embedded resource for small binaries otherwise, and
912
+ * a description when it is too large to carry. Never "{}".
913
+ */
914
+ export async function binaryOutcome(blob, options = {}) {
915
+ const mediaType = (blob.type || "application/octet-stream").split(";")[0].trim().toLowerCase();
916
+ const bytes = new Uint8Array(await blob.arrayBuffer());
917
+ const name = (options.tool ?? "result") + "." + extensionFor(mediaType);
918
+ const summary = { media_type: mediaType, bytes: bytes.length };
919
+ if (mediaType.startsWith("image/") && bytes.length <= MAX_IMAGE_BYTES) {
920
+ return {
921
+ text: JSON.stringify(summary),
922
+ isError: false,
923
+ content: [{ type: "image", data: toBase64(bytes), mimeType: mediaType }, { type: "text", text: JSON.stringify(summary) }],
924
+ };
925
+ }
926
+ if (options.saveBinary) {
927
+ const path = await options.saveBinary(bytes, mediaType, name);
928
+ const saved = { ...summary, saved_to: path, next_steps: ["The file is on the machine running this MCP server; read it from saved_to."] };
929
+ return { text: JSON.stringify(saved), isError: false };
930
+ }
931
+ if (bytes.length <= MAX_EMBED_BYTES) {
932
+ return {
933
+ text: JSON.stringify(summary),
934
+ isError: false,
935
+ content: [
936
+ { type: "resource", resource: { uri: "result://" + name, mimeType: mediaType, blob: toBase64(bytes) } },
937
+ { type: "text", text: JSON.stringify(summary) },
938
+ ],
939
+ };
940
+ }
941
+ return {
942
+ text: JSON.stringify({ ...summary, omitted: true, next_steps: ["The response is a " + mediaType + " of " + bytes.length.toLocaleString("en-US") + " bytes, too large to return through a remote MCP server. Fetch it with the SDK or CLI, or through the package's local MCP server, which saves binaries to disk."] }),
943
+ isError: false,
944
+ };
945
+ }
946
+ function extractRetryAfter(e) {
947
+ const headers = e.headers;
948
+ const fromHeader = headers?.get?.("retry-after");
949
+ if (fromHeader)
950
+ return fromHeader;
951
+ const match = /retry after (\d+)s/i.exec(JSON.stringify(e.body ?? ""));
952
+ return match?.[1];
953
+ }
954
+ /** Classify an SDK error result by status: the code and what to do next. */
955
+ export function classifyError(error, context = {}) {
956
+ const e = (error ?? {});
957
+ const message = e.message ?? String(error);
958
+ if (e.violations !== undefined)
959
+ return { code: "VALIDATION_FAILED", nextSteps: ["Fix the fields named in violations and call again."] };
960
+ if (e.name === "TransportError" || (typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(message))) {
961
+ return { code: "NETWORK_ERROR", nextSteps: ["The API could not be reached (network, DNS, TLS or timeout). Retry once with backoff; do not loop."] };
962
+ }
963
+ const status = typeof e.status === "number" ? e.status : 0;
964
+ const auth = context.authHint ? context.authHint.trim().replace(/[.]?$/, ".") : null;
965
+ if (status === 401) {
966
+ return context.hadCredential
967
+ ? { code: "AUTH_INVALID", nextSteps: ["The credential was rejected; it may be expired or for another environment." + (auth ? " " + auth : "")] }
968
+ : { code: "NO_AUTH", nextSteps: ["No credential was sent." + (auth ? " " + auth : "")] };
969
+ }
970
+ if (status === 403)
971
+ return { code: "AUTH_INVALID", nextSteps: ["The credential lacks access to this operation; it is not a retryable error."] };
972
+ if (status === 402)
973
+ return { code: "PLAN_LIMIT", nextSteps: ["The account's plan stops here; the body may name where to lift the limit. Do not retry the same call as is."] };
974
+ if (status === 404)
975
+ return { code: "NOT_FOUND", nextSteps: notFoundNextSteps(message, e.body) };
976
+ if (status === 429) {
977
+ const retryAfter = extractRetryAfter(e);
978
+ return { code: "RATE_LIMITED", nextSteps: [retryAfter ? "Wait " + retryAfter + " seconds, then call again." : "Back off and retry once; the request was already retried with the server's Retry-After."] };
979
+ }
980
+ if (status === 400 || status === 409 || status === 413 || status === 422) {
981
+ return { code: "INVALID_REQUEST", nextSteps: ["Read body for the field the API named, fix that argument and call again."] };
982
+ }
983
+ if (status >= 500)
984
+ return { code: "SERVER_ERROR", nextSteps: ["Retry once with backoff. If it persists, report the request id from body."] };
985
+ return { code: "CALL_FAILED", nextSteps: [] };
986
+ }
987
+ /** Keep file-path 404s actionable even when the SDK's Error.message is only
988
+ * the OpenAPI response description and the precise message is in body. */
989
+ function notFoundNextSteps(message, body) {
990
+ const record = body;
991
+ const apiMessage = record?.errors?.[0]?.message ?? record?.message ?? record?.error;
992
+ const combined = message + " " + (typeof apiMessage === "string" ? apiMessage : "");
993
+ if (/\bfiles_index\b/i.test(combined)) {
994
+ return ["Read files_index on the generation, choose an exact path it lists, then call again with that path."];
995
+ }
996
+ if (/\bfile\b/i.test(combined) && /\bpaths?\b/i.test(combined)) {
997
+ return ["Check the requested file path against the API's file listing, then call again with an exact path."];
998
+ }
999
+ return ["Check the resource id; list the resource first to find the right one."];
1000
+ }
1001
+ /** A typed API error as the agent should see it: name, a stable code, the
1002
+ * message, status, the API's body, where to read more, and what to do. */
1003
+ export function errorOutcome(error, context = {}) {
1004
+ const e = error;
1005
+ const { code, nextSteps } = classifyError(error, context);
1006
+ const structured = {
1007
+ error: e?.name ?? "Error",
1008
+ code,
1009
+ message: e?.message,
1010
+ ...(typeof e?.status === "number" ? { status: e.status } : {}),
1011
+ ...(e?.body !== undefined ? { body: e.body } : {}),
1012
+ ...(context.docsUrl ? { docs_url: context.docsUrl } : {}),
1013
+ next_steps: nextSteps,
1014
+ };
1015
+ return { text: JSON.stringify(structured), isError: true, structured };
1016
+ }
1017
+ export function textError(text, code = "CALL_FAILED", nextSteps = []) {
1018
+ const structured = { error: "Error", code, message: text, next_steps: nextSteps };
1019
+ return { text: JSON.stringify(structured), isError: true, structured };
1020
+ }
1021
+ function docsPageUrl(source, pathOrFile) {
1022
+ const base = source.docsUrl();
1023
+ if (base === null)
1024
+ return null;
1025
+ try {
1026
+ const baseUrl = new URL(base);
1027
+ if (baseUrl.protocol !== "https:" && baseUrl.protocol !== "http:")
1028
+ return null;
1029
+ const target = /^https?:\/\//.test(pathOrFile)
1030
+ ? new URL(pathOrFile)
1031
+ : new URL(pathOrFile.replace(/^\/+/, ""), baseUrl.toString().replace(/\/+$/, "") + "/");
1032
+ // llms.txt commonly contains absolute links, but a docs tool is not a
1033
+ // general-purpose URL fetcher. Keeping every page on the configured
1034
+ // origin prevents an agent from turning hosted MCP into an SSRF proxy.
1035
+ if (target.origin !== baseUrl.origin || target.username || target.password)
1036
+ return null;
1037
+ return target.toString();
1038
+ }
1039
+ catch {
1040
+ return null;
1041
+ }
1042
+ }
1043
+ async function fetchDocs(source, pathOrFile) {
1044
+ const url = docsPageUrl(source, pathOrFile);
1045
+ return url === null ? null : source.fetchText(url);
1046
+ }
1047
+ export function referenceText(op) {
1048
+ const safety = operationSafety(op);
1049
+ const example = op.exampleArguments ?? exampleArgumentsFromSchema(op.inputSchema);
1050
+ const lines = [
1051
+ op.tool + ": " + op.httpMethod + " " + op.path + (op.paginated ? " (paginated)" : ""),
1052
+ ...(op.summary ? [op.summary] : []),
1053
+ ...(op.description ? ["", op.description.trim()] : []),
1054
+ "",
1055
+ "Safety: " + safety + (safety === "destructive" ? " (execute requires confirm: true)" : ""),
1056
+ ...(op.auth ? ["Authentication: " + op.auth] : []),
1057
+ ];
1058
+ if (op.params.length > 0) {
1059
+ lines.push("", "Arguments:");
1060
+ for (const p of op.params) {
1061
+ lines.push(" " + p.name + " (" + (p.enum ? p.enum.join("|") : p.type) + (p.required ? ", required" : "") + ")" +
1062
+ (p.description ? ": " + p.description.trim().split("\n")[0] : ""));
1063
+ }
1064
+ }
1065
+ if (!hasOwnFieldsParam(op)) {
1066
+ lines.push("", "Also: fields (array of dotted paths) keeps only those keys of the result" + (op.paginated ? ", per item" : "") + ".");
1067
+ }
1068
+ lines.push("", "Input schema:", "```json", JSON.stringify(toolInputSchema(op), null, 2), "```");
1069
+ lines.push("", "Example arguments:", "```json", JSON.stringify(example, null, 2), "```");
1070
+ if (op.outputSchema)
1071
+ lines.push("", "Output schema:", "```json", JSON.stringify(op.outputSchema, null, 2), "```");
1072
+ return lines.join("\n");
1073
+ }
1074
+ /** Query terms: lowercase words of two or more characters, with the
1075
+ * snake/kebab/camel seams split so "createAccount" finds accounts_create. */
1076
+ function searchTerms(query) {
1077
+ return [...new Set(query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 2))];
1078
+ }
1079
+ /** Relevance of one operation to the terms: the tool name counts most,
1080
+ * then summary, path and argument names, then the description. The whole
1081
+ * query as a phrase in the name or summary is a strong signal. */
1082
+ export function searchScore(op, query) {
1083
+ const terms = searchTerms(query);
1084
+ if (terms.length === 0)
1085
+ return 0;
1086
+ const tool = op.tool.toLowerCase();
1087
+ const toolWords = tool.split("_");
1088
+ const summary = (op.summary ?? "").toLowerCase();
1089
+ const path = op.path.toLowerCase();
1090
+ const params = op.params.map((p) => p.name.toLowerCase());
1091
+ const description = (op.description ?? "").toLowerCase();
1092
+ let score = 0;
1093
+ for (const term of terms) {
1094
+ if (toolWords.includes(term))
1095
+ score += 10;
1096
+ else if (tool.includes(term))
1097
+ score += 6;
1098
+ if (summary.split(/[^a-z0-9]+/).includes(term))
1099
+ score += 5;
1100
+ else if (summary.includes(term))
1101
+ score += 3;
1102
+ if (path.includes(term))
1103
+ score += 3;
1104
+ if (params.some((p) => p === term))
1105
+ score += 3;
1106
+ else if (params.some((p) => p.includes(term)))
1107
+ score += 1;
1108
+ if (description.includes(term))
1109
+ score += 1;
1110
+ }
1111
+ const phrase = query.trim().toLowerCase();
1112
+ if (phrase.length >= 3 && (tool.includes(phrase.replace(/[^a-z0-9]+/g, "_")) || summary.includes(phrase)))
1113
+ score += 8;
1114
+ return score;
1115
+ }
1116
+ export async function docsSearch(source, query, page = 1) {
1117
+ const term = query.toLowerCase();
1118
+ const sections = [];
1119
+ const ranked = source.ops
1120
+ .map((op) => ({ op, score: searchScore(op, query) }))
1121
+ .filter((r) => r.score > 0)
1122
+ .sort((a, b) => b.score - a.score || a.op.tool.localeCompare(b.op.tool));
1123
+ const pageIndex = Math.max(1, Math.floor(page)) - 1;
1124
+ const slice = ranked.slice(pageIndex * SEARCH_PAGE_SIZE, (pageIndex + 1) * SEARCH_PAGE_SIZE);
1125
+ if (slice.length > 0) {
1126
+ const more = ranked.length - (pageIndex + 1) * SEARCH_PAGE_SIZE;
1127
+ sections.push("Reference matches (best first" + (ranked.length > SEARCH_PAGE_SIZE ? ", page " + (pageIndex + 1) + " of " + Math.ceil(ranked.length / SEARCH_PAGE_SIZE) : "") + "):\n" +
1128
+ slice.map((r) => "- " + r.op.tool + ": " + (r.op.summary ?? r.op.httpMethod + " " + r.op.path)).join("\n") +
1129
+ (more > 0 ? "\n(" + more + " more; pass page: " + (pageIndex + 2) + ")" : ""));
1130
+ }
1131
+ else if (ranked.length > 0) {
1132
+ sections.push("No reference matches on page " + (pageIndex + 1) + "; there are " + Math.ceil(ranked.length / SEARCH_PAGE_SIZE) + " pages.");
1133
+ }
1134
+ const prose = await fetchDocs(source, "llms-full.txt");
1135
+ if (prose !== null) {
1136
+ let heading = "";
1137
+ const proseMatches = [];
1138
+ for (const line of prose.split("\n")) {
1139
+ if (/^#{1,3} /.test(line))
1140
+ heading = line.replace(/^#+ /, "").trim();
1141
+ else if (line.toLowerCase().includes(term) && proseMatches.length < 15) {
1142
+ proseMatches.push("- [" + heading + "] " + line.trim().slice(0, 160));
1143
+ }
1144
+ }
1145
+ if (proseMatches.length > 0)
1146
+ sections.push("Guide matches:\n" + proseMatches.join("\n"));
1147
+ }
1148
+ if (sections.length === 0) {
1149
+ return {
1150
+ text: "No matches for: " + query + (source.docsUrl() === null ? " (no docs site configured; only the API reference was searched)" : ""),
1151
+ isError: false,
1152
+ };
1153
+ }
1154
+ return { text: sections.join("\n\n"), isError: false };
1155
+ }
1156
+ export async function docsRead(source, page) {
1157
+ const opMatch = findOperation(source.ops, page);
1158
+ if (opMatch)
1159
+ return { text: referenceText(opMatch), isError: false };
1160
+ let target = page;
1161
+ if (!/^https?:\/\//.test(target)) {
1162
+ const index = await fetchDocs(source, "llms.txt");
1163
+ const linked = index?.match(/\((https?:[^)]+)\)/g)?.map((m) => m.slice(1, -1)) ?? [];
1164
+ const hit = linked.find((u) => u.toLowerCase().includes(target.toLowerCase()));
1165
+ if (hit !== undefined)
1166
+ target = hit;
1167
+ }
1168
+ const text = await fetchDocs(source, target);
1169
+ if (text === null) {
1170
+ return textError(source.docsUrl() === null
1171
+ ? "No docs site is configured for this server, and no operation matches \"" + page + "\"."
1172
+ : "Couldn't fetch \"" + page + "\". Use search_docs to find pages.", "NOT_FOUND", ["search_docs finds operations and guide pages."]);
1173
+ }
1174
+ return { text, isError: false };
1175
+ }
1176
+ /**
1177
+ * Dispatch for the shared tools (search_docs, read_docs, execute); returns
1178
+ * undefined for anything else so the caller can run its own tools.
1179
+ * `runOperation` is how an execute call reaches the API; `source.ops` is
1180
+ * the callable set, so a hidden operation is unknown here too.
1181
+ */
1182
+ export async function callSharedTool(name, args, source, runOperation) {
1183
+ if (name === "search_docs") {
1184
+ if (typeof args.query !== "string")
1185
+ return argumentsError({ tool: name }, [{ code: "MISSING_ARGUMENT", argument: "query", message: "search_docs requires a query string." }]);
1186
+ const page = typeof args.page === "number" ? args.page : typeof args.page === "string" && /^\d+$/.test(args.page) ? Number(args.page) : 1;
1187
+ return docsSearch(source, args.query, page);
1188
+ }
1189
+ if (name === "read_docs") {
1190
+ return typeof args.page === "string" ? docsRead(source, args.page) : argumentsError({ tool: name }, [{ code: "MISSING_ARGUMENT", argument: "page", message: "read_docs requires a page string." }]);
1191
+ }
1192
+ if (name === "execute") {
1193
+ if (typeof args.operation !== "string")
1194
+ return argumentsError({ tool: name }, [{ code: "MISSING_ARGUMENT", argument: "operation", message: "execute requires an operation name." }]);
1195
+ const target = findOperation(source.ops, args.operation);
1196
+ if (!target)
1197
+ return textError("Unknown operation: " + args.operation + ".", "NOT_FOUND", ["search_docs finds operations by name, path or description."]);
1198
+ if (operationSafety(target) === "destructive" && args.confirm !== true) {
1199
+ return textError("The destructive operation " + target.tool + " requires explicit confirmation.", "CONFIRMATION_REQUIRED", ["Review read_docs " + target.tool + ", then retry execute with confirm: true if the destructive effect is intended."]);
1200
+ }
1201
+ const opArgs = args.arguments !== null && typeof args.arguments === "object" && !Array.isArray(args.arguments)
1202
+ ? args.arguments
1203
+ : {};
1204
+ return runOperation(target, opArgs);
1205
+ }
1206
+ return undefined;
1207
+ }
1208
+ /** A fetch for docs pages: markdown preferred, same-origin redirects only,
1209
+ * a 10s deadline, and a 2 MB streaming cap. The caller already constrained
1210
+ * the first URL to its configured docs origin; redirects must not escape it. */
1211
+ export const MAX_DOCS_TEXT_BYTES = 2_000_000;
1212
+ export async function fetchDocsText(url) {
1213
+ try {
1214
+ const allowedOrigin = new URL(url).origin;
1215
+ let current = url;
1216
+ const signal = AbortSignal.timeout(10_000);
1217
+ for (let redirects = 0; redirects <= 3; redirects += 1) {
1218
+ const response = await fetch(current, {
1219
+ headers: { Accept: "text/markdown, text/plain, */*" },
1220
+ redirect: "manual",
1221
+ signal,
1222
+ });
1223
+ if (response.status >= 300 && response.status < 400) {
1224
+ const location = response.headers.get("location");
1225
+ if (!location || redirects === 3)
1226
+ return null;
1227
+ const next = new URL(location, current);
1228
+ if (next.origin !== allowedOrigin || next.username || next.password)
1229
+ return null;
1230
+ current = next.toString();
1231
+ continue;
1232
+ }
1233
+ if (!response.ok)
1234
+ return null;
1235
+ const declared = Number(response.headers.get("content-length"));
1236
+ if (Number.isFinite(declared) && declared > MAX_DOCS_TEXT_BYTES)
1237
+ return null;
1238
+ if (!response.body)
1239
+ return "";
1240
+ const reader = response.body.getReader();
1241
+ const decoder = new TextDecoder();
1242
+ let bytes = 0;
1243
+ let text = "";
1244
+ for (;;) {
1245
+ const chunk = await reader.read();
1246
+ if (chunk.done)
1247
+ break;
1248
+ bytes += chunk.value.byteLength;
1249
+ if (bytes > MAX_DOCS_TEXT_BYTES) {
1250
+ await reader.cancel();
1251
+ return null;
1252
+ }
1253
+ text += decoder.decode(chunk.value, { stream: true });
1254
+ }
1255
+ return text + decoder.decode();
1256
+ }
1257
+ return null;
1258
+ }
1259
+ catch {
1260
+ return null;
1261
+ }
1262
+ }