@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.
- package/LICENSE +9 -0
- package/README.md +43 -0
- package/api.json +5163 -0
- package/api.md +512 -0
- package/dist/core/http.d.ts +303 -0
- package/dist/core/http.d.ts.map +1 -0
- package/dist/core/http.js +770 -0
- package/dist/core/pagination.d.ts +51 -0
- package/dist/core/pagination.d.ts.map +1 -0
- package/dist/core/pagination.js +154 -0
- package/dist/dates.d.ts +33 -0
- package/dist/dates.d.ts.map +1 -0
- package/dist/dates.js +136 -0
- package/dist/errors.d.ts +81 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +103 -0
- package/dist/index.d.ts +92 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +86 -0
- package/dist/mcp-protocol.d.ts +453 -0
- package/dist/mcp-protocol.d.ts.map +1 -0
- package/dist/mcp-protocol.js +1262 -0
- package/dist/mcp.d.ts +5 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +449 -0
- package/dist/ops.d.ts +115 -0
- package/dist/ops.d.ts.map +1 -0
- package/dist/ops.js +79 -0
- package/dist/resources/account.d.ts +18 -0
- package/dist/resources/account.d.ts.map +1 -0
- package/dist/resources/account.js +26 -0
- package/dist/resources/api-keys.d.ts +37 -0
- package/dist/resources/api-keys.d.ts.map +1 -0
- package/dist/resources/api-keys.js +67 -0
- package/dist/resources/generate.d.ts +25 -0
- package/dist/resources/generate.d.ts.map +1 -0
- package/dist/resources/generate.js +41 -0
- package/dist/resources/generations.d.ts +31 -0
- package/dist/resources/generations.d.ts.map +1 -0
- package/dist/resources/generations.js +56 -0
- package/dist/resources/projects.d.ts +110 -0
- package/dist/resources/projects.d.ts.map +1 -0
- package/dist/resources/projects.js +220 -0
- package/dist/resources/spec-revisions.d.ts +47 -0
- package/dist/resources/spec-revisions.d.ts.map +1 -0
- package/dist/resources/spec-revisions.js +90 -0
- package/dist/schemas.d.ts +6 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +88 -0
- package/dist/types.d.ts +759 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +37 -0
- package/dist/worker.d.ts +5 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +12 -0
- package/package.json +45 -0
- package/src/core/http.ts +1008 -0
- package/src/core/pagination.ts +195 -0
- package/src/dates.ts +126 -0
- package/src/errors.ts +117 -0
- package/src/index.ts +153 -0
- package/src/mcp-protocol.ts +1451 -0
- package/src/mcp.ts +448 -0
- package/src/ops.ts +174 -0
- package/src/resources/account.ts +43 -0
- package/src/resources/api-keys.ts +105 -0
- package/src/resources/generate.ts +69 -0
- package/src/resources/generations.ts +100 -0
- package/src/resources/projects.ts +391 -0
- package/src/resources/spec-revisions.ts +150 -0
- package/src/schemas.ts +90 -0
- package/src/types.ts +825 -0
- 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
|
+
}
|