@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/src/mcp-protocol.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { dateKindOf, relativeDate } from "./dates.js";
|
|
19
|
+
import { docsReadTarget, resolveDocsContentUrl, searchConnectedGuides } from "./docs.js";
|
|
19
20
|
|
|
20
21
|
export const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
21
22
|
/** Revisions served. Legacy (initialize-handshake) revisions are not; an
|
|
@@ -76,6 +77,28 @@ export interface ToolOutcome {
|
|
|
76
77
|
content?: ContentBlock[];
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/** Throw only from an application-owned credential resolver, before calling
|
|
81
|
+
* the API. The URL must show a sign-in/linking page, never a pre-authenticated
|
|
82
|
+
* resource, token or personal information. The page must verify the same user
|
|
83
|
+
* before linking. The runtime rechecks credentials on every subsequent call. */
|
|
84
|
+
export class McpAccountLinkRequired extends Error {
|
|
85
|
+
readonly url: string;
|
|
86
|
+
constructor(url: string) {
|
|
87
|
+
super("Connect your API account in the browser to continue.");
|
|
88
|
+
this.name = "McpAccountLinkRequired";
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(url);
|
|
91
|
+
if (url.length > 2048 || url !== url.trim() || /[\u0000-\u0020\u007F"\\]/.test(url) || parsed.username || parsed.password || parsed.hash ||
|
|
92
|
+
!(parsed.protocol === "https:" || parsed.protocol === "http:" && ["127.0.0.1", "[::1]", "localhost"].includes(parsed.hostname)) ||
|
|
93
|
+
[...parsed.searchParams.keys()].some(key => /^(access_token|refresh_token|client_secret|api_key|password|authorization)$/i.test(key))) throw new Error();
|
|
94
|
+
} catch { throw new Error("Provide a public HTTPS account-linking page without credentials or a fragment (HTTP loopback is allowed for development)."); }
|
|
95
|
+
this.url = url;
|
|
96
|
+
Object.freeze(this);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const API_LINK_INPUT = "typeship_api_account";
|
|
101
|
+
|
|
79
102
|
/** The subset of an operation spec (ops.ts / the hosted manifest) the
|
|
80
103
|
* protocol layer reads. */
|
|
81
104
|
export interface OpLike {
|
|
@@ -92,7 +115,14 @@ export interface OpLike {
|
|
|
92
115
|
/** GraphQL ops accept a raw selection-set override. */
|
|
93
116
|
select: boolean;
|
|
94
117
|
graphql?: { kind: string };
|
|
95
|
-
params: {
|
|
118
|
+
params: {
|
|
119
|
+
name: string;
|
|
120
|
+
type: string;
|
|
121
|
+
required: boolean;
|
|
122
|
+
enum?: string[];
|
|
123
|
+
description?: string;
|
|
124
|
+
resolve?: false | ReferenceResolver;
|
|
125
|
+
}[];
|
|
96
126
|
inputSchema: Record<string, unknown>;
|
|
97
127
|
outputSchema?: Record<string, unknown>;
|
|
98
128
|
/** Canonical effect classification shared by generated docs, CLI, and MCP. */
|
|
@@ -109,6 +139,16 @@ export interface OpLike {
|
|
|
109
139
|
bodyKind?: string | null;
|
|
110
140
|
}
|
|
111
141
|
|
|
142
|
+
/** Fully proved lookup metadata carried in ops.ts / the hosted manifest. */
|
|
143
|
+
export interface ReferenceResolver {
|
|
144
|
+
via: string;
|
|
145
|
+
match: string[];
|
|
146
|
+
id: string;
|
|
147
|
+
idPattern?: string;
|
|
148
|
+
filterParam?: string;
|
|
149
|
+
inferred?: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
112
152
|
/** Does the operation take a file (multipart form or raw binary body)? */
|
|
113
153
|
export function isUploadOp(op: OpLike): boolean {
|
|
114
154
|
return op.bodyKind === "multipart" || op.bodyKind === "binary";
|
|
@@ -151,9 +191,14 @@ export interface McpServer {
|
|
|
151
191
|
listTools(): ToolDefinition[];
|
|
152
192
|
/**
|
|
153
193
|
* Run a tool. Return undefined for an unknown tool (the client gets
|
|
154
|
-
* -32602), a ToolOutcome otherwise.
|
|
194
|
+
* -32602), a ToolOutcome otherwise. McpAccountLinkRequired requests a
|
|
195
|
+
* browser interaction; other exceptions yield -32603.
|
|
155
196
|
*/
|
|
156
197
|
callTool(name: string, args: Record<string, unknown>): Promise<ToolOutcome | undefined>;
|
|
198
|
+
/** Optional actionable wording for an unknown tool name. The protocol
|
|
199
|
+
* code remains -32602; compact tool surfaces can explain how to call an
|
|
200
|
+
* operation without pretending that operation is absent. */
|
|
201
|
+
unknownToolMessage?(name: string): string;
|
|
157
202
|
/** Called before a tool runs; a non-null outcome is sent instead
|
|
158
203
|
* (rate limiting, entitlement). */
|
|
159
204
|
beforeToolCall?(name: string): Promise<RpcOutcome | null> | RpcOutcome | null;
|
|
@@ -307,11 +352,38 @@ export async function handleRpc(server: McpServer, incoming: unknown): Promise<R
|
|
|
307
352
|
if (args === null || typeof args !== "object" || Array.isArray(args)) {
|
|
308
353
|
return rpcError(id, -32602, "tools/call arguments must be an object", 400);
|
|
309
354
|
}
|
|
355
|
+
const responses = request.params?.inputResponses;
|
|
356
|
+
if (responses !== undefined && (responses === null || typeof responses !== "object" || Array.isArray(responses))) return rpcError(id, -32602, "inputResponses must be an object", 400);
|
|
357
|
+
const response = responses && Object.hasOwn(responses, API_LINK_INPUT) ? (responses as Record<string, unknown>)[API_LINK_INPUT] : undefined;
|
|
358
|
+
if (response !== undefined) {
|
|
359
|
+
if (!response || typeof response !== "object" || Array.isArray(response) || !["accept", "decline", "cancel"].includes((response as { action: string }).action)) return rpcError(id, -32602, "Invalid API account-link response", 400);
|
|
360
|
+
if ((response as { action: string }).action !== "accept") {
|
|
361
|
+
const cancelled = textError("API account linking was cancelled. No API request was made.", "ACCOUNT_LINK_CANCELLED");
|
|
362
|
+
return complete({ content: [{ type: "text", text: cancelled.text }], isError: true });
|
|
363
|
+
}
|
|
364
|
+
// A client acknowledgment is not authorization. Only a fresh lookup
|
|
365
|
+
// of the server's linked credentials can let the operation proceed.
|
|
366
|
+
}
|
|
310
367
|
const denied = server.beforeToolCall ? await server.beforeToolCall(name) : null;
|
|
311
368
|
if (denied) return denied;
|
|
312
369
|
const started = Date.now();
|
|
313
|
-
|
|
314
|
-
|
|
370
|
+
let outcome: ToolOutcome | undefined;
|
|
371
|
+
try { outcome = await server.callTool(name, args); }
|
|
372
|
+
catch (error) {
|
|
373
|
+
if (!(error instanceof McpAccountLinkRequired)) throw error;
|
|
374
|
+
const caps = (request.params?._meta as Record<string, unknown>)[META_CLIENT_CAPS] as { elicitation?: { url?: unknown } };
|
|
375
|
+
const urlMode = caps.elicitation?.url;
|
|
376
|
+
if (urlMode === null || typeof urlMode !== "object" || Array.isArray(urlMode)) {
|
|
377
|
+
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");
|
|
378
|
+
return complete({ content: [{ type: "text", text: unsupported.text }], isError: true });
|
|
379
|
+
}
|
|
380
|
+
return { status: 200, message: { jsonrpc: "2.0", id, result: {
|
|
381
|
+
resultType: "input_required",
|
|
382
|
+
inputRequests: { [API_LINK_INPUT]: { method: "elicitation/create", params: { mode: "url", url: error.url, message: "Connect your API account in the browser to continue." } } },
|
|
383
|
+
_meta: { [META_SERVER_INFO]: server.serverInfo },
|
|
384
|
+
} } };
|
|
385
|
+
}
|
|
386
|
+
if (outcome === undefined) return rpcError(id, -32602, server.unknownToolMessage?.(name) ?? "Unknown tool: " + name, 400);
|
|
315
387
|
if (server.afterToolCall) await server.afterToolCall(name, outcome, Date.now() - started);
|
|
316
388
|
// A tool that declares an outputSchema MUST return structuredContent,
|
|
317
389
|
// and clients enforce it: a successful result without it fails the
|
|
@@ -396,7 +468,16 @@ export function exampleFromSchema(schema: unknown, field = "value", depth = 0):
|
|
|
396
468
|
if (node.example !== undefined) return node.example;
|
|
397
469
|
if (Array.isArray(node.examples) && node.examples.length > 0) return node.examples[0];
|
|
398
470
|
if (node.default !== undefined) return node.default;
|
|
399
|
-
if (Array.isArray(node.enum) && node.enum.length > 0)
|
|
471
|
+
if (Array.isArray(node.enum) && node.enum.length > 0) {
|
|
472
|
+
if (typeof node.pattern === "string") {
|
|
473
|
+
try {
|
|
474
|
+
const pattern = new RegExp(node.pattern);
|
|
475
|
+
const matching = node.enum.find((value) => typeof value === "string" && pattern.test(value));
|
|
476
|
+
if (matching !== undefined) return matching;
|
|
477
|
+
} catch { /* malformed patterns are ignored for examples */ }
|
|
478
|
+
}
|
|
479
|
+
return node.enum.find((v) => v !== null) ?? node.enum[0];
|
|
480
|
+
}
|
|
400
481
|
const variants = (Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : null) as unknown[] | null;
|
|
401
482
|
if (variants) {
|
|
402
483
|
const useful = variants.find((v) => v && typeof v === "object" && (v as Record<string, unknown>).type !== "null") ?? variants[0];
|
|
@@ -432,7 +513,10 @@ export function exampleFromSchema(schema: unknown, field = "value", depth = 0):
|
|
|
432
513
|
if (type === "string" || type === undefined) {
|
|
433
514
|
const format = typeof node.format === "string" ? node.format : "";
|
|
434
515
|
const lower = field.toLowerCase();
|
|
435
|
-
|
|
516
|
+
const min = typeof node.minLength === "number" ? node.minLength : 0;
|
|
517
|
+
const max = typeof node.maxLength === "number" ? node.maxLength : undefined;
|
|
518
|
+
const patterned = typeof node.pattern === "string" ? exampleMatchingPattern(node.pattern, min, max) : null;
|
|
519
|
+
let value = patterned ?? (format === "date-time" ? "2026-01-15T12:00:00Z"
|
|
436
520
|
: format === "date" ? "2026-01-15"
|
|
437
521
|
: format === "email" || lower.includes("email") ? "person@example.com"
|
|
438
522
|
: (format === "uri" || format === "url" || lower.endsWith("url")) && (lower.includes("webhook") || lower.includes("callback")) ? "https://example.com/webhook"
|
|
@@ -443,15 +527,39 @@ export function exampleFromSchema(schema: unknown, field = "value", depth = 0):
|
|
|
443
527
|
: lower.includes("version") ? "1.0.0"
|
|
444
528
|
: /(^|_)id$|Id$/.test(field) ? (lower === "id" ? "id" : field.replace(/[_-]?id$/i, "")) + "_123"
|
|
445
529
|
: lower.includes("name") ? "example"
|
|
446
|
-
: "value";
|
|
447
|
-
const min = typeof node.minLength === "number" ? node.minLength : 0;
|
|
530
|
+
: "value");
|
|
448
531
|
while (value.length < min) value += "x";
|
|
449
|
-
if (
|
|
532
|
+
if (max !== undefined) value = value.slice(0, max);
|
|
450
533
|
return value;
|
|
451
534
|
}
|
|
452
535
|
return null;
|
|
453
536
|
}
|
|
454
537
|
|
|
538
|
+
/** A useful value for the common API-id pattern (`^agt_`, `^src_[a-z0-9]+$`).
|
|
539
|
+
* Full regex generation would be surprising and heavyweight; an anchored
|
|
540
|
+
* literal prefix plus ordinary id suffix covers the schemas that use a
|
|
541
|
+
* pattern to communicate a typed identifier. Every candidate is checked by
|
|
542
|
+
* the actual RegExp before it is returned. */
|
|
543
|
+
function exampleMatchingPattern(pattern: string, minLength: number, maxLength?: number): string | null {
|
|
544
|
+
let regex: RegExp;
|
|
545
|
+
try { regex = new RegExp(pattern); } catch { return null; }
|
|
546
|
+
const match = /^\^((?:\\.|[A-Za-z0-9_-])+)/.exec(pattern);
|
|
547
|
+
const prefix = match?.[1]?.replace(/\\(.)/g, "$1") ?? "";
|
|
548
|
+
const fit = (candidate: string): string => {
|
|
549
|
+
let value = candidate;
|
|
550
|
+
while (value.length < minLength) value += "x";
|
|
551
|
+
if (maxLength !== undefined) value = value.slice(0, maxLength);
|
|
552
|
+
return value;
|
|
553
|
+
};
|
|
554
|
+
const candidates = [prefix + "123", prefix + "example", prefix, "resource.method", "example.value", "example_123", "example", "value"];
|
|
555
|
+
for (const candidate of candidates) {
|
|
556
|
+
const value = fit(candidate);
|
|
557
|
+
regex.lastIndex = 0;
|
|
558
|
+
if (regex.test(value)) return value;
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
455
563
|
export function exampleArgumentsFromSchema(inputSchema: Record<string, unknown>): Record<string, unknown> {
|
|
456
564
|
const value = exampleFromSchema(inputSchema, "arguments");
|
|
457
565
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
@@ -523,7 +631,7 @@ export const EXECUTE_TOOL: ToolDefinition = {
|
|
|
523
631
|
inputSchema: {
|
|
524
632
|
type: "object",
|
|
525
633
|
properties: {
|
|
526
|
-
operation: { type: "string", description: "Operation tool name
|
|
634
|
+
operation: { type: "string", description: "Operation tool name returned by search_docs" },
|
|
527
635
|
arguments: { type: "object", description: "Operation arguments keyed by parameter name" },
|
|
528
636
|
confirm: { type: "boolean", description: "Required and must be true for destructive operations. Omit for reads and ordinary writes." },
|
|
529
637
|
},
|
|
@@ -569,42 +677,23 @@ export function parseIncludeList(value: string | undefined | null): string[] | u
|
|
|
569
677
|
* three-tool "meta" shape (search_docs, read_docs, execute) that keeps huge
|
|
570
678
|
* APIs from flooding an agent's context. Deterministic order.
|
|
571
679
|
*/
|
|
572
|
-
export function toolDefinitions(ops: OpLike[], mode: "operations" | "meta"): ToolDefinition[] {
|
|
680
|
+
export function toolDefinitions(ops: OpLike[], mode: "operations" | "meta", omittedOps: OpLike[] = []): ToolDefinition[] {
|
|
573
681
|
if (mode === "meta") {
|
|
682
|
+
const execute = structuredClone(EXECUTE_TOOL);
|
|
683
|
+
const operation = (execute.inputSchema.properties as Record<string, Record<string, unknown>>).operation!;
|
|
684
|
+
if (ops[0]) operation.examples = [ops[0].tool];
|
|
685
|
+
const coverage = omittedOps.length > 0
|
|
686
|
+
? " Generated " + ops.length + " of " + (ops.length + omittedOps.length) + " operations; search_docs names operations omitted by the plan limit."
|
|
687
|
+
: "";
|
|
574
688
|
return [
|
|
575
|
-
{ ...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." },
|
|
689
|
+
{ ...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 },
|
|
576
690
|
{ ...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." },
|
|
577
|
-
|
|
691
|
+
execute,
|
|
578
692
|
];
|
|
579
693
|
}
|
|
580
694
|
return [...ops.map(operationTool), SEARCH_DOCS_TOOL, READ_DOCS_TOOL];
|
|
581
695
|
}
|
|
582
696
|
|
|
583
|
-
/** How big a tools/list is, as agents pay for it. Characters of JSON and a
|
|
584
|
-
* rough token count (one token per four characters, the usual estimate for
|
|
585
|
-
* JSON). Generation and `<bin> mcp` report it. */
|
|
586
|
-
export function toolsListSize(tools: ToolDefinition[]): { tools: number; chars: number; approxTokens: number } {
|
|
587
|
-
const chars = JSON.stringify(tools).length;
|
|
588
|
-
return { tools: tools.length, chars, approxTokens: Math.round(chars / 4) };
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/** Keep automatic per-operation discovery under roughly 10k tokens. The
|
|
592
|
-
* schema, not merely the operation count, determines what an agent pays. */
|
|
593
|
-
export const MCP_AUTO_MAX_TOOLS_LIST_CHARS = 40_000;
|
|
594
|
-
|
|
595
|
-
export function resolveToolMode(
|
|
596
|
-
ops: OpLike[],
|
|
597
|
-
requested: "operations" | "meta" | "auto" = "auto",
|
|
598
|
-
): { mode: "operations" | "meta"; tools: number; chars: number; approxTokens: number } {
|
|
599
|
-
if (requested === "operations" || requested === "meta") {
|
|
600
|
-
return { mode: requested, ...toolsListSize(toolDefinitions(ops, requested)) };
|
|
601
|
-
}
|
|
602
|
-
const operations = toolsListSize(toolDefinitions(ops, "operations"));
|
|
603
|
-
const mode = ops.length > 100 || operations.chars > MCP_AUTO_MAX_TOOLS_LIST_CHARS ? "meta" : "operations";
|
|
604
|
-
return mode === "operations"
|
|
605
|
-
? { mode, ...operations }
|
|
606
|
-
: { mode, ...toolsListSize(toolDefinitions(ops, mode)) };
|
|
607
|
-
}
|
|
608
697
|
|
|
609
698
|
/** Resolve an operation by tool name or dotted resource.method. */
|
|
610
699
|
export function findOperation(ops: OpLike[], wanted: string): OpLike | undefined {
|
|
@@ -621,8 +710,14 @@ export function missingArguments(op: OpLike, args: Record<string, unknown>): str
|
|
|
621
710
|
|
|
622
711
|
export interface InstructionsInput {
|
|
623
712
|
title: string;
|
|
713
|
+
/** Callable operations, used to advertise only capabilities the surface has. */
|
|
714
|
+
ops: OpLike[];
|
|
624
715
|
/** Operations the server serves (after read-only / include filtering). */
|
|
625
716
|
toolCount: number;
|
|
717
|
+
/** Operations present in the Definition but absent from this capped generation. */
|
|
718
|
+
omittedOps?: OpLike[];
|
|
719
|
+
/** Count generated before read-only or include filters narrow this server. */
|
|
720
|
+
generatedOperationCount?: number;
|
|
626
721
|
mode: "operations" | "meta";
|
|
627
722
|
readOnly?: boolean;
|
|
628
723
|
/** Write operations hidden by read-only mode. */
|
|
@@ -631,6 +726,8 @@ export interface InstructionsInput {
|
|
|
631
726
|
authHint?: string | null;
|
|
632
727
|
/** The tool that returns the caller (the CLI's whoami target), when the API has one. */
|
|
633
728
|
identityTool?: string | null;
|
|
729
|
+
/** At least one argument accepts an exact human reference as well as an ID. */
|
|
730
|
+
referenceResolution?: boolean;
|
|
634
731
|
/** Upload operations are exposed (local server): their file arguments take paths. */
|
|
635
732
|
uploads?: boolean;
|
|
636
733
|
/** Project-supplied text, appended verbatim. */
|
|
@@ -645,7 +742,15 @@ export function serverInstructions(input: InstructionsInput): string {
|
|
|
645
742
|
parts.push(input.mode === "meta"
|
|
646
743
|
? 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."
|
|
647
744
|
: 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.");
|
|
745
|
+
if (input.omittedOps?.length) {
|
|
746
|
+
const generated = input.generatedOperationCount ?? input.toolCount;
|
|
747
|
+
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.");
|
|
748
|
+
}
|
|
648
749
|
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).");
|
|
750
|
+
if (input.referenceResolution) 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.");
|
|
751
|
+
if (input.identityTool && input.ops.some((op) => op.params.some((param) => param.type === "string" && param.resolve !== false && userShapedReference(param.name)))) {
|
|
752
|
+
parts.push("User-shaped reference arguments also accept \"me\", resolved through " + input.identityTool + ".");
|
|
753
|
+
}
|
|
649
754
|
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.");
|
|
650
755
|
parts.push("Errors carry error, code, message, status, the API's body and next_steps.");
|
|
651
756
|
if (input.authHint) parts.push(input.authHint.trim().replace(/[.]?$/, "."));
|
|
@@ -873,6 +978,194 @@ export function prepareCall(
|
|
|
873
978
|
return { ok: true, call: { args, fields, maxChars: options.maxChars ?? DEFAULT_MAX_RESULT_CHARS } };
|
|
874
979
|
}
|
|
875
980
|
|
|
981
|
+
/** One caller/session's resolved names. Hosted transports must scope this
|
|
982
|
+
* map by caller credential; generated stdio servers naturally have one map
|
|
983
|
+
* per process. */
|
|
984
|
+
export type ReferenceCache = Map<string, string | number>;
|
|
985
|
+
|
|
986
|
+
export interface ResolveReferencesOptions {
|
|
987
|
+
/** Operations available to act as declared/inferred resolvers. */
|
|
988
|
+
ops: OpLike[];
|
|
989
|
+
/** Operation returning the authenticated caller, for user-shaped "me". */
|
|
990
|
+
identityTool?: string | null;
|
|
991
|
+
cache: ReferenceCache;
|
|
992
|
+
/** Raw operation runner: validates and calls, but does not resolve again. */
|
|
993
|
+
runOperation(op: OpLike, args: Record<string, unknown>): Promise<ToolOutcome>;
|
|
994
|
+
/** Hard bound for list scans. Default 5. */
|
|
995
|
+
maxPages?: number;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
export type ResolveReferencesResult =
|
|
999
|
+
| { ok: true; args: Record<string, unknown> }
|
|
1000
|
+
| { ok: false; outcome: ToolOutcome };
|
|
1001
|
+
|
|
1002
|
+
function referenceError(
|
|
1003
|
+
code: "REFERENCE_NOT_FOUND" | "REFERENCE_AMBIGUOUS" | "REFERENCE_SCAN_LIMIT" | "NOT_AVAILABLE",
|
|
1004
|
+
message: string,
|
|
1005
|
+
argument: string,
|
|
1006
|
+
nextSteps: string[],
|
|
1007
|
+
candidates?: Record<string, unknown>[],
|
|
1008
|
+
): ToolOutcome {
|
|
1009
|
+
const structured = {
|
|
1010
|
+
error: code === "REFERENCE_NOT_FOUND" ? "ReferenceNotFound" : code === "REFERENCE_AMBIGUOUS" ? "AmbiguousReference" : code === "REFERENCE_SCAN_LIMIT" ? "ReferenceScanLimit" : "ReferenceResolutionUnavailable",
|
|
1011
|
+
code,
|
|
1012
|
+
message,
|
|
1013
|
+
argument,
|
|
1014
|
+
...(candidates ? { candidates } : {}),
|
|
1015
|
+
next_steps: nextSteps,
|
|
1016
|
+
};
|
|
1017
|
+
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function outcomeValue(outcome: ToolOutcome): unknown {
|
|
1021
|
+
if (outcome.structured !== undefined) return outcome.structured;
|
|
1022
|
+
try { return JSON.parse(outcome.text); } catch { return undefined; }
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function referencePage(value: unknown): { items: Record<string, unknown>[]; nextPage: Record<string, unknown> | null } | null {
|
|
1026
|
+
if (Array.isArray(value)) {
|
|
1027
|
+
return { items: value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item)), nextPage: null };
|
|
1028
|
+
}
|
|
1029
|
+
if (!value || typeof value !== "object") return null;
|
|
1030
|
+
const record = value as Record<string, unknown>;
|
|
1031
|
+
const direct = Array.isArray(record.items) ? record.items : undefined;
|
|
1032
|
+
const arrays = direct ? [direct] : Object.entries(record)
|
|
1033
|
+
.filter(([name, entry]) => name !== "request_id" && name !== "requestId" && Array.isArray(entry))
|
|
1034
|
+
.map(([, entry]) => entry as unknown[]);
|
|
1035
|
+
if (arrays.length !== 1) return null;
|
|
1036
|
+
return {
|
|
1037
|
+
items: arrays[0]!.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item)),
|
|
1038
|
+
nextPage: record.nextPage && typeof record.nextPage === "object" && !Array.isArray(record.nextPage)
|
|
1039
|
+
? record.nextPage as Record<string, unknown>
|
|
1040
|
+
: null,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function userShapedReference(name: string): boolean {
|
|
1045
|
+
const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/id$/, "");
|
|
1046
|
+
return ["user", "assignee", "owner", "member", "actor", "creator", "account", "profile"].includes(normalized);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function referenceMatchLabel(fields: string[]): string {
|
|
1050
|
+
return fields.length === 1 ? fields[0]! : fields.slice(0, -1).join(", ") + " or " + fields.at(-1);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function looksLikeIdentifier(value: string, resolver: ReferenceResolver): boolean {
|
|
1054
|
+
if (/\s/.test(value)) return false;
|
|
1055
|
+
if (resolver.idPattern !== undefined) {
|
|
1056
|
+
try { return new RegExp(resolver.idPattern).test(value); } catch { return false; }
|
|
1057
|
+
}
|
|
1058
|
+
return /^\d+$/.test(value) ||
|
|
1059
|
+
/^[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) ||
|
|
1060
|
+
/^[a-z][a-z0-9]*_[a-z0-9][a-z0-9_-]*$/i.test(value) ||
|
|
1061
|
+
/^[A-Z][A-Z0-9]{1,9}-\d+$/.test(value) ||
|
|
1062
|
+
/^[A-Za-z0-9_-]{20,}$/.test(value);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function putReferenceCache(cache: ReferenceCache, key: string, value: string | number): void {
|
|
1066
|
+
cache.set(key, value);
|
|
1067
|
+
while (cache.size > 256) cache.delete(cache.keys().next().value!);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function candidateRecord(item: Record<string, unknown>, resolver: ReferenceResolver): Record<string, unknown> {
|
|
1071
|
+
return Object.fromEntries([resolver.id, ...resolver.match]
|
|
1072
|
+
.filter((name, index, all) => all.indexOf(name) === index && item[name] !== undefined)
|
|
1073
|
+
.map((name) => [name, item[name]]));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** Resolve every eligible reference after validation/coercion and before the
|
|
1077
|
+
* requested API call. Matching is exact (case-insensitive for strings),
|
|
1078
|
+
* never fuzzy. Zero matches fail before the requested API call. */
|
|
1079
|
+
export async function resolveReferences(
|
|
1080
|
+
op: OpLike,
|
|
1081
|
+
preparedArgs: Record<string, unknown>,
|
|
1082
|
+
options: ResolveReferencesOptions,
|
|
1083
|
+
): Promise<ResolveReferencesResult> {
|
|
1084
|
+
const args = { ...preparedArgs };
|
|
1085
|
+
const maxPages = Math.max(1, Math.floor(options.maxPages ?? 5));
|
|
1086
|
+
for (const param of op.params) {
|
|
1087
|
+
const raw = args[param.name];
|
|
1088
|
+
if (typeof raw !== "string" || param.resolve === false) continue;
|
|
1089
|
+
const value = raw.trim();
|
|
1090
|
+
|
|
1091
|
+
if (value.toLowerCase() === "me" && userShapedReference(param.name) && options.identityTool) {
|
|
1092
|
+
const identity = findOperation(options.ops, options.identityTool);
|
|
1093
|
+
if (identity) {
|
|
1094
|
+
const cacheKey = "me:" + identity.tool;
|
|
1095
|
+
const cached = options.cache.get(cacheKey);
|
|
1096
|
+
if (cached !== undefined) {
|
|
1097
|
+
args[param.name] = cached;
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
const outcome = await options.runOperation(identity, {});
|
|
1101
|
+
if (outcome.isError) return { ok: false, outcome };
|
|
1102
|
+
const body = outcomeValue(outcome);
|
|
1103
|
+
const id = body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>).id : undefined;
|
|
1104
|
+
if (typeof id !== "string" && typeof id !== "number") {
|
|
1105
|
+
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."]) };
|
|
1106
|
+
}
|
|
1107
|
+
putReferenceCache(options.cache, cacheKey, id);
|
|
1108
|
+
args[param.name] = id;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const resolver = param.resolve && typeof param.resolve === "object" ? param.resolve : undefined;
|
|
1114
|
+
if (!resolver || looksLikeIdentifier(value, resolver)) continue;
|
|
1115
|
+
const source = findOperation(options.ops, resolver.via);
|
|
1116
|
+
if (!source) continue;
|
|
1117
|
+
const cacheKey = source.tool + ":" + resolver.id + ":" + resolver.match.join(",") + ":" + value.toLowerCase();
|
|
1118
|
+
const cached = options.cache.get(cacheKey);
|
|
1119
|
+
if (cached !== undefined) {
|
|
1120
|
+
args[param.name] = cached;
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const matches = new Map<string, { id: string | number; item: Record<string, unknown> }>();
|
|
1125
|
+
let pageArgs: Record<string, unknown> = {};
|
|
1126
|
+
for (const sourceParam of source.params) {
|
|
1127
|
+
if (args[sourceParam.name] !== undefined && sourceParam.name !== param.name) pageArgs[sourceParam.name] = args[sourceParam.name];
|
|
1128
|
+
}
|
|
1129
|
+
if (resolver.filterParam) pageArgs[resolver.filterParam] = value;
|
|
1130
|
+
if (!hasOwnFieldsParam(source)) pageArgs.fields = [resolver.id, ...resolver.match];
|
|
1131
|
+
|
|
1132
|
+
let exhausted = false;
|
|
1133
|
+
for (let pageNumber = 1; pageNumber <= maxPages; pageNumber++) {
|
|
1134
|
+
const outcome = await options.runOperation(source, pageArgs);
|
|
1135
|
+
if (outcome.isError) return { ok: false, outcome };
|
|
1136
|
+
const page = referencePage(outcomeValue(outcome));
|
|
1137
|
+
if (!page) {
|
|
1138
|
+
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}.`]) };
|
|
1139
|
+
}
|
|
1140
|
+
for (const item of page.items) {
|
|
1141
|
+
const id = item[resolver.id];
|
|
1142
|
+
if (typeof id !== "string" && typeof id !== "number") continue;
|
|
1143
|
+
const hit = resolver.match.some((field) => typeof item[field] === "string" && (item[field] as string).toLowerCase() === value.toLowerCase());
|
|
1144
|
+
if (hit) matches.set(typeof id + ":" + String(id), { id, item });
|
|
1145
|
+
}
|
|
1146
|
+
if (matches.size > 1) {
|
|
1147
|
+
const candidates = [...matches.values()].map(({ item }) => candidateRecord(item, resolver));
|
|
1148
|
+
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) };
|
|
1149
|
+
}
|
|
1150
|
+
if (!page.nextPage) {
|
|
1151
|
+
exhausted = true;
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
pageArgs = { ...pageArgs, ...page.nextPage };
|
|
1155
|
+
}
|
|
1156
|
+
if (!exhausted) {
|
|
1157
|
+
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.`]) };
|
|
1158
|
+
}
|
|
1159
|
+
const match = [...matches.values()][0];
|
|
1160
|
+
if (!match) {
|
|
1161
|
+
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.`]) };
|
|
1162
|
+
}
|
|
1163
|
+
putReferenceCache(options.cache, cacheKey, match.id);
|
|
1164
|
+
args[param.name] = match.id;
|
|
1165
|
+
}
|
|
1166
|
+
return { ok: true, args };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
876
1169
|
/** The isError result for bad arguments: one stable code, one issue per
|
|
877
1170
|
* argument, and the way out. */
|
|
878
1171
|
export function argumentsError(op: { tool: string }, issues: ArgumentIssue[]): ToolOutcome {
|
|
@@ -920,6 +1213,9 @@ export function projectFields(value: unknown, paths: string[][] | null): unknown
|
|
|
920
1213
|
export interface ResultOptions {
|
|
921
1214
|
fields?: string[][] | null;
|
|
922
1215
|
maxChars?: number;
|
|
1216
|
+
/** Request identifier for a page that is reshaped into the MCP paging
|
|
1217
|
+
* envelope. Ordinary object results already retain their body field. */
|
|
1218
|
+
requestId?: string;
|
|
923
1219
|
/** For paginated results that must be cut: the op's pagination config and
|
|
924
1220
|
* the call's arguments let some styles resume exactly where the cut fell. */
|
|
925
1221
|
pagination?: OpLike["pagination"];
|
|
@@ -951,13 +1247,18 @@ export function pageOutcome(items: unknown[], nextPage: Record<string, unknown>
|
|
|
951
1247
|
const fields = options.fields ?? null;
|
|
952
1248
|
const maxChars = options.maxChars ?? DEFAULT_MAX_RESULT_CHARS;
|
|
953
1249
|
const shown = projectFields(items, fields) as unknown[];
|
|
954
|
-
const full = {
|
|
1250
|
+
const full = {
|
|
1251
|
+
items: shown,
|
|
1252
|
+
hasMore: nextPage !== null,
|
|
1253
|
+
...(nextPage !== null ? { nextPage } : {}),
|
|
1254
|
+
...(options.requestId ? { request_id: options.requestId } : {}),
|
|
1255
|
+
};
|
|
955
1256
|
const text = JSON.stringify(full);
|
|
956
1257
|
if (text.length <= maxChars) {
|
|
957
1258
|
return { text, isError: false, structured: full };
|
|
958
1259
|
}
|
|
959
1260
|
|
|
960
|
-
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;
|
|
1261
|
+
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;
|
|
961
1262
|
const k = itemsThatFit(shown, Math.max(0, maxChars - overhead));
|
|
962
1263
|
const omitted = shown.length - k;
|
|
963
1264
|
const pg = options.pagination;
|
|
@@ -981,6 +1282,7 @@ export function pageOutcome(items: unknown[], nextPage: Record<string, unknown>
|
|
|
981
1282
|
items: shown.slice(0, k),
|
|
982
1283
|
hasMore: resume !== null ? true : nextPage !== null,
|
|
983
1284
|
...(resume !== null ? { nextPage: resume } : nextPage !== null ? { nextPage } : {}),
|
|
1285
|
+
...(options.requestId ? { request_id: options.requestId } : {}),
|
|
984
1286
|
truncated: {
|
|
985
1287
|
omitted,
|
|
986
1288
|
of: shown.length,
|
|
@@ -1123,7 +1425,9 @@ export async function binaryOutcome(blob: Blob, options: BinaryOptions = {}): Pr
|
|
|
1123
1425
|
* generated CLI's error envelope). Additive only. */
|
|
1124
1426
|
export type ErrorCode =
|
|
1125
1427
|
| "NO_AUTH" | "AUTH_INVALID" | "PLAN_LIMIT" | "NOT_FOUND" | "INVALID_REQUEST" | "RATE_LIMITED"
|
|
1126
|
-
| "SERVER_ERROR" | "NETWORK_ERROR" | "VALIDATION_FAILED" | "INVALID_ARGUMENTS" | "CONFIRMATION_REQUIRED"
|
|
1428
|
+
| "SPEC_INVALID" | "SERVER_ERROR" | "NETWORK_ERROR" | "VALIDATION_FAILED" | "INVALID_ARGUMENTS" | "CONFIRMATION_REQUIRED"
|
|
1429
|
+
| "REFERENCE_NOT_FOUND" | "REFERENCE_AMBIGUOUS" | "REFERENCE_SCAN_LIMIT" | "NOT_AVAILABLE" | "CALL_FAILED"
|
|
1430
|
+
| "ACCOUNT_LINK_REQUIRED" | "ACCOUNT_LINK_CANCELLED";
|
|
1127
1431
|
|
|
1128
1432
|
export interface ErrorContext {
|
|
1129
1433
|
/** One sentence on how to supply a credential on this transport. */
|
|
@@ -1151,6 +1455,7 @@ export function classifyError(error: unknown, context: ErrorContext = {}): { cod
|
|
|
1151
1455
|
return { code: "NETWORK_ERROR", nextSteps: ["The API could not be reached (network, DNS, TLS or timeout). Retry once with backoff; do not loop."] };
|
|
1152
1456
|
}
|
|
1153
1457
|
const status = typeof e.status === "number" ? e.status : 0;
|
|
1458
|
+
const body = e.body as { errors?: { code?: string; message?: string }[] } | undefined;
|
|
1154
1459
|
const auth = context.authHint ? context.authHint.trim().replace(/[.]?$/, ".") : null;
|
|
1155
1460
|
if (status === 401) {
|
|
1156
1461
|
return context.hadCredential
|
|
@@ -1164,6 +1469,15 @@ export function classifyError(error: unknown, context: ErrorContext = {}): { cod
|
|
|
1164
1469
|
const retryAfter = extractRetryAfter(e);
|
|
1165
1470
|
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."] };
|
|
1166
1471
|
}
|
|
1472
|
+
if (status === 422 && body?.errors?.[0]?.code === "spec_error") {
|
|
1473
|
+
return {
|
|
1474
|
+
code: "SPEC_INVALID",
|
|
1475
|
+
nextSteps: [
|
|
1476
|
+
"The API rejected the spec it was given; its error message names the invalid part.",
|
|
1477
|
+
context.docsUrl ? "Use search_docs with the error message, fix the spec, then call again." : "Fix the spec, then call again.",
|
|
1478
|
+
],
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1167
1481
|
if (status === 400 || status === 409 || status === 413 || status === 422) {
|
|
1168
1482
|
return { code: "INVALID_REQUEST", nextSteps: ["Read body for the field the API named, fix that argument and call again."] };
|
|
1169
1483
|
}
|
|
@@ -1189,13 +1503,18 @@ function notFoundNextSteps(message: string, body: unknown): string[] {
|
|
|
1189
1503
|
/** A typed API error as the agent should see it: name, a stable code, the
|
|
1190
1504
|
* message, status, the API's body, where to read more, and what to do. */
|
|
1191
1505
|
export function errorOutcome(error: unknown, context: ErrorContext = {}): ToolOutcome {
|
|
1192
|
-
const e = error as { name?: string; message?: string; status?: number; body?: unknown };
|
|
1506
|
+
const e = error as { name?: string; message?: string; status?: number; body?: unknown; response?: { requestId?: string } };
|
|
1193
1507
|
const { code, nextSteps } = classifyError(error, context);
|
|
1508
|
+
const bodyRequestId = e?.body && typeof e.body === "object" && !Array.isArray(e.body)
|
|
1509
|
+
? (e.body as Record<string, unknown>).request_id ?? (e.body as Record<string, unknown>).requestId
|
|
1510
|
+
: undefined;
|
|
1511
|
+
const requestId = e?.response?.requestId ?? (typeof bodyRequestId === "string" ? bodyRequestId : undefined);
|
|
1194
1512
|
const structured = {
|
|
1195
1513
|
error: e?.name ?? "Error",
|
|
1196
1514
|
code,
|
|
1197
1515
|
message: e?.message,
|
|
1198
1516
|
...(typeof e?.status === "number" ? { status: e.status } : {}),
|
|
1517
|
+
...(requestId ? { request_id: requestId } : {}),
|
|
1199
1518
|
...(e?.body !== undefined ? { body: e.body } : {}),
|
|
1200
1519
|
...(context.docsUrl ? { docs_url: context.docsUrl } : {}),
|
|
1201
1520
|
next_steps: nextSteps,
|
|
@@ -1212,36 +1531,44 @@ export function textError(text: string, code: ErrorCode = "CALL_FAILED", nextSte
|
|
|
1212
1531
|
|
|
1213
1532
|
export interface DocsSource {
|
|
1214
1533
|
ops: OpLike[];
|
|
1534
|
+
/** Operations omitted from a capped generation. */
|
|
1535
|
+
omittedOps?: OpLike[];
|
|
1536
|
+
/** Count generated before runtime surface filters. */
|
|
1537
|
+
generatedOperationCount?: number;
|
|
1215
1538
|
/** Base URL of the docs site, or null when none is configured. */
|
|
1216
1539
|
docsUrl(): string | null;
|
|
1540
|
+
/** Exact llms.txt URL when it is not at <docsUrl>/llms.txt. */
|
|
1541
|
+
docsIndexUrl?(): string | null;
|
|
1217
1542
|
/** Fetch a URL's text, or null on any failure. */
|
|
1218
1543
|
fetchText(url: string): Promise<string | null>;
|
|
1219
1544
|
}
|
|
1220
1545
|
|
|
1221
|
-
function docsPageUrl(source: DocsSource, pathOrFile: string): string | null {
|
|
1222
|
-
const base = source.docsUrl();
|
|
1223
|
-
if (base === null) return null;
|
|
1224
|
-
try {
|
|
1225
|
-
const baseUrl = new URL(base);
|
|
1226
|
-
if (baseUrl.protocol !== "https:" && baseUrl.protocol !== "http:") return null;
|
|
1227
|
-
const target = /^https?:\/\//.test(pathOrFile)
|
|
1228
|
-
? new URL(pathOrFile)
|
|
1229
|
-
: new URL(pathOrFile.replace(/^\/+/, ""), baseUrl.toString().replace(/\/+$/, "") + "/");
|
|
1230
|
-
// llms.txt commonly contains absolute links, but a docs tool is not a
|
|
1231
|
-
// general-purpose URL fetcher. Keeping every page on the configured
|
|
1232
|
-
// origin prevents an agent from turning hosted MCP into an SSRF proxy.
|
|
1233
|
-
if (target.origin !== baseUrl.origin || target.username || target.password) return null;
|
|
1234
|
-
return target.toString();
|
|
1235
|
-
} catch {
|
|
1236
|
-
return null;
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
1546
|
async function fetchDocs(source: DocsSource, pathOrFile: string): Promise<string | null> {
|
|
1241
|
-
const url =
|
|
1547
|
+
const url = resolveDocsContentUrl(source.docsUrl(), source.docsIndexUrl?.() ?? null, pathOrFile);
|
|
1242
1548
|
return url === null ? null : source.fetchText(url);
|
|
1243
1549
|
}
|
|
1244
1550
|
|
|
1551
|
+
function coverageText(source: DocsSource): string | null {
|
|
1552
|
+
const omitted = source.omittedOps ?? [];
|
|
1553
|
+
const generated = source.generatedOperationCount ?? source.ops.length;
|
|
1554
|
+
return omitted.length > 0
|
|
1555
|
+
? "Coverage: generated " + generated + " of " + (generated + omitted.length) + " operations. Omitted by the plan limit: " + omitted.map((op) => op.tool + " (" + op.httpMethod + " " + op.path + ")").join(", ") + "."
|
|
1556
|
+
: null;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function omittedPlanLimit(ops: OpLike[], requested?: string): ToolOutcome {
|
|
1560
|
+
const structured = {
|
|
1561
|
+
error: "PlanLimitError",
|
|
1562
|
+
code: "PLAN_LIMIT",
|
|
1563
|
+
message: requested
|
|
1564
|
+
? "The operation " + requested + " exists in the API Definition but was omitted from this generated package by its plan limit."
|
|
1565
|
+
: "Matching operations exist in the API Definition but were omitted from this generated package by its plan limit.",
|
|
1566
|
+
omitted_operations: ops.map((op) => ({ tool: op.tool, method: op.httpMethod, path: op.path })),
|
|
1567
|
+
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."],
|
|
1568
|
+
};
|
|
1569
|
+
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1245
1572
|
export function referenceText(op: OpLike): string {
|
|
1246
1573
|
const safety = operationSafety(op);
|
|
1247
1574
|
const example = op.exampleArguments ?? exampleArgumentsFromSchema(op.inputSchema);
|
|
@@ -1306,12 +1633,17 @@ export function searchScore(op: OpLike, query: string): number {
|
|
|
1306
1633
|
}
|
|
1307
1634
|
|
|
1308
1635
|
export async function docsSearch(source: DocsSource, query: string, page = 1): Promise<ToolOutcome> {
|
|
1309
|
-
const term = query.toLowerCase();
|
|
1310
1636
|
const sections: string[] = [];
|
|
1311
1637
|
const ranked = source.ops
|
|
1312
1638
|
.map((op) => ({ op, score: searchScore(op, query) }))
|
|
1313
1639
|
.filter((r) => r.score > 0)
|
|
1314
1640
|
.sort((a, b) => b.score - a.score || a.op.tool.localeCompare(b.op.tool));
|
|
1641
|
+
const omittedRanked = (source.omittedOps ?? [])
|
|
1642
|
+
.map((op) => ({ op, score: searchScore(op, query) }))
|
|
1643
|
+
.filter((result) => result.score > 0)
|
|
1644
|
+
.sort((a, b) => b.score - a.score || a.op.tool.localeCompare(b.op.tool));
|
|
1645
|
+
const exactOmitted = findOperation(source.omittedOps ?? [], query);
|
|
1646
|
+
if (exactOmitted) return omittedPlanLimit([exactOmitted], exactOmitted.tool);
|
|
1315
1647
|
const pageIndex = Math.max(1, Math.floor(page)) - 1;
|
|
1316
1648
|
const slice = ranked.slice(pageIndex * SEARCH_PAGE_SIZE, (pageIndex + 1) * SEARCH_PAGE_SIZE);
|
|
1317
1649
|
if (slice.length > 0) {
|
|
@@ -1322,44 +1654,55 @@ export async function docsSearch(source: DocsSource, query: string, page = 1): P
|
|
|
1322
1654
|
} else if (ranked.length > 0) {
|
|
1323
1655
|
sections.push("No reference matches on page " + (pageIndex + 1) + "; there are " + Math.ceil(ranked.length / SEARCH_PAGE_SIZE) + " pages.");
|
|
1324
1656
|
}
|
|
1325
|
-
const
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
else if (line.toLowerCase().includes(term) && proseMatches.length < 15) {
|
|
1332
|
-
proseMatches.push("- [" + heading + "] " + line.trim().slice(0, 160));
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
if (proseMatches.length > 0) sections.push("Guide matches:\n" + proseMatches.join("\n"));
|
|
1657
|
+
const { guides, status } = await searchConnectedGuides(source.docsUrl(), source.docsIndexUrl?.() ?? null, (path) => fetchDocs(source, path), query);
|
|
1658
|
+
const guideSlice = guides.slice(pageIndex * SEARCH_PAGE_SIZE, (pageIndex + 1) * SEARCH_PAGE_SIZE);
|
|
1659
|
+
const proseMatchCount = guides.length;
|
|
1660
|
+
if (guideSlice.length > 0) {
|
|
1661
|
+
sections.push("Guide matches (best first, " + guides.length + " pages):\n" + guideSlice.map((match) =>
|
|
1662
|
+
"- [" + match.title + (match.section ? " / " + match.section : "") + "](" + match.url + "): " + match.excerpt + "\n read_docs " + JSON.stringify({ page: match.url })).join("\n"));
|
|
1336
1663
|
}
|
|
1664
|
+
if (status === "unavailable") sections.push("The docs site is unavailable; the API reference was still searched.");
|
|
1665
|
+
const structured = {
|
|
1666
|
+
schema_version: "1", query, page: pageIndex + 1,
|
|
1667
|
+
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 } } })),
|
|
1668
|
+
guides: guideSlice.map((match) => ({ ...match, read_tool: { name: "read_docs", arguments: { page: match.url } } })),
|
|
1669
|
+
totals: { reference: ranked.length, guides: guides.length }, guides_status: status,
|
|
1670
|
+
};
|
|
1671
|
+
if (omittedRanked.length > 0 && ranked.length === 0 && proseMatchCount === 0) {
|
|
1672
|
+
return omittedPlanLimit(omittedRanked.map((result) => result.op));
|
|
1673
|
+
}
|
|
1674
|
+
if (omittedRanked.length > 0) {
|
|
1675
|
+
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"));
|
|
1676
|
+
}
|
|
1677
|
+
const coverage = coverageText(source);
|
|
1337
1678
|
if (sections.length === 0) {
|
|
1338
1679
|
return {
|
|
1339
|
-
text: "No matches for: " + query + (source.docsUrl() === null ? " (
|
|
1680
|
+
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)" : ""),
|
|
1340
1681
|
isError: false,
|
|
1682
|
+
structured,
|
|
1341
1683
|
};
|
|
1342
1684
|
}
|
|
1343
|
-
return { text: sections.join("\n\n"), isError: false };
|
|
1685
|
+
return { text: [...(coverage ? [coverage] : []), ...sections].join("\n\n"), isError: false, structured };
|
|
1344
1686
|
}
|
|
1345
1687
|
|
|
1346
1688
|
export async function docsRead(source: DocsSource, page: string): Promise<ToolOutcome> {
|
|
1347
1689
|
const opMatch = findOperation(source.ops, page);
|
|
1348
|
-
|
|
1690
|
+
const coverage = coverageText(source);
|
|
1691
|
+
if (opMatch) return { text: [...(coverage ? [coverage] : []), referenceText(opMatch)].join("\n\n"), isError: false };
|
|
1692
|
+
const omittedMatch = findOperation(source.omittedOps ?? [], page);
|
|
1693
|
+
if (omittedMatch) return omittedPlanLimit([omittedMatch], omittedMatch.tool);
|
|
1349
1694
|
let target = page;
|
|
1350
1695
|
if (!/^https?:\/\//.test(target)) {
|
|
1351
1696
|
const index = await fetchDocs(source, "llms.txt");
|
|
1352
|
-
|
|
1353
|
-
const hit = linked.find((u) => u.toLowerCase().includes(target.toLowerCase()));
|
|
1354
|
-
if (hit !== undefined) target = hit;
|
|
1697
|
+
target = docsReadTarget(index, source.docsUrl(), source.docsIndexUrl?.() ?? null, target);
|
|
1355
1698
|
}
|
|
1356
1699
|
const text = await fetchDocs(source, target);
|
|
1357
1700
|
if (text === null) {
|
|
1358
|
-
return textError(source.docsUrl() === null
|
|
1359
|
-
? "
|
|
1701
|
+
return textError(source.docsUrl() === null && (source.docsIndexUrl?.() ?? null) === null
|
|
1702
|
+
? "A docs URL was not provided at generate time, and no generated operation matches \"" + page + "\"."
|
|
1360
1703
|
: "Couldn't fetch \"" + page + "\". Use search_docs to find pages.", "NOT_FOUND", ["search_docs finds operations and guide pages."]);
|
|
1361
1704
|
}
|
|
1362
|
-
return { text, isError: false };
|
|
1705
|
+
return { text: [...(coverage ? [coverage] : []), text].join("\n\n"), isError: false };
|
|
1363
1706
|
}
|
|
1364
1707
|
|
|
1365
1708
|
/**
|
|
@@ -1385,7 +1728,12 @@ export async function callSharedTool(
|
|
|
1385
1728
|
if (name === "execute") {
|
|
1386
1729
|
if (typeof args.operation !== "string") return argumentsError({ tool: name }, [{ code: "MISSING_ARGUMENT", argument: "operation", message: "execute requires an operation name." }]);
|
|
1387
1730
|
const target = findOperation(source.ops, args.operation);
|
|
1388
|
-
if (!target)
|
|
1731
|
+
if (!target) {
|
|
1732
|
+
const omitted = findOperation(source.omittedOps ?? [], args.operation);
|
|
1733
|
+
return omitted
|
|
1734
|
+
? omittedPlanLimit([omitted], omitted.tool)
|
|
1735
|
+
: textError("Unknown operation: " + args.operation + ".", "NOT_FOUND", ["search_docs finds operations by name, path or description."]);
|
|
1736
|
+
}
|
|
1389
1737
|
if (operationSafety(target) === "destructive" && args.confirm !== true) {
|
|
1390
1738
|
return textError(
|
|
1391
1739
|
"The destructive operation " + target.tool + " requires explicit confirmation.",
|
|
@@ -1400,52 +1748,3 @@ export async function callSharedTool(
|
|
|
1400
1748
|
}
|
|
1401
1749
|
return undefined;
|
|
1402
1750
|
}
|
|
1403
|
-
|
|
1404
|
-
/** A fetch for docs pages: markdown preferred, same-origin redirects only,
|
|
1405
|
-
* a 10s deadline, and a 2 MB streaming cap. The caller already constrained
|
|
1406
|
-
* the first URL to its configured docs origin; redirects must not escape it. */
|
|
1407
|
-
export const MAX_DOCS_TEXT_BYTES = 2_000_000;
|
|
1408
|
-
export async function fetchDocsText(url: string): Promise<string | null> {
|
|
1409
|
-
try {
|
|
1410
|
-
const allowedOrigin = new URL(url).origin;
|
|
1411
|
-
let current = url;
|
|
1412
|
-
const signal = AbortSignal.timeout(10_000);
|
|
1413
|
-
for (let redirects = 0; redirects <= 3; redirects += 1) {
|
|
1414
|
-
const response = await fetch(current, {
|
|
1415
|
-
headers: { Accept: "text/markdown, text/plain, */*" },
|
|
1416
|
-
redirect: "manual",
|
|
1417
|
-
signal,
|
|
1418
|
-
});
|
|
1419
|
-
if (response.status >= 300 && response.status < 400) {
|
|
1420
|
-
const location = response.headers.get("location");
|
|
1421
|
-
if (!location || redirects === 3) return null;
|
|
1422
|
-
const next = new URL(location, current);
|
|
1423
|
-
if (next.origin !== allowedOrigin || next.username || next.password) return null;
|
|
1424
|
-
current = next.toString();
|
|
1425
|
-
continue;
|
|
1426
|
-
}
|
|
1427
|
-
if (!response.ok) return null;
|
|
1428
|
-
const declared = Number(response.headers.get("content-length"));
|
|
1429
|
-
if (Number.isFinite(declared) && declared > MAX_DOCS_TEXT_BYTES) return null;
|
|
1430
|
-
if (!response.body) return "";
|
|
1431
|
-
const reader = response.body.getReader();
|
|
1432
|
-
const decoder = new TextDecoder();
|
|
1433
|
-
let bytes = 0;
|
|
1434
|
-
let text = "";
|
|
1435
|
-
for (;;) {
|
|
1436
|
-
const chunk = await reader.read();
|
|
1437
|
-
if (chunk.done) break;
|
|
1438
|
-
bytes += chunk.value.byteLength;
|
|
1439
|
-
if (bytes > MAX_DOCS_TEXT_BYTES) {
|
|
1440
|
-
await reader.cancel();
|
|
1441
|
-
return null;
|
|
1442
|
-
}
|
|
1443
|
-
text += decoder.decode(chunk.value, { stream: true });
|
|
1444
|
-
}
|
|
1445
|
-
return text + decoder.decode();
|
|
1446
|
-
}
|
|
1447
|
-
return null;
|
|
1448
|
-
} catch {
|
|
1449
|
-
return null;
|
|
1450
|
-
}
|
|
1451
|
-
}
|