@typeship-ax/mcp 0.6.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -0
- package/README.md +67 -10
- package/api.json +6735 -3243
- package/api.md +8537 -248
- package/dist/api-identity.d.ts +40 -0
- package/dist/api-identity.d.ts.map +1 -0
- package/dist/api-identity.js +128 -0
- package/dist/auth-profiles.d.ts +30 -0
- package/dist/auth-profiles.d.ts.map +1 -0
- package/dist/auth-profiles.js +138 -0
- package/dist/core/http.d.ts +21 -92
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +143 -221
- package/dist/core/pagination.d.ts.map +1 -1
- package/dist/core/pagination.js +6 -34
- package/dist/credential-storage.d.ts +24 -0
- package/dist/credential-storage.d.ts.map +1 -0
- package/dist/credential-storage.js +207 -0
- package/dist/dates.d.ts +0 -2
- package/dist/dates.d.ts.map +1 -1
- package/dist/dates.js +0 -1
- package/dist/docs.d.ts +36 -0
- package/dist/docs.d.ts.map +1 -0
- package/dist/docs.js +258 -0
- package/dist/errors.d.ts +42 -34
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +30 -20
- package/dist/index.d.ts +27 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -14
- package/dist/mcp-authorization.d.ts +52 -0
- package/dist/mcp-authorization.d.ts.map +1 -0
- package/dist/mcp-authorization.js +232 -0
- package/dist/mcp-protocol.d.ts +69 -25
- package/dist/mcp-protocol.d.ts.map +1 -1
- package/dist/mcp-protocol.js +386 -138
- package/dist/mcp.d.ts +21 -3
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +199 -85
- package/dist/named-credentials.d.ts +21 -0
- package/dist/named-credentials.d.ts.map +1 -0
- package/dist/named-credentials.js +86 -0
- package/dist/oauth-request.d.ts +21 -0
- package/dist/oauth-request.d.ts.map +1 -0
- package/dist/oauth-request.js +119 -0
- package/dist/oauth-session.d.ts +106 -0
- package/dist/oauth-session.d.ts.map +1 -0
- package/dist/oauth-session.js +244 -0
- package/dist/ops.d.ts +18 -0
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +31 -17
- package/dist/resources/account.d.ts +4 -4
- package/dist/resources/account.d.ts.map +1 -1
- package/dist/resources/account.js +1 -0
- package/dist/resources/api-keys.d.ts +13 -8
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +5 -1
- package/dist/resources/definition-revisions.d.ts +58 -0
- package/dist/resources/definition-revisions.d.ts.map +1 -0
- package/dist/resources/definition-revisions.js +114 -0
- package/dist/resources/definitions.d.ts +35 -0
- package/dist/resources/definitions.d.ts.map +1 -0
- package/dist/resources/definitions.js +60 -0
- package/dist/resources/generate.d.ts +18 -7
- package/dist/resources/generate.d.ts.map +1 -1
- package/dist/resources/generate.js +13 -5
- package/dist/resources/generations.d.ts +6 -6
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +3 -1
- package/dist/resources/projects.d.ts +111 -35
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +125 -15
- package/dist/resources/targets.d.ts +97 -0
- package/dist/resources/targets.d.ts.map +1 -0
- package/dist/resources/targets.js +197 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +135 -62
- package/dist/types.d.ts +2072 -267
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +20 -3
- package/dist/worker.js +4 -4
- package/package.json +11 -1
- package/server.json +42 -0
- package/src/api-identity.ts +98 -0
- package/src/auth-profiles.ts +114 -0
- package/src/core/http.ts +156 -305
- package/src/core/pagination.ts +6 -30
- package/src/credential-storage.ts +183 -0
- package/src/dates.ts +0 -1
- package/src/docs.ts +239 -0
- package/src/errors.ts +52 -41
- package/src/index.ts +49 -14
- package/src/mcp-authorization.ts +211 -0
- package/src/mcp-protocol.ts +432 -133
- package/src/mcp.ts +204 -90
- package/src/named-credentials.ts +74 -0
- package/src/oauth-request.ts +90 -0
- package/src/oauth-session.ts +258 -0
- package/src/ops.ts +56 -17
- package/src/resources/account.ts +6 -3
- package/src/resources/api-keys.ts +27 -7
- package/src/resources/definition-revisions.ts +207 -0
- package/src/resources/definitions.ts +122 -0
- package/src/resources/generate.ts +29 -6
- package/src/resources/generations.ts +9 -4
- package/src/resources/projects.ts +274 -41
- package/src/resources/targets.ts +378 -0
- package/src/schemas.ts +135 -62
- package/src/types.ts +2273 -322
- package/src/worker.ts +4 -4
- package/dist/resources/spec-revisions.d.ts +0 -47
- package/dist/resources/spec-revisions.d.ts.map +0 -1
- package/dist/resources/spec-revisions.js +0 -90
- package/src/resources/spec-revisions.ts +0 -150
package/dist/mcp-protocol.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* Spec: https://modelcontextprotocol.io/specification/2026-07-28
|
|
16
16
|
*/
|
|
17
17
|
import { dateKindOf, relativeDate } from "./dates.js";
|
|
18
|
+
import { docsReadTarget, resolveDocsContentUrl, searchConnectedGuides } from "./docs.js";
|
|
18
19
|
export const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
19
20
|
/** Revisions served. Legacy (initialize-handshake) revisions are not; an
|
|
20
21
|
* initialize request gets an error naming this list, as the spec asks of
|
|
@@ -28,6 +29,30 @@ export const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
|
|
|
28
29
|
* ask for less. Roughly 16k tokens: under every major client's own cap, so
|
|
29
30
|
* the agent sees this explanation instead of a mid-JSON chop. */
|
|
30
31
|
export const DEFAULT_MAX_RESULT_CHARS = 64_000;
|
|
32
|
+
/** Throw only from an application-owned credential resolver, before calling
|
|
33
|
+
* the API. The URL must show a sign-in/linking page, never a pre-authenticated
|
|
34
|
+
* resource, token or personal information. The page must verify the same user
|
|
35
|
+
* before linking. The runtime rechecks credentials on every subsequent call. */
|
|
36
|
+
export class McpAccountLinkRequired extends Error {
|
|
37
|
+
url;
|
|
38
|
+
constructor(url) {
|
|
39
|
+
super("Connect your API account in the browser to continue.");
|
|
40
|
+
this.name = "McpAccountLinkRequired";
|
|
41
|
+
try {
|
|
42
|
+
const parsed = new URL(url);
|
|
43
|
+
if (url.length > 2048 || url !== url.trim() || /[\u0000-\u0020\u007F"\\]/.test(url) || parsed.username || parsed.password || parsed.hash ||
|
|
44
|
+
!(parsed.protocol === "https:" || parsed.protocol === "http:" && ["127.0.0.1", "[::1]", "localhost"].includes(parsed.hostname)) ||
|
|
45
|
+
[...parsed.searchParams.keys()].some(key => /^(access_token|refresh_token|client_secret|api_key|password|authorization)$/i.test(key)))
|
|
46
|
+
throw new Error();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new Error("Provide a public HTTPS account-linking page without credentials or a fragment (HTTP loopback is allowed for development).");
|
|
50
|
+
}
|
|
51
|
+
this.url = url;
|
|
52
|
+
Object.freeze(this);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const API_LINK_INPUT = "typeship_api_account";
|
|
31
56
|
/** Does the operation take a file (multipart form or raw binary body)? */
|
|
32
57
|
export function isUploadOp(op) {
|
|
33
58
|
return op.bodyKind === "multipart" || op.bodyKind === "binary";
|
|
@@ -179,13 +204,45 @@ export async function handleRpc(server, incoming) {
|
|
|
179
204
|
if (args === null || typeof args !== "object" || Array.isArray(args)) {
|
|
180
205
|
return rpcError(id, -32602, "tools/call arguments must be an object", 400);
|
|
181
206
|
}
|
|
207
|
+
const responses = request.params?.inputResponses;
|
|
208
|
+
if (responses !== undefined && (responses === null || typeof responses !== "object" || Array.isArray(responses)))
|
|
209
|
+
return rpcError(id, -32602, "inputResponses must be an object", 400);
|
|
210
|
+
const response = responses && Object.hasOwn(responses, API_LINK_INPUT) ? responses[API_LINK_INPUT] : undefined;
|
|
211
|
+
if (response !== undefined) {
|
|
212
|
+
if (!response || typeof response !== "object" || Array.isArray(response) || !["accept", "decline", "cancel"].includes(response.action))
|
|
213
|
+
return rpcError(id, -32602, "Invalid API account-link response", 400);
|
|
214
|
+
if (response.action !== "accept") {
|
|
215
|
+
const cancelled = textError("API account linking was cancelled. No API request was made.", "ACCOUNT_LINK_CANCELLED");
|
|
216
|
+
return complete({ content: [{ type: "text", text: cancelled.text }], isError: true });
|
|
217
|
+
}
|
|
218
|
+
// A client acknowledgment is not authorization. Only a fresh lookup
|
|
219
|
+
// of the server's linked credentials can let the operation proceed.
|
|
220
|
+
}
|
|
182
221
|
const denied = server.beforeToolCall ? await server.beforeToolCall(name) : null;
|
|
183
222
|
if (denied)
|
|
184
223
|
return denied;
|
|
185
224
|
const started = Date.now();
|
|
186
|
-
|
|
225
|
+
let outcome;
|
|
226
|
+
try {
|
|
227
|
+
outcome = await server.callTool(name, args);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (!(error instanceof McpAccountLinkRequired))
|
|
231
|
+
throw error;
|
|
232
|
+
const caps = (request.params?._meta)[META_CLIENT_CAPS];
|
|
233
|
+
const urlMode = caps.elicitation?.url;
|
|
234
|
+
if (urlMode === null || typeof urlMode !== "object" || Array.isArray(urlMode)) {
|
|
235
|
+
const unsupported = textError("Connect your API account using an MCP client that supports browser account-linking prompts (URL-mode elicitation), then retry.", "ACCOUNT_LINK_REQUIRED");
|
|
236
|
+
return complete({ content: [{ type: "text", text: unsupported.text }], isError: true });
|
|
237
|
+
}
|
|
238
|
+
return { status: 200, message: { jsonrpc: "2.0", id, result: {
|
|
239
|
+
resultType: "input_required",
|
|
240
|
+
inputRequests: { [API_LINK_INPUT]: { method: "elicitation/create", params: { mode: "url", url: error.url, message: "Connect your API account in the browser to continue." } } },
|
|
241
|
+
_meta: { [META_SERVER_INFO]: server.serverInfo },
|
|
242
|
+
} } };
|
|
243
|
+
}
|
|
187
244
|
if (outcome === undefined)
|
|
188
|
-
return rpcError(id, -32602, "Unknown tool: " + name, 400);
|
|
245
|
+
return rpcError(id, -32602, server.unknownToolMessage?.(name) ?? "Unknown tool: " + name, 400);
|
|
189
246
|
if (server.afterToolCall)
|
|
190
247
|
await server.afterToolCall(name, outcome, Date.now() - started);
|
|
191
248
|
// A tool that declares an outputSchema MUST return structuredContent,
|
|
@@ -275,8 +332,18 @@ export function exampleFromSchema(schema, field = "value", depth = 0) {
|
|
|
275
332
|
return node.examples[0];
|
|
276
333
|
if (node.default !== undefined)
|
|
277
334
|
return node.default;
|
|
278
|
-
if (Array.isArray(node.enum) && node.enum.length > 0)
|
|
335
|
+
if (Array.isArray(node.enum) && node.enum.length > 0) {
|
|
336
|
+
if (typeof node.pattern === "string") {
|
|
337
|
+
try {
|
|
338
|
+
const pattern = new RegExp(node.pattern);
|
|
339
|
+
const matching = node.enum.find((value) => typeof value === "string" && pattern.test(value));
|
|
340
|
+
if (matching !== undefined)
|
|
341
|
+
return matching;
|
|
342
|
+
}
|
|
343
|
+
catch { /* malformed patterns are ignored for examples */ }
|
|
344
|
+
}
|
|
279
345
|
return node.enum.find((v) => v !== null) ?? node.enum[0];
|
|
346
|
+
}
|
|
280
347
|
const variants = (Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : null);
|
|
281
348
|
if (variants) {
|
|
282
349
|
const useful = variants.find((v) => v && typeof v === "object" && v.type !== "null") ?? variants[0];
|
|
@@ -316,7 +383,10 @@ export function exampleFromSchema(schema, field = "value", depth = 0) {
|
|
|
316
383
|
if (type === "string" || type === undefined) {
|
|
317
384
|
const format = typeof node.format === "string" ? node.format : "";
|
|
318
385
|
const lower = field.toLowerCase();
|
|
319
|
-
|
|
386
|
+
const min = typeof node.minLength === "number" ? node.minLength : 0;
|
|
387
|
+
const max = typeof node.maxLength === "number" ? node.maxLength : undefined;
|
|
388
|
+
const patterned = typeof node.pattern === "string" ? exampleMatchingPattern(node.pattern, min, max) : null;
|
|
389
|
+
let value = patterned ?? (format === "date-time" ? "2026-01-15T12:00:00Z"
|
|
320
390
|
: format === "date" ? "2026-01-15"
|
|
321
391
|
: format === "email" || lower.includes("email") ? "person@example.com"
|
|
322
392
|
: (format === "uri" || format === "url" || lower.endsWith("url")) && (lower.includes("webhook") || lower.includes("callback")) ? "https://example.com/webhook"
|
|
@@ -327,16 +397,47 @@ export function exampleFromSchema(schema, field = "value", depth = 0) {
|
|
|
327
397
|
: lower.includes("version") ? "1.0.0"
|
|
328
398
|
: /(^|_)id$|Id$/.test(field) ? (lower === "id" ? "id" : field.replace(/[_-]?id$/i, "")) + "_123"
|
|
329
399
|
: lower.includes("name") ? "example"
|
|
330
|
-
: "value";
|
|
331
|
-
const min = typeof node.minLength === "number" ? node.minLength : 0;
|
|
400
|
+
: "value");
|
|
332
401
|
while (value.length < min)
|
|
333
402
|
value += "x";
|
|
334
|
-
if (
|
|
335
|
-
value = value.slice(0,
|
|
403
|
+
if (max !== undefined)
|
|
404
|
+
value = value.slice(0, max);
|
|
336
405
|
return value;
|
|
337
406
|
}
|
|
338
407
|
return null;
|
|
339
408
|
}
|
|
409
|
+
/** A useful value for the common API-id pattern (`^agt_`, `^src_[a-z0-9]+$`).
|
|
410
|
+
* Full regex generation would be surprising and heavyweight; an anchored
|
|
411
|
+
* literal prefix plus ordinary id suffix covers the schemas that use a
|
|
412
|
+
* pattern to communicate a typed identifier. Every candidate is checked by
|
|
413
|
+
* the actual RegExp before it is returned. */
|
|
414
|
+
function exampleMatchingPattern(pattern, minLength, maxLength) {
|
|
415
|
+
let regex;
|
|
416
|
+
try {
|
|
417
|
+
regex = new RegExp(pattern);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
const match = /^\^((?:\\.|[A-Za-z0-9_-])+)/.exec(pattern);
|
|
423
|
+
const prefix = match?.[1]?.replace(/\\(.)/g, "$1") ?? "";
|
|
424
|
+
const fit = (candidate) => {
|
|
425
|
+
let value = candidate;
|
|
426
|
+
while (value.length < minLength)
|
|
427
|
+
value += "x";
|
|
428
|
+
if (maxLength !== undefined)
|
|
429
|
+
value = value.slice(0, maxLength);
|
|
430
|
+
return value;
|
|
431
|
+
};
|
|
432
|
+
const candidates = [prefix + "123", prefix + "example", prefix, "resource.method", "example.value", "example_123", "example", "value"];
|
|
433
|
+
for (const candidate of candidates) {
|
|
434
|
+
const value = fit(candidate);
|
|
435
|
+
regex.lastIndex = 0;
|
|
436
|
+
if (regex.test(value))
|
|
437
|
+
return value;
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
340
441
|
export function exampleArgumentsFromSchema(inputSchema) {
|
|
341
442
|
const value = exampleFromSchema(inputSchema, "arguments");
|
|
342
443
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
@@ -400,7 +501,7 @@ export const EXECUTE_TOOL = {
|
|
|
400
501
|
inputSchema: {
|
|
401
502
|
type: "object",
|
|
402
503
|
properties: {
|
|
403
|
-
operation: { type: "string", description: "Operation tool name
|
|
504
|
+
operation: { type: "string", description: "Operation tool name returned by search_docs" },
|
|
404
505
|
arguments: { type: "object", description: "Operation arguments keyed by parameter name" },
|
|
405
506
|
confirm: { type: "boolean", description: "Required and must be true for destructive operations. Omit for reads and ordinary writes." },
|
|
406
507
|
},
|
|
@@ -433,36 +534,23 @@ export function parseIncludeList(value) {
|
|
|
433
534
|
* three-tool "meta" shape (search_docs, read_docs, execute) that keeps huge
|
|
434
535
|
* APIs from flooding an agent's context. Deterministic order.
|
|
435
536
|
*/
|
|
436
|
-
export function toolDefinitions(ops, mode) {
|
|
537
|
+
export function toolDefinitions(ops, mode, omittedOps = []) {
|
|
437
538
|
if (mode === "meta") {
|
|
539
|
+
const execute = structuredClone(EXECUTE_TOOL);
|
|
540
|
+
const operation = execute.inputSchema.properties.operation;
|
|
541
|
+
if (ops[0])
|
|
542
|
+
operation.examples = [ops[0].tool];
|
|
543
|
+
const coverage = omittedOps.length > 0
|
|
544
|
+
? " Generated " + ops.length + " of " + (ops.length + omittedOps.length) + " operations; search_docs names operations omitted by the plan limit."
|
|
545
|
+
: "";
|
|
438
546
|
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." },
|
|
547
|
+
{ ...SEARCH_DOCS_TOOL, description: "Search this API's " + ops.length + " generated operations and, when a docs site is configured, its guides. Start here to find the operation you need." + coverage },
|
|
440
548
|
{ ...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
|
-
|
|
549
|
+
execute,
|
|
442
550
|
];
|
|
443
551
|
}
|
|
444
552
|
return [...ops.map(operationTool), SEARCH_DOCS_TOOL, READ_DOCS_TOOL];
|
|
445
553
|
}
|
|
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
554
|
/** Resolve an operation by tool name or dotted resource.method. */
|
|
467
555
|
export function findOperation(ops, wanted) {
|
|
468
556
|
return ops.find((o) => o.tool === wanted)
|
|
@@ -480,7 +568,16 @@ export function serverInstructions(input) {
|
|
|
480
568
|
parts.push(input.mode === "meta"
|
|
481
569
|
? 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
570
|
: 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.");
|
|
571
|
+
if (input.omittedOps?.length) {
|
|
572
|
+
const generated = input.generatedOperationCount ?? input.toolCount;
|
|
573
|
+
parts.push("Plan-limited generation: generated " + generated + " of " + (generated + input.omittedOps.length) + " operations. Omitted operations: " + input.omittedOps.map((op) => op.tool + " (" + op.httpMethod + " " + op.path + ")").join(", ") + ". Calling or searching for one returns PLAN_LIMIT with upgrade next_steps.");
|
|
574
|
+
}
|
|
483
575
|
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).");
|
|
576
|
+
if (input.referenceResolution)
|
|
577
|
+
parts.push("Reference arguments marked in their schema accept either an ID or an exact case-insensitive name, slug, key or email; the server resolves one match through the named list tool, reports multiple candidates, and never guesses fuzzily.");
|
|
578
|
+
if (input.identityTool && input.ops.some((op) => op.params.some((param) => param.type === "string" && param.resolve !== false && userShapedReference(param.name)))) {
|
|
579
|
+
parts.push("User-shaped reference arguments also accept \"me\", resolved through " + input.identityTool + ".");
|
|
580
|
+
}
|
|
484
581
|
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
582
|
parts.push("Errors carry error, code, message, status, the API's body and next_steps.");
|
|
486
583
|
if (input.authHint)
|
|
@@ -714,6 +811,175 @@ export function prepareCall(op, rawArgs, options = {}) {
|
|
|
714
811
|
return { ok: false, outcome: argumentsError(op, issues) };
|
|
715
812
|
return { ok: true, call: { args, fields, maxChars: options.maxChars ?? DEFAULT_MAX_RESULT_CHARS } };
|
|
716
813
|
}
|
|
814
|
+
function referenceError(code, message, argument, nextSteps, candidates) {
|
|
815
|
+
const structured = {
|
|
816
|
+
error: code === "REFERENCE_NOT_FOUND" ? "ReferenceNotFound" : code === "REFERENCE_AMBIGUOUS" ? "AmbiguousReference" : code === "REFERENCE_SCAN_LIMIT" ? "ReferenceScanLimit" : "ReferenceResolutionUnavailable",
|
|
817
|
+
code,
|
|
818
|
+
message,
|
|
819
|
+
argument,
|
|
820
|
+
...(candidates ? { candidates } : {}),
|
|
821
|
+
next_steps: nextSteps,
|
|
822
|
+
};
|
|
823
|
+
return { text: JSON.stringify(structured), isError: true, structured };
|
|
824
|
+
}
|
|
825
|
+
function outcomeValue(outcome) {
|
|
826
|
+
if (outcome.structured !== undefined)
|
|
827
|
+
return outcome.structured;
|
|
828
|
+
try {
|
|
829
|
+
return JSON.parse(outcome.text);
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
return undefined;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
function referencePage(value) {
|
|
836
|
+
if (Array.isArray(value)) {
|
|
837
|
+
return { items: value.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)), nextPage: null };
|
|
838
|
+
}
|
|
839
|
+
if (!value || typeof value !== "object")
|
|
840
|
+
return null;
|
|
841
|
+
const record = value;
|
|
842
|
+
const direct = Array.isArray(record.items) ? record.items : undefined;
|
|
843
|
+
const arrays = direct ? [direct] : Object.entries(record)
|
|
844
|
+
.filter(([name, entry]) => name !== "request_id" && name !== "requestId" && Array.isArray(entry))
|
|
845
|
+
.map(([, entry]) => entry);
|
|
846
|
+
if (arrays.length !== 1)
|
|
847
|
+
return null;
|
|
848
|
+
return {
|
|
849
|
+
items: arrays[0].filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)),
|
|
850
|
+
nextPage: record.nextPage && typeof record.nextPage === "object" && !Array.isArray(record.nextPage)
|
|
851
|
+
? record.nextPage
|
|
852
|
+
: null,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
function userShapedReference(name) {
|
|
856
|
+
const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/id$/, "");
|
|
857
|
+
return ["user", "assignee", "owner", "member", "actor", "creator", "account", "profile"].includes(normalized);
|
|
858
|
+
}
|
|
859
|
+
function referenceMatchLabel(fields) {
|
|
860
|
+
return fields.length === 1 ? fields[0] : fields.slice(0, -1).join(", ") + " or " + fields.at(-1);
|
|
861
|
+
}
|
|
862
|
+
function looksLikeIdentifier(value, resolver) {
|
|
863
|
+
if (/\s/.test(value))
|
|
864
|
+
return false;
|
|
865
|
+
if (resolver.idPattern !== undefined) {
|
|
866
|
+
try {
|
|
867
|
+
return new RegExp(resolver.idPattern).test(value);
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return /^\d+$/.test(value) ||
|
|
874
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) ||
|
|
875
|
+
/^[a-z][a-z0-9]*_[a-z0-9][a-z0-9_-]*$/i.test(value) ||
|
|
876
|
+
/^[A-Z][A-Z0-9]{1,9}-\d+$/.test(value) ||
|
|
877
|
+
/^[A-Za-z0-9_-]{20,}$/.test(value);
|
|
878
|
+
}
|
|
879
|
+
function putReferenceCache(cache, key, value) {
|
|
880
|
+
cache.set(key, value);
|
|
881
|
+
while (cache.size > 256)
|
|
882
|
+
cache.delete(cache.keys().next().value);
|
|
883
|
+
}
|
|
884
|
+
function candidateRecord(item, resolver) {
|
|
885
|
+
return Object.fromEntries([resolver.id, ...resolver.match]
|
|
886
|
+
.filter((name, index, all) => all.indexOf(name) === index && item[name] !== undefined)
|
|
887
|
+
.map((name) => [name, item[name]]));
|
|
888
|
+
}
|
|
889
|
+
/** Resolve every eligible reference after validation/coercion and before the
|
|
890
|
+
* requested API call. Matching is exact (case-insensitive for strings),
|
|
891
|
+
* never fuzzy. Zero matches fail before the requested API call. */
|
|
892
|
+
export async function resolveReferences(op, preparedArgs, options) {
|
|
893
|
+
const args = { ...preparedArgs };
|
|
894
|
+
const maxPages = Math.max(1, Math.floor(options.maxPages ?? 5));
|
|
895
|
+
for (const param of op.params) {
|
|
896
|
+
const raw = args[param.name];
|
|
897
|
+
if (typeof raw !== "string" || param.resolve === false)
|
|
898
|
+
continue;
|
|
899
|
+
const value = raw.trim();
|
|
900
|
+
if (value.toLowerCase() === "me" && userShapedReference(param.name) && options.identityTool) {
|
|
901
|
+
const identity = findOperation(options.ops, options.identityTool);
|
|
902
|
+
if (identity) {
|
|
903
|
+
const cacheKey = "me:" + identity.tool;
|
|
904
|
+
const cached = options.cache.get(cacheKey);
|
|
905
|
+
if (cached !== undefined) {
|
|
906
|
+
args[param.name] = cached;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
const outcome = await options.runOperation(identity, {});
|
|
910
|
+
if (outcome.isError)
|
|
911
|
+
return { ok: false, outcome };
|
|
912
|
+
const body = outcomeValue(outcome);
|
|
913
|
+
const id = body && typeof body === "object" && !Array.isArray(body) ? body.id : undefined;
|
|
914
|
+
if (typeof id !== "string" && typeof id !== "number") {
|
|
915
|
+
return { ok: false, outcome: referenceError("NOT_AVAILABLE", `${identity.tool} did not return a top-level id, so "me" cannot be resolved for ${param.name}.`, param.name, ["Pass the caller's exact ID instead."]) };
|
|
916
|
+
}
|
|
917
|
+
putReferenceCache(options.cache, cacheKey, id);
|
|
918
|
+
args[param.name] = id;
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
const resolver = param.resolve && typeof param.resolve === "object" ? param.resolve : undefined;
|
|
923
|
+
if (!resolver || looksLikeIdentifier(value, resolver))
|
|
924
|
+
continue;
|
|
925
|
+
const source = findOperation(options.ops, resolver.via);
|
|
926
|
+
if (!source)
|
|
927
|
+
continue;
|
|
928
|
+
const cacheKey = source.tool + ":" + resolver.id + ":" + resolver.match.join(",") + ":" + value.toLowerCase();
|
|
929
|
+
const cached = options.cache.get(cacheKey);
|
|
930
|
+
if (cached !== undefined) {
|
|
931
|
+
args[param.name] = cached;
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
const matches = new Map();
|
|
935
|
+
let pageArgs = {};
|
|
936
|
+
for (const sourceParam of source.params) {
|
|
937
|
+
if (args[sourceParam.name] !== undefined && sourceParam.name !== param.name)
|
|
938
|
+
pageArgs[sourceParam.name] = args[sourceParam.name];
|
|
939
|
+
}
|
|
940
|
+
if (resolver.filterParam)
|
|
941
|
+
pageArgs[resolver.filterParam] = value;
|
|
942
|
+
if (!hasOwnFieldsParam(source))
|
|
943
|
+
pageArgs.fields = [resolver.id, ...resolver.match];
|
|
944
|
+
let exhausted = false;
|
|
945
|
+
for (let pageNumber = 1; pageNumber <= maxPages; pageNumber++) {
|
|
946
|
+
const outcome = await options.runOperation(source, pageArgs);
|
|
947
|
+
if (outcome.isError)
|
|
948
|
+
return { ok: false, outcome };
|
|
949
|
+
const page = referencePage(outcomeValue(outcome));
|
|
950
|
+
if (!page) {
|
|
951
|
+
return { ok: false, outcome: referenceError("NOT_AVAILABLE", `${source.tool} did not return one recognizable item array, so ${param.name} cannot be resolved by name.`, param.name, ["Pass the exact ID instead.", `Check the resolver hint for ${source.tool}.`]) };
|
|
952
|
+
}
|
|
953
|
+
for (const item of page.items) {
|
|
954
|
+
const id = item[resolver.id];
|
|
955
|
+
if (typeof id !== "string" && typeof id !== "number")
|
|
956
|
+
continue;
|
|
957
|
+
const hit = resolver.match.some((field) => typeof item[field] === "string" && item[field].toLowerCase() === value.toLowerCase());
|
|
958
|
+
if (hit)
|
|
959
|
+
matches.set(typeof id + ":" + String(id), { id, item });
|
|
960
|
+
}
|
|
961
|
+
if (matches.size > 1) {
|
|
962
|
+
const candidates = [...matches.values()].map(({ item }) => candidateRecord(item, resolver));
|
|
963
|
+
return { ok: false, outcome: referenceError("REFERENCE_AMBIGUOUS", `${JSON.stringify(raw)} matches multiple candidates for ${param.name}; nothing was sent to ${op.tool}.`, param.name, ["Choose one candidate ID and call again."], candidates) };
|
|
964
|
+
}
|
|
965
|
+
if (!page.nextPage) {
|
|
966
|
+
exhausted = true;
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
pageArgs = { ...pageArgs, ...page.nextPage };
|
|
970
|
+
}
|
|
971
|
+
if (!exhausted) {
|
|
972
|
+
return { ok: false, outcome: referenceError("REFERENCE_SCAN_LIMIT", `${source.tool} still had more results after ${maxPages} pages, so ${JSON.stringify(raw)} could not be resolved unambiguously.`, param.name, ["Pass the exact ID instead.", `Narrow ${source.tool} with its filters, or declare a more selective resolver.`]) };
|
|
973
|
+
}
|
|
974
|
+
const match = [...matches.values()][0];
|
|
975
|
+
if (!match) {
|
|
976
|
+
return { ok: false, outcome: referenceError("REFERENCE_NOT_FOUND", `${JSON.stringify(raw)} did not exactly match any ${referenceMatchLabel(resolver.match)} from ${source.tool}; nothing was sent to ${op.tool}.`, param.name, [`Call ${source.tool} to choose an exact ${referenceMatchLabel(resolver.match)} or ID, then call again.`]) };
|
|
977
|
+
}
|
|
978
|
+
putReferenceCache(options.cache, cacheKey, match.id);
|
|
979
|
+
args[param.name] = match.id;
|
|
980
|
+
}
|
|
981
|
+
return { ok: true, args };
|
|
982
|
+
}
|
|
717
983
|
/** The isError result for bad arguments: one stable code, one issue per
|
|
718
984
|
* argument, and the way out. */
|
|
719
985
|
export function argumentsError(op, issues) {
|
|
@@ -786,12 +1052,17 @@ export function pageOutcome(items, nextPage, options = {}) {
|
|
|
786
1052
|
const fields = options.fields ?? null;
|
|
787
1053
|
const maxChars = options.maxChars ?? DEFAULT_MAX_RESULT_CHARS;
|
|
788
1054
|
const shown = projectFields(items, fields);
|
|
789
|
-
const full = {
|
|
1055
|
+
const full = {
|
|
1056
|
+
items: shown,
|
|
1057
|
+
hasMore: nextPage !== null,
|
|
1058
|
+
...(nextPage !== null ? { nextPage } : {}),
|
|
1059
|
+
...(options.requestId ? { request_id: options.requestId } : {}),
|
|
1060
|
+
};
|
|
790
1061
|
const text = JSON.stringify(full);
|
|
791
1062
|
if (text.length <= maxChars) {
|
|
792
1063
|
return { text, isError: false, structured: full };
|
|
793
1064
|
}
|
|
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;
|
|
1065
|
+
const overhead = JSON.stringify({ items: [], hasMore: true, nextPage: nextPage ?? {}, ...(options.requestId ? { request_id: options.requestId } : {}), truncated: { omitted: 0, of: 0, reason: "x".repeat(160), next_steps: ["x".repeat(220), "x".repeat(120)] } }).length;
|
|
795
1066
|
const k = itemsThatFit(shown, Math.max(0, maxChars - overhead));
|
|
796
1067
|
const omitted = shown.length - k;
|
|
797
1068
|
const pg = options.pagination;
|
|
@@ -818,6 +1089,7 @@ export function pageOutcome(items, nextPage, options = {}) {
|
|
|
818
1089
|
items: shown.slice(0, k),
|
|
819
1090
|
hasMore: resume !== null ? true : nextPage !== null,
|
|
820
1091
|
...(resume !== null ? { nextPage: resume } : nextPage !== null ? { nextPage } : {}),
|
|
1092
|
+
...(options.requestId ? { request_id: options.requestId } : {}),
|
|
821
1093
|
truncated: {
|
|
822
1094
|
omitted,
|
|
823
1095
|
of: shown.length,
|
|
@@ -961,6 +1233,7 @@ export function classifyError(error, context = {}) {
|
|
|
961
1233
|
return { code: "NETWORK_ERROR", nextSteps: ["The API could not be reached (network, DNS, TLS or timeout). Retry once with backoff; do not loop."] };
|
|
962
1234
|
}
|
|
963
1235
|
const status = typeof e.status === "number" ? e.status : 0;
|
|
1236
|
+
const body = e.body;
|
|
964
1237
|
const auth = context.authHint ? context.authHint.trim().replace(/[.]?$/, ".") : null;
|
|
965
1238
|
if (status === 401) {
|
|
966
1239
|
return context.hadCredential
|
|
@@ -977,6 +1250,15 @@ export function classifyError(error, context = {}) {
|
|
|
977
1250
|
const retryAfter = extractRetryAfter(e);
|
|
978
1251
|
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
1252
|
}
|
|
1253
|
+
if (status === 422 && body?.errors?.[0]?.code === "spec_error") {
|
|
1254
|
+
return {
|
|
1255
|
+
code: "SPEC_INVALID",
|
|
1256
|
+
nextSteps: [
|
|
1257
|
+
"The API rejected the spec it was given; its error message names the invalid part.",
|
|
1258
|
+
context.docsUrl ? "Use search_docs with the error message, fix the spec, then call again." : "Fix the spec, then call again.",
|
|
1259
|
+
],
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
980
1262
|
if (status === 400 || status === 409 || status === 413 || status === 422) {
|
|
981
1263
|
return { code: "INVALID_REQUEST", nextSteps: ["Read body for the field the API named, fix that argument and call again."] };
|
|
982
1264
|
}
|
|
@@ -1003,11 +1285,16 @@ function notFoundNextSteps(message, body) {
|
|
|
1003
1285
|
export function errorOutcome(error, context = {}) {
|
|
1004
1286
|
const e = error;
|
|
1005
1287
|
const { code, nextSteps } = classifyError(error, context);
|
|
1288
|
+
const bodyRequestId = e?.body && typeof e.body === "object" && !Array.isArray(e.body)
|
|
1289
|
+
? e.body.request_id ?? e.body.requestId
|
|
1290
|
+
: undefined;
|
|
1291
|
+
const requestId = e?.response?.requestId ?? (typeof bodyRequestId === "string" ? bodyRequestId : undefined);
|
|
1006
1292
|
const structured = {
|
|
1007
1293
|
error: e?.name ?? "Error",
|
|
1008
1294
|
code,
|
|
1009
1295
|
message: e?.message,
|
|
1010
1296
|
...(typeof e?.status === "number" ? { status: e.status } : {}),
|
|
1297
|
+
...(requestId ? { request_id: requestId } : {}),
|
|
1011
1298
|
...(e?.body !== undefined ? { body: e.body } : {}),
|
|
1012
1299
|
...(context.docsUrl ? { docs_url: context.docsUrl } : {}),
|
|
1013
1300
|
next_steps: nextSteps,
|
|
@@ -1018,32 +1305,29 @@ export function textError(text, code = "CALL_FAILED", nextSteps = []) {
|
|
|
1018
1305
|
const structured = { error: "Error", code, message: text, next_steps: nextSteps };
|
|
1019
1306
|
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1020
1307
|
}
|
|
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
1308
|
async function fetchDocs(source, pathOrFile) {
|
|
1044
|
-
const url =
|
|
1309
|
+
const url = resolveDocsContentUrl(source.docsUrl(), source.docsIndexUrl?.() ?? null, pathOrFile);
|
|
1045
1310
|
return url === null ? null : source.fetchText(url);
|
|
1046
1311
|
}
|
|
1312
|
+
function coverageText(source) {
|
|
1313
|
+
const omitted = source.omittedOps ?? [];
|
|
1314
|
+
const generated = source.generatedOperationCount ?? source.ops.length;
|
|
1315
|
+
return omitted.length > 0
|
|
1316
|
+
? "Coverage: generated " + generated + " of " + (generated + omitted.length) + " operations. Omitted by the plan limit: " + omitted.map((op) => op.tool + " (" + op.httpMethod + " " + op.path + ")").join(", ") + "."
|
|
1317
|
+
: null;
|
|
1318
|
+
}
|
|
1319
|
+
function omittedPlanLimit(ops, requested) {
|
|
1320
|
+
const structured = {
|
|
1321
|
+
error: "PlanLimitError",
|
|
1322
|
+
code: "PLAN_LIMIT",
|
|
1323
|
+
message: requested
|
|
1324
|
+
? "The operation " + requested + " exists in the API Definition but was omitted from this generated package by its plan limit."
|
|
1325
|
+
: "Matching operations exist in the API Definition but were omitted from this generated package by its plan limit.",
|
|
1326
|
+
omitted_operations: ops.map((op) => ({ tool: op.tool, method: op.httpMethod, path: op.path })),
|
|
1327
|
+
next_steps: ["Upgrade at https://typeship.dev/pricing and regenerate the package without the operation cap.", "Do not invent or retry an omitted operation against this generated package."],
|
|
1328
|
+
};
|
|
1329
|
+
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1330
|
+
}
|
|
1047
1331
|
export function referenceText(op) {
|
|
1048
1332
|
const safety = operationSafety(op);
|
|
1049
1333
|
const example = op.exampleArguments ?? exampleArgumentsFromSchema(op.inputSchema);
|
|
@@ -1114,12 +1398,18 @@ export function searchScore(op, query) {
|
|
|
1114
1398
|
return score;
|
|
1115
1399
|
}
|
|
1116
1400
|
export async function docsSearch(source, query, page = 1) {
|
|
1117
|
-
const term = query.toLowerCase();
|
|
1118
1401
|
const sections = [];
|
|
1119
1402
|
const ranked = source.ops
|
|
1120
1403
|
.map((op) => ({ op, score: searchScore(op, query) }))
|
|
1121
1404
|
.filter((r) => r.score > 0)
|
|
1122
1405
|
.sort((a, b) => b.score - a.score || a.op.tool.localeCompare(b.op.tool));
|
|
1406
|
+
const omittedRanked = (source.omittedOps ?? [])
|
|
1407
|
+
.map((op) => ({ op, score: searchScore(op, query) }))
|
|
1408
|
+
.filter((result) => result.score > 0)
|
|
1409
|
+
.sort((a, b) => b.score - a.score || a.op.tool.localeCompare(b.op.tool));
|
|
1410
|
+
const exactOmitted = findOperation(source.omittedOps ?? [], query);
|
|
1411
|
+
if (exactOmitted)
|
|
1412
|
+
return omittedPlanLimit([exactOmitted], exactOmitted.tool);
|
|
1123
1413
|
const pageIndex = Math.max(1, Math.floor(page)) - 1;
|
|
1124
1414
|
const slice = ranked.slice(pageIndex * SEARCH_PAGE_SIZE, (pageIndex + 1) * SEARCH_PAGE_SIZE);
|
|
1125
1415
|
if (slice.length > 0) {
|
|
@@ -1131,47 +1421,56 @@ export async function docsSearch(source, query, page = 1) {
|
|
|
1131
1421
|
else if (ranked.length > 0) {
|
|
1132
1422
|
sections.push("No reference matches on page " + (pageIndex + 1) + "; there are " + Math.ceil(ranked.length / SEARCH_PAGE_SIZE) + " pages.");
|
|
1133
1423
|
}
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1424
|
+
const { guides, status } = await searchConnectedGuides(source.docsUrl(), source.docsIndexUrl?.() ?? null, (path) => fetchDocs(source, path), query);
|
|
1425
|
+
const guideSlice = guides.slice(pageIndex * SEARCH_PAGE_SIZE, (pageIndex + 1) * SEARCH_PAGE_SIZE);
|
|
1426
|
+
const proseMatchCount = guides.length;
|
|
1427
|
+
if (guideSlice.length > 0) {
|
|
1428
|
+
sections.push("Guide matches (best first, " + guides.length + " pages):\n" + guideSlice.map((match) => "- [" + match.title + (match.section ? " / " + match.section : "") + "](" + match.url + "): " + match.excerpt + "\n read_docs " + JSON.stringify({ page: match.url })).join("\n"));
|
|
1429
|
+
}
|
|
1430
|
+
if (status === "unavailable")
|
|
1431
|
+
sections.push("The docs site is unavailable; the API reference was still searched.");
|
|
1432
|
+
const structured = {
|
|
1433
|
+
schema_version: "1", query, page: pageIndex + 1,
|
|
1434
|
+
reference: slice.map(({ op }) => ({ tool: op.tool, method: op.httpMethod, path: op.path, ...(op.summary ? { summary: op.summary } : {}), read_tool: { name: "read_docs", arguments: { page: op.tool } } })),
|
|
1435
|
+
guides: guideSlice.map((match) => ({ ...match, read_tool: { name: "read_docs", arguments: { page: match.url } } })),
|
|
1436
|
+
totals: { reference: ranked.length, guides: guides.length }, guides_status: status,
|
|
1437
|
+
};
|
|
1438
|
+
if (omittedRanked.length > 0 && ranked.length === 0 && proseMatchCount === 0) {
|
|
1439
|
+
return omittedPlanLimit(omittedRanked.map((result) => result.op));
|
|
1147
1440
|
}
|
|
1441
|
+
if (omittedRanked.length > 0) {
|
|
1442
|
+
sections.push("Operations omitted by the plan limit:\n" + omittedRanked.slice(0, SEARCH_PAGE_SIZE).map((result) => "- " + result.op.tool + ": " + (result.op.summary ?? result.op.httpMethod + " " + result.op.path)).join("\n"));
|
|
1443
|
+
}
|
|
1444
|
+
const coverage = coverageText(source);
|
|
1148
1445
|
if (sections.length === 0) {
|
|
1149
1446
|
return {
|
|
1150
|
-
text: "No matches for: " + query + (source.docsUrl() === null ? " (
|
|
1447
|
+
text: (coverage ? coverage + "\n\n" : "") + "No matches for: " + query + (source.docsUrl() === null && (source.docsIndexUrl?.() ?? null) === null ? " (a docs URL was not provided at generate time; only the API reference was searched)" : ""),
|
|
1151
1448
|
isError: false,
|
|
1449
|
+
structured,
|
|
1152
1450
|
};
|
|
1153
1451
|
}
|
|
1154
|
-
return { text: sections.join("\n\n"), isError: false };
|
|
1452
|
+
return { text: [...(coverage ? [coverage] : []), ...sections].join("\n\n"), isError: false, structured };
|
|
1155
1453
|
}
|
|
1156
1454
|
export async function docsRead(source, page) {
|
|
1157
1455
|
const opMatch = findOperation(source.ops, page);
|
|
1456
|
+
const coverage = coverageText(source);
|
|
1158
1457
|
if (opMatch)
|
|
1159
|
-
return { text: referenceText(opMatch), isError: false };
|
|
1458
|
+
return { text: [...(coverage ? [coverage] : []), referenceText(opMatch)].join("\n\n"), isError: false };
|
|
1459
|
+
const omittedMatch = findOperation(source.omittedOps ?? [], page);
|
|
1460
|
+
if (omittedMatch)
|
|
1461
|
+
return omittedPlanLimit([omittedMatch], omittedMatch.tool);
|
|
1160
1462
|
let target = page;
|
|
1161
1463
|
if (!/^https?:\/\//.test(target)) {
|
|
1162
1464
|
const index = await fetchDocs(source, "llms.txt");
|
|
1163
|
-
|
|
1164
|
-
const hit = linked.find((u) => u.toLowerCase().includes(target.toLowerCase()));
|
|
1165
|
-
if (hit !== undefined)
|
|
1166
|
-
target = hit;
|
|
1465
|
+
target = docsReadTarget(index, source.docsUrl(), source.docsIndexUrl?.() ?? null, target);
|
|
1167
1466
|
}
|
|
1168
1467
|
const text = await fetchDocs(source, target);
|
|
1169
1468
|
if (text === null) {
|
|
1170
|
-
return textError(source.docsUrl() === null
|
|
1171
|
-
? "
|
|
1469
|
+
return textError(source.docsUrl() === null && (source.docsIndexUrl?.() ?? null) === null
|
|
1470
|
+
? "A docs URL was not provided at generate time, and no generated operation matches \"" + page + "\"."
|
|
1172
1471
|
: "Couldn't fetch \"" + page + "\". Use search_docs to find pages.", "NOT_FOUND", ["search_docs finds operations and guide pages."]);
|
|
1173
1472
|
}
|
|
1174
|
-
return { text, isError: false };
|
|
1473
|
+
return { text: [...(coverage ? [coverage] : []), text].join("\n\n"), isError: false };
|
|
1175
1474
|
}
|
|
1176
1475
|
/**
|
|
1177
1476
|
* Dispatch for the shared tools (search_docs, read_docs, execute); returns
|
|
@@ -1193,8 +1492,12 @@ export async function callSharedTool(name, args, source, runOperation) {
|
|
|
1193
1492
|
if (typeof args.operation !== "string")
|
|
1194
1493
|
return argumentsError({ tool: name }, [{ code: "MISSING_ARGUMENT", argument: "operation", message: "execute requires an operation name." }]);
|
|
1195
1494
|
const target = findOperation(source.ops, args.operation);
|
|
1196
|
-
if (!target)
|
|
1197
|
-
|
|
1495
|
+
if (!target) {
|
|
1496
|
+
const omitted = findOperation(source.omittedOps ?? [], args.operation);
|
|
1497
|
+
return omitted
|
|
1498
|
+
? omittedPlanLimit([omitted], omitted.tool)
|
|
1499
|
+
: textError("Unknown operation: " + args.operation + ".", "NOT_FOUND", ["search_docs finds operations by name, path or description."]);
|
|
1500
|
+
}
|
|
1198
1501
|
if (operationSafety(target) === "destructive" && args.confirm !== true) {
|
|
1199
1502
|
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
1503
|
}
|
|
@@ -1205,58 +1508,3 @@ export async function callSharedTool(name, args, source, runOperation) {
|
|
|
1205
1508
|
}
|
|
1206
1509
|
return undefined;
|
|
1207
1510
|
}
|
|
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
|
-
}
|