@typeship-ax/mcp 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -11
- package/README.md +22 -53
- package/api.json +9998 -10118
- package/api.md +8983 -9120
- package/dist/arguments.d.ts +54 -0
- package/dist/arguments.d.ts.map +1 -0
- package/dist/arguments.js +265 -0
- package/dist/core/http.d.ts +162 -19
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +381 -48
- package/dist/core/pagination.d.ts +42 -6
- package/dist/core/pagination.d.ts.map +1 -1
- package/dist/core/pagination.js +111 -17
- package/dist/credential-storage.d.ts +10 -3
- package/dist/credential-storage.d.ts.map +1 -1
- package/dist/credential-storage.js +15 -6
- package/dist/dates.d.ts +1 -1
- package/dist/dates.js +1 -1
- package/dist/errors.d.ts +20 -84
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +20 -108
- package/dist/fields.d.ts +36 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +187 -0
- package/dist/index.d.ts +28 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -25
- package/dist/mcp-authorization.d.ts.map +1 -1
- package/dist/mcp-authorization.js +34 -10
- package/dist/mcp-protocol.d.ts +108 -44
- package/dist/mcp-protocol.d.ts.map +1 -1
- package/dist/mcp-protocol.js +780 -484
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +129 -29
- package/dist/named-credentials.d.ts +19 -0
- package/dist/named-credentials.d.ts.map +1 -1
- package/dist/named-credentials.js +81 -1
- package/dist/oauth-request.d.ts +7 -1
- package/dist/oauth-request.d.ts.map +1 -1
- package/dist/oauth-request.js +26 -4
- package/dist/oauth-session.d.ts +13 -1
- package/dist/oauth-session.d.ts.map +1 -1
- package/dist/oauth-session.js +34 -18
- package/dist/ops.d.ts +58 -5
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +110 -41
- package/dist/resources/api-keys.d.ts +10 -7
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +10 -31
- package/dist/resources/deliveries.d.ts +89 -5
- package/dist/resources/deliveries.d.ts.map +1 -1
- package/dist/resources/deliveries.js +96 -19
- package/dist/resources/drafts.d.ts +16 -16
- package/dist/resources/drafts.d.ts.map +1 -1
- package/dist/resources/drafts.js +12 -65
- package/dist/resources/files.d.ts +4 -4
- package/dist/resources/files.d.ts.map +1 -1
- package/dist/resources/files.js +3 -12
- package/dist/resources/generations.d.ts +16 -16
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +23 -47
- package/dist/resources/organization.d.ts +4 -4
- package/dist/resources/organization.d.ts.map +1 -1
- package/dist/resources/organization.js +3 -10
- package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
- package/dist/resources/packages.d.ts.map +1 -0
- package/dist/resources/{generate.js → packages.js} +13 -29
- package/dist/resources/projects.d.ts +50 -50
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +60 -116
- package/dist/resources/releases.d.ts +22 -17
- package/dist/resources/releases.d.ts.map +1 -1
- package/dist/resources/releases.js +19 -40
- package/dist/resources/spec-revisions.d.ts +16 -7
- package/dist/resources/spec-revisions.d.ts.map +1 -1
- package/dist/resources/spec-revisions.js +7 -29
- package/dist/resources/specs.d.ts +7 -7
- package/dist/resources/specs.d.ts.map +1 -1
- package/dist/resources/specs.js +6 -34
- package/dist/resources/targets.d.ts +49 -49
- package/dist/resources/targets.d.ts.map +1 -1
- package/dist/resources/targets.js +59 -115
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +78 -76
- package/dist/search.d.ts +54 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +421 -0
- package/dist/type-docs.d.ts +61 -0
- package/dist/type-docs.d.ts.map +1 -0
- package/dist/type-docs.js +174 -0
- package/dist/types.d.ts +499 -339
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +18 -18
- package/dist/worker.js +2 -2
- package/package.json +5 -2
- package/server.json +5 -5
- package/src/arguments.ts +254 -0
- package/src/core/http.ts +457 -58
- package/src/core/pagination.ts +129 -18
- package/src/credential-storage.ts +16 -6
- package/src/dates.ts +1 -1
- package/src/errors.ts +46 -115
- package/src/fields.ts +167 -0
- package/src/index.ts +45 -28
- package/src/mcp-authorization.ts +29 -9
- package/src/mcp-protocol.ts +808 -435
- package/src/mcp.ts +115 -27
- package/src/named-credentials.ts +66 -1
- package/src/oauth-request.ts +32 -6
- package/src/oauth-session.ts +37 -19
- package/src/ops.ts +146 -45
- package/src/resources/api-keys.ts +34 -48
- package/src/resources/deliveries.ts +213 -32
- package/src/resources/drafts.ts +62 -109
- package/src/resources/files.ts +19 -20
- package/src/resources/generations.ts +61 -79
- package/src/resources/organization.ts +11 -16
- package/src/resources/{generate.ts → packages.ts} +43 -51
- package/src/resources/projects.ts +145 -200
- package/src/resources/releases.ts +50 -67
- package/src/resources/spec-revisions.ts +40 -49
- package/src/resources/specs.ts +39 -59
- package/src/resources/targets.ts +144 -194
- package/src/schemas.ts +78 -76
- package/src/search.ts +434 -0
- package/src/type-docs.ts +205 -0
- package/src/types.ts +538 -357
- package/src/worker.ts +2 -2
- package/dist/resources/generate.d.ts.map +0 -1
- package/dist/resources/publications.d.ts +0 -47
- package/dist/resources/publications.d.ts.map +0 -1
- package/dist/resources/publications.js +0 -70
- package/src/resources/publications.ts +0 -140
package/src/mcp-protocol.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP tools server with a 2025-11-25 handshake adapter and a 2026-07-28 core.
|
|
3
|
-
* Generated by
|
|
3
|
+
* Generated by Typeship — https://typeship.dev
|
|
4
4
|
*
|
|
5
|
-
* The protocol layer
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The protocol layer of the MCP server, shared by its stdio and HTTP
|
|
6
|
+
* transports: JSON-RPC shape checks, the per-request _meta rules,
|
|
7
|
+
* Streamable HTTP header validation, result wrapping, and the tool
|
|
8
8
|
* surface (per-operation tools or the three-tool "meta" shape, plus the
|
|
9
9
|
* search_docs / read_docs pair). Also the agent-facing contract of a tool
|
|
10
10
|
* call: argument validation and coercion against the tool's input schema
|
|
11
|
-
* (
|
|
11
|
+
* (via ./arguments, shared with the CLI), field projection, a size cap on results,
|
|
12
12
|
* and error results that carry a stable code and next steps. Transport,
|
|
13
13
|
* credentials, and how a tool call reaches the API stay with the caller.
|
|
14
14
|
* No external dependencies.
|
|
@@ -16,8 +16,13 @@
|
|
|
16
16
|
* Spec: https://modelcontextprotocol.io/specification/2026-07-28
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { checkValue, closestName, isMeReference, meIdentityField, normalizeName, userShapedReference, type ArgumentIssue } from "./arguments.js";
|
|
20
20
|
import { docsReadTarget, resolveDocsContentUrl, searchConnectedGuides } from "./docs.js";
|
|
21
|
+
import { projectFields, unmatchedFields, unmatchedFieldsMessage } from "./fields.js";
|
|
22
|
+
import { SEARCH_PAGE_SIZE, rankOperations } from "./search.js";
|
|
23
|
+
import { argumentPathText, findInputType, inputTypeText, namedTypesIn, type InputTypes } from "./type-docs.js";
|
|
24
|
+
|
|
25
|
+
export { projectFields } from "./fields.js";
|
|
21
26
|
|
|
22
27
|
export const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
23
28
|
export const LEGACY_PROTOCOL_VERSION = "2025-11-25";
|
|
@@ -97,7 +102,7 @@ export class McpAccountLinkRequired extends Error {
|
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
const API_LINK_INPUT = "
|
|
105
|
+
const API_LINK_INPUT = "api_account";
|
|
101
106
|
|
|
102
107
|
/** The subset of an operation spec (ops.ts / the hosted manifest) the
|
|
103
108
|
* protocol layer reads. */
|
|
@@ -114,7 +119,7 @@ export interface OpLike {
|
|
|
114
119
|
paginated: boolean;
|
|
115
120
|
/** GraphQL ops accept a raw selection-set override. */
|
|
116
121
|
select: boolean;
|
|
117
|
-
graphql?: { kind: string };
|
|
122
|
+
graphql?: { kind: string; field?: string };
|
|
118
123
|
params: {
|
|
119
124
|
name: string;
|
|
120
125
|
type: string;
|
|
@@ -122,6 +127,10 @@ export interface OpLike {
|
|
|
122
127
|
enum?: string[];
|
|
123
128
|
description?: string;
|
|
124
129
|
resolve?: false | ReferenceResolver;
|
|
130
|
+
/** A path argument that is the Basic-auth username (Twilio's AccountSid):
|
|
131
|
+
* optional, defaulting to the configured credential. `env` names the
|
|
132
|
+
* variable that supplies it, when the target has one. */
|
|
133
|
+
credential?: { from: "username"; env?: string };
|
|
125
134
|
}[];
|
|
126
135
|
inputSchema: Record<string, unknown>;
|
|
127
136
|
outputSchema?: Record<string, unknown>;
|
|
@@ -129,14 +138,24 @@ export interface OpLike {
|
|
|
129
138
|
safety?: "read" | "write" | "destructive";
|
|
130
139
|
/** Whether the operation accepts or requires an API credential. */
|
|
131
140
|
auth?: "required" | "optional" | "none";
|
|
141
|
+
/** The API Spec declares no security; the generic token is optional. */
|
|
142
|
+
authNotDeclared?: boolean;
|
|
143
|
+
/** Complete credential alternatives this runtime can send; empty when the
|
|
144
|
+
* operation's only schemes are unsupported (digest, mutual TLS, …). */
|
|
145
|
+
credentialOptions?: string[][];
|
|
146
|
+
security?: Record<string, string[]>[];
|
|
132
147
|
/** Schema-derived, valid wire arguments for examples and agent discovery. */
|
|
133
148
|
exampleArguments?: Record<string, unknown>;
|
|
149
|
+
/** The operation generated examples lead with: a safe read. */
|
|
150
|
+
showcase?: true;
|
|
134
151
|
/** Page-walking config (same shape as the SDK's PageConfig), when paginated. */
|
|
135
152
|
pagination?: { style: string; itemsField: string; cursorParam?: string; idField?: string; pageParam?: string; offsetParam?: string; limitParam?: string };
|
|
136
153
|
/** Success body is a text/event-stream. */
|
|
137
154
|
sse?: boolean;
|
|
138
155
|
/** Wire encoding of the request body. */
|
|
139
156
|
bodyKind?: string | null;
|
|
157
|
+
/** The spec marks the operation deprecated: search ranks it last. */
|
|
158
|
+
deprecated?: boolean;
|
|
140
159
|
}
|
|
141
160
|
|
|
142
161
|
/** Fully proved lookup metadata carried in ops.ts / the hosted manifest. */
|
|
@@ -271,16 +290,15 @@ export function checkRequestHeaders(headers: { get(name: string): string | null
|
|
|
271
290
|
const id = message.id;
|
|
272
291
|
const version = headers.get("mcp-protocol-version");
|
|
273
292
|
const bodyVersion = (message.params?._meta as Record<string, unknown> | undefined)?.[META_VERSION];
|
|
274
|
-
if (version !== null && !SUPPORTED_PROTOCOL_VERSIONS.includes(version)) return rpcError(id, -32022, "Unsupported protocol version", 400, { supported: SUPPORTED_PROTOCOL_VERSIONS, requested: version });
|
|
275
293
|
if (message.method === "initialize" || version === LEGACY_PROTOCOL_VERSION) {
|
|
276
294
|
if (bodyVersion !== undefined || headers.get("mcp-method") !== null || headers.get("mcp-name") !== null) {
|
|
277
295
|
return rpcError(id, -32020, "Header mismatch: initialize-handshake requests cannot carry modern protocol metadata or routing headers.", 400);
|
|
278
296
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
297
|
+
// initialize negotiates the revision in its body, so a header naming an
|
|
298
|
+
// older one is not an error here.
|
|
282
299
|
return null;
|
|
283
300
|
}
|
|
301
|
+
if (version !== null && !SUPPORTED_PROTOCOL_VERSIONS.includes(version)) return rpcError(id, -32022, "Unsupported protocol version", 400, { supported: SUPPORTED_PROTOCOL_VERSIONS, requested: version });
|
|
284
302
|
if (version === null) return rpcError(id, -32020, "Header mismatch: MCP-Protocol-Version header is required", 400);
|
|
285
303
|
if (typeof bodyVersion === "string" && version !== bodyVersion) {
|
|
286
304
|
return rpcError(id, -32020, "Header mismatch: MCP-Protocol-Version header '" + version + "' does not match body value '" + bodyVersion + "'", 400);
|
|
@@ -364,7 +382,10 @@ export async function handleRpc(server: McpServer, incoming: unknown, protocolVe
|
|
|
364
382
|
!info || typeof info !== "object" || Array.isArray(info) || typeof info.name !== "string" || typeof info.version !== "string") {
|
|
365
383
|
return rpcError(id, -32602, "initialize requires protocolVersion, capabilities, and clientInfo with name and version.", 400);
|
|
366
384
|
}
|
|
367
|
-
|
|
385
|
+
// Version negotiation: the server answers with a revision it supports
|
|
386
|
+
// (the requested one when it can), and a client that cannot speak it
|
|
387
|
+
// disconnects. So an older request (2025-06-18, 2025-03-26, 2024-11-05)
|
|
388
|
+
// gets 2025-11-25 rather than an error.
|
|
368
389
|
return { status: 200, message: { jsonrpc: "2.0", id, result: {
|
|
369
390
|
protocolVersion: LEGACY_PROTOCOL_VERSION,
|
|
370
391
|
capabilities: { tools: {} },
|
|
@@ -515,114 +536,6 @@ export function operationSafety(op: { httpMethod: string; method?: string; graph
|
|
|
515
536
|
: "write";
|
|
516
537
|
}
|
|
517
538
|
|
|
518
|
-
/** A deterministic, schema-valid-enough example for documentation and
|
|
519
|
-
* agent calls. Prefer facts supplied by the API author, then conservative
|
|
520
|
-
* values based on formats and field names. Only required object fields are
|
|
521
|
-
* included, keeping examples useful instead of manufacturing giant bodies. */
|
|
522
|
-
export function exampleFromSchema(schema: unknown, field = "value", depth = 0): unknown {
|
|
523
|
-
if (!schema || typeof schema !== "object" || depth > 8) return null;
|
|
524
|
-
const node = schema as Record<string, unknown>;
|
|
525
|
-
if (node.const !== undefined) return node.const;
|
|
526
|
-
if (node.example !== undefined) return node.example;
|
|
527
|
-
if (Array.isArray(node.examples) && node.examples.length > 0) return node.examples[0];
|
|
528
|
-
if (node.default !== undefined) return node.default;
|
|
529
|
-
if (Array.isArray(node.enum) && node.enum.length > 0) {
|
|
530
|
-
if (typeof node.pattern === "string") {
|
|
531
|
-
try {
|
|
532
|
-
const pattern = new RegExp(node.pattern);
|
|
533
|
-
const matching = node.enum.find((value) => typeof value === "string" && pattern.test(value));
|
|
534
|
-
if (matching !== undefined) return matching;
|
|
535
|
-
} catch { /* malformed patterns are ignored for examples */ }
|
|
536
|
-
}
|
|
537
|
-
return node.enum.find((v) => v !== null) ?? node.enum[0];
|
|
538
|
-
}
|
|
539
|
-
const variants = (Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : null) as unknown[] | null;
|
|
540
|
-
if (variants) {
|
|
541
|
-
const useful = variants.find((v) => v && typeof v === "object" && (v as Record<string, unknown>).type !== "null") ?? variants[0];
|
|
542
|
-
return exampleFromSchema(useful, field, depth + 1);
|
|
543
|
-
}
|
|
544
|
-
const type = Array.isArray(node.type) ? node.type.find((v) => v !== "null") : node.type;
|
|
545
|
-
if (type === "object" || node.properties || node.additionalProperties) {
|
|
546
|
-
const properties = (node.properties ?? {}) as Record<string, unknown>;
|
|
547
|
-
const required = new Set(Array.isArray(node.required) ? node.required.filter((v): v is string => typeof v === "string") : []);
|
|
548
|
-
// At the operation root, optional really means optional: the most honest
|
|
549
|
-
// runnable example is `{}`. Inside a required object, one representative
|
|
550
|
-
// optional field still makes an otherwise empty nested shape legible.
|
|
551
|
-
const names = required.size > 0 ? [...required] : depth === 0 ? [] : Object.keys(properties).slice(0, 1);
|
|
552
|
-
const value: Record<string, unknown> = {};
|
|
553
|
-
for (const name of names) {
|
|
554
|
-
if (properties[name] !== undefined) value[name] = exampleFromSchema(properties[name], name, depth + 1);
|
|
555
|
-
}
|
|
556
|
-
if (Object.keys(value).length === 0 && node.additionalProperties && typeof node.additionalProperties === "object") {
|
|
557
|
-
value.key = exampleFromSchema(node.additionalProperties, "key", depth + 1);
|
|
558
|
-
}
|
|
559
|
-
return value;
|
|
560
|
-
}
|
|
561
|
-
if (type === "array" || node.items) {
|
|
562
|
-
const count = typeof node.minItems === "number" && node.minItems > 1 ? Math.min(node.minItems, 3) : 1;
|
|
563
|
-
return Array.from({ length: count }, () => exampleFromSchema(node.items, field, depth + 1));
|
|
564
|
-
}
|
|
565
|
-
if (type === "integer" || type === "number") {
|
|
566
|
-
if (typeof node.minimum === "number") return node.minimum;
|
|
567
|
-
if (typeof node.exclusiveMinimum === "number") return node.exclusiveMinimum + 1;
|
|
568
|
-
return 1;
|
|
569
|
-
}
|
|
570
|
-
if (type === "boolean") return true;
|
|
571
|
-
if (type === "string" || type === undefined) {
|
|
572
|
-
const format = typeof node.format === "string" ? node.format : "";
|
|
573
|
-
const lower = field.toLowerCase();
|
|
574
|
-
const min = typeof node.minLength === "number" ? node.minLength : 0;
|
|
575
|
-
const max = typeof node.maxLength === "number" ? node.maxLength : undefined;
|
|
576
|
-
const patterned = typeof node.pattern === "string" ? exampleMatchingPattern(node.pattern, min, max) : null;
|
|
577
|
-
let value = patterned ?? (format === "date-time" ? "2026-01-15T12:00:00Z"
|
|
578
|
-
: format === "date" ? "2026-01-15"
|
|
579
|
-
: format === "email" || lower.includes("email") ? "person@example.com"
|
|
580
|
-
: (format === "uri" || format === "url" || lower.endsWith("url")) && (lower.includes("webhook") || lower.includes("callback")) ? "https://example.com/webhook"
|
|
581
|
-
: format === "uri" || format === "url" || lower.endsWith("url") ? "https://example.com"
|
|
582
|
-
: format === "uuid" ? "00000000-0000-4000-8000-000000000000"
|
|
583
|
-
: lower.includes("repository") || lower === "repo" ? "acme/api"
|
|
584
|
-
: lower.includes("path") ? "openapi.yaml"
|
|
585
|
-
: lower.includes("version") ? "1.0.0"
|
|
586
|
-
: /(^|_)id$|Id$/.test(field) ? (lower === "id" ? "id" : field.replace(/[_-]?id$/i, "")) + "_123"
|
|
587
|
-
: lower.includes("name") ? "example"
|
|
588
|
-
: "value");
|
|
589
|
-
while (value.length < min) value += "x";
|
|
590
|
-
if (max !== undefined) value = value.slice(0, max);
|
|
591
|
-
return value;
|
|
592
|
-
}
|
|
593
|
-
return null;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
/** A useful value for the common API-id pattern (`^agt_`, `^src_[a-z0-9]+$`).
|
|
597
|
-
* Full regex generation would be surprising and heavyweight; an anchored
|
|
598
|
-
* literal prefix plus ordinary id suffix covers the schemas that use a
|
|
599
|
-
* pattern to communicate a typed identifier. Every candidate is checked by
|
|
600
|
-
* the actual RegExp before it is returned. */
|
|
601
|
-
function exampleMatchingPattern(pattern: string, minLength: number, maxLength?: number): string | null {
|
|
602
|
-
let regex: RegExp;
|
|
603
|
-
try { regex = new RegExp(pattern); } catch { return null; }
|
|
604
|
-
const match = /^\^((?:\\.|[A-Za-z0-9_-])+)/.exec(pattern);
|
|
605
|
-
const prefix = match?.[1]?.replace(/\\(.)/g, "$1") ?? "";
|
|
606
|
-
const fit = (candidate: string): string => {
|
|
607
|
-
let value = candidate;
|
|
608
|
-
while (value.length < minLength) value += "x";
|
|
609
|
-
if (maxLength !== undefined) value = value.slice(0, maxLength);
|
|
610
|
-
return value;
|
|
611
|
-
};
|
|
612
|
-
const candidates = [prefix + "123", prefix + "example", prefix, "resource.method", "example.value", "example_123", "example", "value"];
|
|
613
|
-
for (const candidate of candidates) {
|
|
614
|
-
const value = fit(candidate);
|
|
615
|
-
regex.lastIndex = 0;
|
|
616
|
-
if (regex.test(value)) return value;
|
|
617
|
-
}
|
|
618
|
-
return null;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
export function exampleArgumentsFromSchema(inputSchema: Record<string, unknown>): Record<string, unknown> {
|
|
622
|
-
const value = exampleFromSchema(inputSchema, "arguments");
|
|
623
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
624
|
-
}
|
|
625
|
-
|
|
626
539
|
/** The `fields` argument every tool takes unless the API already has one:
|
|
627
540
|
* dotted paths to keep in the result (per item for paginated tools). */
|
|
628
541
|
export const FIELDS_ARGUMENT = "fields";
|
|
@@ -666,26 +579,25 @@ export function operationTool(op: OpLike): ToolDefinition {
|
|
|
666
579
|
};
|
|
667
580
|
}
|
|
668
581
|
|
|
669
|
-
|
|
670
|
-
export const SEARCH_PAGE_SIZE = 15;
|
|
582
|
+
export { SEARCH_PAGE_SIZE } from "./search.js";
|
|
671
583
|
|
|
672
584
|
export const SEARCH_DOCS_TOOL: ToolDefinition = {
|
|
673
585
|
name: "search_docs",
|
|
674
586
|
description: "Search this API's reference (operations, parameters) and, when a docs site is configured, its guides. Best matches first; page through with page.",
|
|
675
|
-
inputSchema: { type: "object", properties: { query: { type: "string" }, page: { type: "integer", minimum: 1, description: "Page of reference matches (
|
|
587
|
+
inputSchema: { type: "object", properties: { query: { type: "string", description: "The task in a few words, e.g. \"assign issue\" or \"list calls\"" }, page: { type: "integer", minimum: 1, description: "Page of reference matches (" + SEARCH_PAGE_SIZE + " per page), default 1" } }, required: ["query"] },
|
|
676
588
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
677
589
|
};
|
|
678
590
|
|
|
679
591
|
export const READ_DOCS_TOOL: ToolDefinition = {
|
|
680
592
|
name: "read_docs",
|
|
681
|
-
description: 'Read a documentation page: an operation reference (a tool name, or dotted "resource.method") or a docs-site guide page by name or URL.',
|
|
682
|
-
inputSchema: { type: "object", properties: { page: { type: "string" } }, required: ["page"] },
|
|
593
|
+
description: 'Read a documentation page: an operation reference (a tool name, or dotted "resource.method"), a named input type such as a filter, or a docs-site guide page by name or URL. Long pages come in parts; pass the offset a part ends with to continue.',
|
|
594
|
+
inputSchema: { type: "object", properties: { page: { type: "string" }, path: { type: "string", description: "A dotted argument path within the operation or type, e.g. \"filter.team.key\": that field's type, with its named type's fields or values" }, schema: { type: "boolean", description: "Also return the operation's complete input and output JSON Schemas, default false" }, offset: { type: "integer", minimum: 0, description: "Character offset to continue a long page from, default 0" } }, required: ["page"] },
|
|
683
595
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
684
596
|
};
|
|
685
597
|
|
|
686
598
|
export const EXECUTE_TOOL: ToolDefinition = {
|
|
687
599
|
name: "execute",
|
|
688
|
-
description: "Execute an API operation by name. Discover it with search_docs, then read_docs <operation> for its
|
|
600
|
+
description: "Execute an API operation by name. Discover it with search_docs, then read_docs <operation> for its arguments, example, and safety classification. Destructive operations require confirm: true.",
|
|
689
601
|
inputSchema: {
|
|
690
602
|
type: "object",
|
|
691
603
|
properties: {
|
|
@@ -698,6 +610,9 @@ export const EXECUTE_TOOL: ToolDefinition = {
|
|
|
698
610
|
annotations: { openWorldHint: false },
|
|
699
611
|
};
|
|
700
612
|
|
|
613
|
+
/** The keys execute itself takes; everything else is an operation argument. */
|
|
614
|
+
const EXECUTE_KEYS = ["operation", "arguments", "confirm"];
|
|
615
|
+
|
|
701
616
|
/** Which operations a server exposes, beyond the streams rule. */
|
|
702
617
|
export interface SurfaceOptions {
|
|
703
618
|
/** Hide every operation that is not a read (isReadOperation). */
|
|
@@ -716,14 +631,63 @@ export function visibleOps<T extends OpLike>(ops: T[], options: SurfaceOptions =
|
|
|
716
631
|
if (options.readOnly) out = out.filter(isReadOperation);
|
|
717
632
|
if (options.include && options.include.length > 0) {
|
|
718
633
|
const wanted = new Set(options.include.map((s) => s.trim().toLowerCase()).filter(Boolean));
|
|
719
|
-
out = out.filter((op) =>
|
|
720
|
-
wanted.has(op.tool.toLowerCase()) ||
|
|
721
|
-
(op.resource !== undefined && wanted.has(op.resource.toLowerCase())) ||
|
|
722
|
-
(op.resource !== undefined && op.method !== undefined && wanted.has((op.resource + "." + op.method).toLowerCase())));
|
|
634
|
+
out = out.filter((op) => includedBy(op, wanted));
|
|
723
635
|
}
|
|
724
636
|
return out;
|
|
725
637
|
}
|
|
726
638
|
|
|
639
|
+
function includedBy(op: OpLike, wanted: Set<string>): boolean {
|
|
640
|
+
return wanted.has(op.tool.toLowerCase()) ||
|
|
641
|
+
(op.resource !== undefined && wanted.has(op.resource.toLowerCase())) ||
|
|
642
|
+
(op.resource !== undefined && op.method !== undefined && wanted.has((op.resource + "." + op.method).toLowerCase()));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Entries of an include list that name no operation (a typo such as
|
|
646
|
+
* "pull" for "pulls"). Matched against every generated operation, so an
|
|
647
|
+
* entry the other switches hide still counts as a name. */
|
|
648
|
+
export function unmatchedIncludes(ops: OpLike[], include: string[] | undefined): string[] {
|
|
649
|
+
return (include ?? []).filter((entry) => !ops.some((op) => includedBy(op, new Set([entry.trim().toLowerCase()]))));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** An operation the server's own switches hide, with the reason, so a call
|
|
653
|
+
* to it says why instead of reading like a typo. */
|
|
654
|
+
export interface HiddenOperation {
|
|
655
|
+
op: OpLike;
|
|
656
|
+
message: string;
|
|
657
|
+
nextSteps: string[];
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The exposed operations that readOnly or include hide, each explained in
|
|
661
|
+
* terms of the switch the operator set (`switches` names them as the
|
|
662
|
+
* operator wrote them, such as "--read-only"). */
|
|
663
|
+
export function hiddenOperations(ops: OpLike[], options: SurfaceOptions, switches: { readOnly: string; include: string }): HiddenOperation[] {
|
|
664
|
+
const visible = new Set(visibleOps(ops, options));
|
|
665
|
+
return ops.filter((op) => !visible.has(op) && mcpExposed(op, { uploads: options.uploads })).map((op) => {
|
|
666
|
+
const included = !options.include?.length || includedBy(op, new Set(options.include.map((entry) => entry.trim().toLowerCase())));
|
|
667
|
+
return included
|
|
668
|
+
? {
|
|
669
|
+
op,
|
|
670
|
+
message: op.tool + " is a write, and this server is read-only (" + switches.readOnly + "), so it cannot be called here.",
|
|
671
|
+
nextSteps: ["Writes need a server started without " + switches.readOnly + "; tell the user if this operation is required."],
|
|
672
|
+
}
|
|
673
|
+
: {
|
|
674
|
+
op,
|
|
675
|
+
message: op.tool + " is not enabled on this server: " + switches.include + " limits it to " + (options.include ?? []).join(", ") + ".",
|
|
676
|
+
nextSteps: ["The operation needs a server whose " + switches.include + " includes " + (op.resource ?? op.tool) + "; tell the user if it is required."],
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function findHidden(source: DocsSource, wanted: string): HiddenOperation | undefined {
|
|
682
|
+
const hidden = source.hiddenOps ?? [];
|
|
683
|
+
const op = findOperation(hidden.map((h) => h.op), wanted);
|
|
684
|
+
return op ? hidden.find((h) => h.op === op) : undefined;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function hiddenOutcome(hidden: HiddenOperation): ToolOutcome {
|
|
688
|
+
return textError(hidden.message, "NOT_AVAILABLE", hidden.nextSteps);
|
|
689
|
+
}
|
|
690
|
+
|
|
727
691
|
/** Parse a comma-separated include list (from an env var or a flag). */
|
|
728
692
|
export function parseIncludeList(value: string | undefined | null): string[] | undefined {
|
|
729
693
|
const list = (value ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
@@ -739,13 +703,14 @@ export function toolDefinitions(ops: OpLike[], mode: "operations" | "meta", omit
|
|
|
739
703
|
if (mode === "meta") {
|
|
740
704
|
const execute = structuredClone(EXECUTE_TOOL);
|
|
741
705
|
const operation = (execute.inputSchema.properties as Record<string, Record<string, unknown>>).operation!;
|
|
742
|
-
|
|
706
|
+
const example = ops.find((op) => op.showcase) ?? ops[0];
|
|
707
|
+
if (example) operation.examples = [example.tool];
|
|
743
708
|
const coverage = omittedOps.length > 0
|
|
744
|
-
? "
|
|
709
|
+
? " This build includes " + ops.length + " of " + (ops.length + omittedOps.length) + " operations."
|
|
745
710
|
: "";
|
|
746
711
|
return [
|
|
747
712
|
{ ...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 },
|
|
748
|
-
{ ...READ_DOCS_TOOL, description: "Read an operation's
|
|
713
|
+
{ ...READ_DOCS_TOOL, description: "Read an operation's reference (arguments, authentication, safety, an example and the result's shape) by tool name, a named input type, or a docs-site guide page. path narrows to one nested argument, e.g. \"filter.team.key\"; schema: true adds the complete JSON Schemas." },
|
|
749
714
|
execute,
|
|
750
715
|
];
|
|
751
716
|
}
|
|
@@ -766,6 +731,55 @@ export function missingArguments(op: OpLike, args: Record<string, unknown>): str
|
|
|
766
731
|
|
|
767
732
|
// ---- server instructions ----------------------------------------------------------
|
|
768
733
|
|
|
734
|
+
/** Identity fields that name the caller for a login-shaped "me" (GitHub's
|
|
735
|
+
* users_get_authenticated returns login). */
|
|
736
|
+
const IDENTITY_LOGIN_FIELDS = ["login", "username", "handle"];
|
|
737
|
+
|
|
738
|
+
const MAX_ME_SCHEMA_DEPTH = 6;
|
|
739
|
+
|
|
740
|
+
function acceptsString(schema: unknown): boolean {
|
|
741
|
+
if (!schema || typeof schema !== "object") return false;
|
|
742
|
+
const record = schema as Record<string, unknown>;
|
|
743
|
+
const type = record.type;
|
|
744
|
+
if (type === "string" || (Array.isArray(type) && type.includes("string"))) return true;
|
|
745
|
+
const variants = record.anyOf ?? record.oneOf;
|
|
746
|
+
return Array.isArray(variants) && variants.some(acceptsString);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function objectVariants(schema: unknown): Record<string, unknown>[] {
|
|
750
|
+
if (!schema || typeof schema !== "object") return [];
|
|
751
|
+
const record = schema as Record<string, unknown>;
|
|
752
|
+
const variants = record.anyOf ?? record.oneOf;
|
|
753
|
+
return [record, ...(Array.isArray(variants) ? variants.flatMap(objectVariants) : [])]
|
|
754
|
+
.filter((candidate) => candidate.properties && typeof candidate.properties === "object");
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Every argument path where the server resolves "me": a user-shaped string
|
|
758
|
+
* (or a list or union that takes one) at the top level or inside an object
|
|
759
|
+
* argument. The instructions name exactly these. */
|
|
760
|
+
export function meReferenceArguments(ops: OpLike[]): string[] {
|
|
761
|
+
const found = new Set<string>();
|
|
762
|
+
const visit = (name: string, schema: unknown, path: string, depth: number): void => {
|
|
763
|
+
if (!schema || typeof schema !== "object" || depth > MAX_ME_SCHEMA_DEPTH) return;
|
|
764
|
+
const record = schema as Record<string, unknown>;
|
|
765
|
+
const items = record.items ?? (Array.isArray(record.anyOf ?? record.oneOf) ? ((record.anyOf ?? record.oneOf) as Record<string, unknown>[]).find((variant) => variant && variant.items)?.items : undefined);
|
|
766
|
+
if (userShapedReference(name) && (acceptsString(record) || acceptsString(items))) found.add(path);
|
|
767
|
+
const nested: [Record<string, unknown>[], string][] = [[objectVariants(record), path], [objectVariants(items), path + "[]"]];
|
|
768
|
+
for (const [variants, prefix] of nested) {
|
|
769
|
+
for (const variant of variants) {
|
|
770
|
+
for (const [key, child] of Object.entries(variant.properties as Record<string, unknown>)) visit(key, child, prefix + "." + key, depth + 1);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
for (const op of ops) {
|
|
775
|
+
const properties = (op.inputSchema.properties ?? {}) as Record<string, unknown>;
|
|
776
|
+
for (const param of op.params) {
|
|
777
|
+
if (param.resolve !== false) visit(param.name, properties[param.name], param.name, 0);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return [...found].sort((a, b) => a.split(".").length - b.split(".").length || a.localeCompare(b));
|
|
781
|
+
}
|
|
782
|
+
|
|
769
783
|
export interface InstructionsInput {
|
|
770
784
|
title: string;
|
|
771
785
|
/** Callable operations, used to advertise only capabilities the surface has. */
|
|
@@ -802,12 +816,13 @@ export function serverInstructions(input: InstructionsInput): string {
|
|
|
802
816
|
: 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.");
|
|
803
817
|
if (input.omittedOps?.length) {
|
|
804
818
|
const generated = input.generatedOperationCount ?? input.toolCount;
|
|
805
|
-
parts.push("
|
|
819
|
+
parts.push("This build includes " + generated + " of " + (generated + input.omittedOps.length) + " operations; calling or searching for one of the others returns PLAN_LIMIT.");
|
|
806
820
|
}
|
|
807
821
|
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).");
|
|
808
822
|
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.");
|
|
809
|
-
|
|
810
|
-
|
|
823
|
+
const meArguments = input.identityTool ? meReferenceArguments(input.ops) : [];
|
|
824
|
+
if (meArguments.length > 0) {
|
|
825
|
+
parts.push("These arguments also accept \"me\" for the caller, resolved through " + input.identityTool + " (ID arguments take its id, others its login when it has one): " + meArguments.join(", ") + ".");
|
|
811
826
|
}
|
|
812
827
|
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.");
|
|
813
828
|
parts.push("Errors carry error, code, message, status, the API's body and next_steps.");
|
|
@@ -823,11 +838,8 @@ export function serverInstructions(input: InstructionsInput): string {
|
|
|
823
838
|
|
|
824
839
|
// ---- argument validation + coercion ---------------------------------------------
|
|
825
840
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
argument: string;
|
|
829
|
-
message: string;
|
|
830
|
-
}
|
|
841
|
+
// The checks themselves live in ./arguments, shared with the CLI.
|
|
842
|
+
export { checkValue, closestName, coerceValue, type ArgumentIssue } from "./arguments.js";
|
|
831
843
|
|
|
832
844
|
/** Everything a tool call needs after its arguments were checked: the
|
|
833
845
|
* arguments to send (coerced, projection stripped), and how to shape the
|
|
@@ -836,138 +848,29 @@ export interface PreparedCall {
|
|
|
836
848
|
args: Record<string, unknown>;
|
|
837
849
|
/** Dotted paths from the `fields` argument, null for the whole result. */
|
|
838
850
|
fields: string[][] | null;
|
|
851
|
+
/** Of those, the dotted paths a result may lack (written "?path"). */
|
|
852
|
+
optionalFields: string[];
|
|
839
853
|
maxChars: number;
|
|
840
854
|
}
|
|
841
855
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
for (let i = 1; i <= a.length; i++) {
|
|
847
|
-
let diag = prev[0]!;
|
|
848
|
-
prev[0] = i;
|
|
849
|
-
for (let j = 1; j <= b.length; j++) {
|
|
850
|
-
const tmp = prev[j]!;
|
|
851
|
-
prev[j] = Math.min(prev[j]! + 1, prev[j - 1]! + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
852
|
-
diag = tmp;
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
return prev[b.length]!;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
/** The closest accepted name: same letters ignoring case/punctuation first,
|
|
859
|
-
* then a small edit distance. Undefined when nothing is close. */
|
|
860
|
-
export function closestName(name: string, known: string[]): string | undefined {
|
|
861
|
-
const exact = known.filter((k) => normalizeName(k) === normalizeName(name));
|
|
862
|
-
if (exact.length === 1) return exact[0];
|
|
863
|
-
if (exact.length > 1) return undefined;
|
|
864
|
-
let best: { name: string; d: number } | undefined;
|
|
865
|
-
for (const k of known) {
|
|
866
|
-
const d = editDistance(name.toLowerCase(), k.toLowerCase());
|
|
867
|
-
if (d <= Math.max(1, Math.floor(k.length / 4)) && (best === undefined || d < best.d)) best = { name: k, d };
|
|
868
|
-
}
|
|
869
|
-
return best?.name;
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function schemaTypes(schema: Record<string, unknown>): string[] {
|
|
873
|
-
const t = schema.type;
|
|
874
|
-
if (typeof t === "string") return [t];
|
|
875
|
-
if (Array.isArray(t)) return t.filter((x): x is string => typeof x === "string");
|
|
876
|
-
return [];
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/**
|
|
880
|
-
* Coerce one value toward its schema when the intent is unambiguous: the
|
|
881
|
-
* strings agents produce for booleans and numbers, a JSON string for an
|
|
882
|
-
* object or array, a scalar for a one-element array, an enum member in the
|
|
883
|
-
* wrong case. Returns the value to send, or a message when it can't be made
|
|
884
|
-
* to fit. Untyped schemas (unions, anything) pass through.
|
|
885
|
-
*/
|
|
886
|
-
export function coerceValue(value: unknown, schema: Record<string, unknown>): { value: unknown } | { error: string } {
|
|
887
|
-
if (value === null || value === undefined) return { value };
|
|
888
|
-
// Date-shaped arguments take relative forms (-P7D, 7 days ago, today),
|
|
889
|
-
// resolved here so the API sees an absolute value.
|
|
890
|
-
const dateKind = dateKindOf(schema.format);
|
|
891
|
-
if (dateKind && typeof value === "string") {
|
|
892
|
-
const resolved = relativeDate(value, dateKind);
|
|
893
|
-
if (resolved && "error" in resolved) return { error: resolved.error };
|
|
894
|
-
if (resolved) value = resolved.value;
|
|
895
|
-
}
|
|
896
|
-
const types = schemaTypes(schema);
|
|
897
|
-
const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
|
|
898
|
-
const accepts = (t: string) => types.length === 0 || types.includes(t);
|
|
899
|
-
const kind = Array.isArray(value) ? "array" : typeof value;
|
|
900
|
-
|
|
901
|
-
let out: unknown = value;
|
|
902
|
-
if (types.length > 0) {
|
|
903
|
-
if (kind === "boolean" && !accepts("boolean")) {
|
|
904
|
-
if (accepts("string")) out = String(value);
|
|
905
|
-
else return { error: "expected " + types.join(" or ") + ", got boolean" };
|
|
906
|
-
} else if (kind === "number" && !accepts("number") && !accepts("integer")) {
|
|
907
|
-
if (accepts("string")) out = String(value);
|
|
908
|
-
else if (accepts("array")) out = [value];
|
|
909
|
-
else return { error: "expected " + types.join(" or ") + ", got number" };
|
|
910
|
-
} else if (kind === "number" && accepts("integer") && !accepts("number") && !Number.isInteger(value)) {
|
|
911
|
-
return { error: "expected an integer, got " + String(value) };
|
|
912
|
-
} else if (kind === "string" && !accepts("string")) {
|
|
913
|
-
const s = (value as string).trim();
|
|
914
|
-
if (accepts("boolean") && /^(true|false|yes|no|1|0)$/i.test(s)) out = /^(true|yes|1)$/i.test(s);
|
|
915
|
-
else if ((accepts("integer") || accepts("number")) && s !== "" && !Number.isNaN(Number(s))) {
|
|
916
|
-
const n = Number(s);
|
|
917
|
-
if (accepts("integer") && !accepts("number") && !Number.isInteger(n)) return { error: "expected an integer, got \"" + s + "\"" };
|
|
918
|
-
out = n;
|
|
919
|
-
} else if ((accepts("object") || accepts("array")) && /^[[{]/.test(s)) {
|
|
920
|
-
try {
|
|
921
|
-
const parsed: unknown = JSON.parse(s);
|
|
922
|
-
const parsedKind = Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed;
|
|
923
|
-
if (!accepts(parsedKind)) return { error: "expected " + types.join(" or ") + ", got a JSON " + parsedKind + " in a string" };
|
|
924
|
-
out = parsed;
|
|
925
|
-
} catch {
|
|
926
|
-
return { error: "expected " + types.join(" or ") + ", got a string that is not valid JSON" };
|
|
927
|
-
}
|
|
928
|
-
} else if (accepts("array")) {
|
|
929
|
-
out = [value];
|
|
930
|
-
} else {
|
|
931
|
-
return { error: "expected " + types.join(" or ") + ", got string" };
|
|
932
|
-
}
|
|
933
|
-
} else if (kind === "object" && !accepts("object")) {
|
|
934
|
-
if (accepts("array")) out = [value];
|
|
935
|
-
else return { error: "expected " + types.join(" or ") + ", got object" };
|
|
936
|
-
} else if (kind === "array" && !accepts("array")) {
|
|
937
|
-
return { error: "expected " + types.join(" or ") + ", got array" };
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
// Array items: coerce each against the items schema when it has one.
|
|
942
|
-
if (Array.isArray(out) && schema.items && typeof schema.items === "object" && !Array.isArray(schema.items)) {
|
|
943
|
-
const itemSchema = schema.items as Record<string, unknown>;
|
|
944
|
-
const items: unknown[] = [];
|
|
945
|
-
for (let i = 0; i < out.length; i++) {
|
|
946
|
-
const r = coerceValue(out[i], itemSchema);
|
|
947
|
-
if ("error" in r) return { error: "item " + i + ": " + r.error };
|
|
948
|
-
items.push(r.value);
|
|
949
|
-
}
|
|
950
|
-
out = items;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
if (enumValues && typeof out === "string" && !enumValues.includes(out)) {
|
|
954
|
-
const match = enumValues.filter((e) => typeof e === "string" && e.toLowerCase() === (out as string).toLowerCase());
|
|
955
|
-
if (match.length === 1) out = match[0];
|
|
956
|
-
else return { error: "must be one of " + enumValues.map((e) => JSON.stringify(e)).join(", ") + ", got " + JSON.stringify(out) };
|
|
957
|
-
}
|
|
958
|
-
return { value: out };
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
function parseFields(value: unknown): string[][] | { error: string } {
|
|
856
|
+
/** Field paths, and which of them may be absent: a leading "?" marks a
|
|
857
|
+
* path the result may lack, as reference resolution's list calls ask for
|
|
858
|
+
* match keys an API can omit. Any other path must match something. */
|
|
859
|
+
function parseFields(value: unknown): { paths: string[][]; optional: string[] } | { error: string } {
|
|
962
860
|
const raw = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : null;
|
|
963
861
|
if (raw === null) return { error: "expected an array of field paths, e.g. [\"id\",\"name\"]" };
|
|
964
862
|
const paths: string[][] = [];
|
|
863
|
+
const optional: string[] = [];
|
|
965
864
|
for (const entry of raw) {
|
|
966
865
|
if (typeof entry !== "string") return { error: "expected an array of strings" };
|
|
967
|
-
|
|
866
|
+
let path = entry.trim();
|
|
867
|
+
if (path.startsWith("?")) {
|
|
868
|
+
path = path.slice(1).trim();
|
|
869
|
+
if (path !== "") optional.push(path);
|
|
870
|
+
}
|
|
968
871
|
if (path !== "") paths.push(path.split("."));
|
|
969
872
|
}
|
|
970
|
-
return paths;
|
|
873
|
+
return { paths, optional };
|
|
971
874
|
}
|
|
972
875
|
|
|
973
876
|
/**
|
|
@@ -982,7 +885,7 @@ function parseFields(value: unknown): string[][] | { error: string } {
|
|
|
982
885
|
export function prepareCall(
|
|
983
886
|
op: OpLike,
|
|
984
887
|
rawArgs: Record<string, unknown>,
|
|
985
|
-
options: { maxChars?: number } = {},
|
|
888
|
+
options: { maxChars?: number; credentialUsername?: string | null } = {},
|
|
986
889
|
): { ok: true; call: PreparedCall } | { ok: false; outcome: ToolOutcome } {
|
|
987
890
|
const schema = toolInputSchema(op);
|
|
988
891
|
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
|
@@ -1010,19 +913,38 @@ export function prepareCall(
|
|
|
1010
913
|
}
|
|
1011
914
|
|
|
1012
915
|
let fields: string[][] | null = null;
|
|
916
|
+
let optionalFields: string[] = [];
|
|
1013
917
|
if (!hasOwnFieldsParam(op) && args[FIELDS_ARGUMENT] !== undefined) {
|
|
1014
918
|
const parsed = parseFields(args[FIELDS_ARGUMENT]);
|
|
1015
919
|
if ("error" in parsed) issues.push({ code: "INVALID_ARGUMENT", argument: FIELDS_ARGUMENT, message: "fields: " + parsed.error });
|
|
1016
|
-
else
|
|
920
|
+
else if (parsed.paths.length > 0) ({ paths: fields, optional: optionalFields } = parsed);
|
|
1017
921
|
delete args[FIELDS_ARGUMENT];
|
|
1018
922
|
}
|
|
1019
923
|
|
|
1020
924
|
for (const [name, value] of Object.entries(args)) {
|
|
1021
925
|
const propSchema = properties[name];
|
|
1022
926
|
if (!propSchema) continue;
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
927
|
+
// Patterns apply inside objects and arrays. A top-level argument's own
|
|
928
|
+
// pattern is left to the API: its documented example values do not all
|
|
929
|
+
// satisfy it yet, and a name or "me" is only resolved to an ID later.
|
|
930
|
+
args[name] = checkValue(value, propSchema, name, issues, { skipPattern: true, meName: name });
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// A path argument that is the configured credential's username (Twilio's
|
|
934
|
+
// AccountSid) defaults to it; without one it is an ordinary missing argument
|
|
935
|
+
// whose message says where the default would come from.
|
|
936
|
+
for (const param of op.params) {
|
|
937
|
+
if (!param.credential || args[param.name] !== undefined) continue;
|
|
938
|
+
const username = options.credentialUsername ?? undefined;
|
|
939
|
+
if (username !== undefined && username !== "") {
|
|
940
|
+
args[param.name] = username;
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
issues.push({
|
|
944
|
+
code: "MISSING_ARGUMENT",
|
|
945
|
+
argument: param.name,
|
|
946
|
+
message: "Missing required argument \"" + param.name + "\": it defaults to the Basic-auth username" + (param.credential.env ? " (" + param.credential.env + ")" : "") + ", and none is configured. Pass " + param.name + ", or configure the credential.",
|
|
947
|
+
});
|
|
1026
948
|
}
|
|
1027
949
|
|
|
1028
950
|
const required = Array.isArray(schema.required) ? (schema.required as string[]) : [];
|
|
@@ -1033,7 +955,7 @@ export function prepareCall(
|
|
|
1033
955
|
}
|
|
1034
956
|
|
|
1035
957
|
if (issues.length > 0) return { ok: false, outcome: argumentsError(op, issues) };
|
|
1036
|
-
return { ok: true, call: { args, fields, maxChars: options.maxChars ?? DEFAULT_MAX_RESULT_CHARS } };
|
|
958
|
+
return { ok: true, call: { args, fields, optionalFields, maxChars: options.maxChars ?? DEFAULT_MAX_RESULT_CHARS } };
|
|
1037
959
|
}
|
|
1038
960
|
|
|
1039
961
|
/** One caller/session's resolved names. Hosted transports must scope this
|
|
@@ -1099,11 +1021,6 @@ function referencePage(value: unknown): { items: Record<string, unknown>[]; next
|
|
|
1099
1021
|
};
|
|
1100
1022
|
}
|
|
1101
1023
|
|
|
1102
|
-
function userShapedReference(name: string): boolean {
|
|
1103
|
-
const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/id$/, "");
|
|
1104
|
-
return ["user", "assignee", "owner", "member", "actor", "creator", "account", "profile"].includes(normalized);
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
1024
|
function referenceMatchLabel(fields: string[]): string {
|
|
1108
1025
|
return fields.length === 1 ? fields[0]! : fields.slice(0, -1).join(", ") + " or " + fields.at(-1);
|
|
1109
1026
|
}
|
|
@@ -1141,31 +1058,75 @@ export async function resolveReferences(
|
|
|
1141
1058
|
): Promise<ResolveReferencesResult> {
|
|
1142
1059
|
const args = { ...preparedArgs };
|
|
1143
1060
|
const maxPages = Math.max(1, Math.floor(options.maxPages ?? 5));
|
|
1061
|
+
const identity = options.identityTool ? findOperation(options.ops, options.identityTool) : undefined;
|
|
1062
|
+
let identityBody: Record<string, unknown> | undefined;
|
|
1063
|
+
/** The caller's ID, or login for a login-shaped name (GitHub's owner and
|
|
1064
|
+
* assignees), for "me": one identity call per cache. */
|
|
1065
|
+
const callerId = async (argument: string, key: string): Promise<{ id: string | number } | { outcome: ToolOutcome }> => {
|
|
1066
|
+
const field = meIdentityField(key);
|
|
1067
|
+
const cacheKey = "me:" + identity!.tool + (field === "id" ? "" : ":" + field);
|
|
1068
|
+
const cached = options.cache.get(cacheKey);
|
|
1069
|
+
if (cached !== undefined) return { id: cached };
|
|
1070
|
+
if (!identityBody) {
|
|
1071
|
+
const outcome = await options.runOperation(identity!, {});
|
|
1072
|
+
if (outcome.isError) return { outcome };
|
|
1073
|
+
const body = outcomeValue(outcome);
|
|
1074
|
+
identityBody = body && typeof body === "object" && !Array.isArray(body) ? body as Record<string, unknown> : {};
|
|
1075
|
+
}
|
|
1076
|
+
const login = IDENTITY_LOGIN_FIELDS.map((name) => identityBody![name]).find((value) => typeof value === "string" && value !== "");
|
|
1077
|
+
const id = field === "login" && login !== undefined ? login : identityBody.id;
|
|
1078
|
+
if (typeof id !== "string" && typeof id !== "number") {
|
|
1079
|
+
const expected = field === "id" ? "a top-level id" : "a top-level " + IDENTITY_LOGIN_FIELDS.join(", ") + " or id";
|
|
1080
|
+
return { outcome: referenceError("NOT_AVAILABLE", `${identity!.tool} did not return ${expected}, so "me" cannot be resolved for ${argument}.`, argument, [field === "id" ? "Pass the caller's exact ID instead." : "Pass the caller's exact login instead."]) };
|
|
1081
|
+
}
|
|
1082
|
+
putReferenceCache(options.cache, cacheKey, id);
|
|
1083
|
+
return { id };
|
|
1084
|
+
};
|
|
1085
|
+
/** "me" inside an object or array argument (a GraphQL input's assigneeId,
|
|
1086
|
+
* each of its subscriberIds, each of GitHub's assignees): the same
|
|
1087
|
+
* resolution, keyed by property name. */
|
|
1088
|
+
const resolveNestedMe = async (value: unknown, key: string, path: string): Promise<{ value: unknown } | { outcome: ToolOutcome }> => {
|
|
1089
|
+
if (typeof value === "string") {
|
|
1090
|
+
if (!isMeReference(key, value)) return { value };
|
|
1091
|
+
const caller = await callerId(path, key);
|
|
1092
|
+
return "outcome" in caller ? caller : { value: caller.id };
|
|
1093
|
+
}
|
|
1094
|
+
if (Array.isArray(value)) {
|
|
1095
|
+
const items: unknown[] = [];
|
|
1096
|
+
for (let i = 0; i < value.length; i++) {
|
|
1097
|
+
const item = await resolveNestedMe(value[i], key, path + "[" + i + "]");
|
|
1098
|
+
if ("outcome" in item) return item;
|
|
1099
|
+
items.push(item.value);
|
|
1100
|
+
}
|
|
1101
|
+
return { value: items };
|
|
1102
|
+
}
|
|
1103
|
+
if (value && typeof value === "object") {
|
|
1104
|
+
const out: Record<string, unknown> = {};
|
|
1105
|
+
for (const [name, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
1106
|
+
const resolved = await resolveNestedMe(entry, name, path + "." + name);
|
|
1107
|
+
if ("outcome" in resolved) return resolved;
|
|
1108
|
+
out[name] = resolved.value;
|
|
1109
|
+
}
|
|
1110
|
+
return { value: out };
|
|
1111
|
+
}
|
|
1112
|
+
return { value };
|
|
1113
|
+
};
|
|
1144
1114
|
for (const param of op.params) {
|
|
1145
1115
|
const raw = args[param.name];
|
|
1116
|
+
if (identity && raw && typeof raw === "object" && param.resolve !== false) {
|
|
1117
|
+
const resolved = await resolveNestedMe(raw, param.name, param.name);
|
|
1118
|
+
if ("outcome" in resolved) return { ok: false, outcome: resolved.outcome };
|
|
1119
|
+
args[param.name] = resolved.value;
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1146
1122
|
if (typeof raw !== "string" || param.resolve === false) continue;
|
|
1147
1123
|
const value = raw.trim();
|
|
1148
1124
|
|
|
1149
|
-
if (
|
|
1150
|
-
const
|
|
1151
|
-
if (
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
if (cached !== undefined) {
|
|
1155
|
-
args[param.name] = cached;
|
|
1156
|
-
continue;
|
|
1157
|
-
}
|
|
1158
|
-
const outcome = await options.runOperation(identity, {});
|
|
1159
|
-
if (outcome.isError) return { ok: false, outcome };
|
|
1160
|
-
const body = outcomeValue(outcome);
|
|
1161
|
-
const id = body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>).id : undefined;
|
|
1162
|
-
if (typeof id !== "string" && typeof id !== "number") {
|
|
1163
|
-
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."]) };
|
|
1164
|
-
}
|
|
1165
|
-
putReferenceCache(options.cache, cacheKey, id);
|
|
1166
|
-
args[param.name] = id;
|
|
1167
|
-
continue;
|
|
1168
|
-
}
|
|
1125
|
+
if (identity && isMeReference(param.name, value)) {
|
|
1126
|
+
const caller = await callerId(param.name, param.name);
|
|
1127
|
+
if ("outcome" in caller) return { ok: false, outcome: caller.outcome };
|
|
1128
|
+
args[param.name] = caller.id;
|
|
1129
|
+
continue;
|
|
1169
1130
|
}
|
|
1170
1131
|
|
|
1171
1132
|
const resolver = param.resolve && typeof param.resolve === "object" ? param.resolve : undefined;
|
|
@@ -1185,7 +1146,7 @@ export async function resolveReferences(
|
|
|
1185
1146
|
if (args[sourceParam.name] !== undefined && sourceParam.name !== param.name) pageArgs[sourceParam.name] = args[sourceParam.name];
|
|
1186
1147
|
}
|
|
1187
1148
|
if (resolver.filterParam) pageArgs[resolver.filterParam] = value;
|
|
1188
|
-
if (!hasOwnFieldsParam(source)) pageArgs.fields = [resolver.id, ...resolver.match];
|
|
1149
|
+
if (!hasOwnFieldsParam(source)) pageArgs.fields = [resolver.id, ...resolver.match.map((field) => "?" + field)];
|
|
1189
1150
|
|
|
1190
1151
|
let exhausted = false;
|
|
1191
1152
|
for (let pageNumber = 1; pageNumber <= maxPages; pageNumber++) {
|
|
@@ -1242,29 +1203,6 @@ export function argumentsError(op: { tool: string }, issues: ArgumentIssue[]): T
|
|
|
1242
1203
|
|
|
1243
1204
|
// ---- results: projection, size cap, envelopes ------------------------------------
|
|
1244
1205
|
|
|
1245
|
-
/** Keep only `paths` of a value: arrays item by item, objects by dotted
|
|
1246
|
-
* path, including paths through arrays (`items.id` keeps each item's id);
|
|
1247
|
-
* scalars untouched. Same rule as the CLI's --fields. */
|
|
1248
|
-
export function projectFields(value: unknown, paths: string[][] | null): unknown {
|
|
1249
|
-
if (paths === null) return value;
|
|
1250
|
-
if (Array.isArray(value)) return value.map((v) => projectFields(v, paths));
|
|
1251
|
-
if (value === null || typeof value !== "object") return value;
|
|
1252
|
-
const groups = new Map<string, string[][]>();
|
|
1253
|
-
for (const [key, ...rest] of paths) {
|
|
1254
|
-
if (key === undefined) continue;
|
|
1255
|
-
const group = groups.get(key);
|
|
1256
|
-
if (group) group.push(rest); else groups.set(key, [rest]);
|
|
1257
|
-
}
|
|
1258
|
-
const out: Record<string, unknown> = {};
|
|
1259
|
-
for (const [key, rests] of groups) {
|
|
1260
|
-
const child = (value as Record<string, unknown>)[key];
|
|
1261
|
-
if (child === undefined) continue;
|
|
1262
|
-
if (rests.some((rest) => rest.length === 0)) out[key] = child;
|
|
1263
|
-
else if (child !== null && typeof child === "object") out[key] = projectFields(child, rests);
|
|
1264
|
-
}
|
|
1265
|
-
return out;
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
1206
|
/** How a result was shaped; every field optional so callers can pass what they have. */
|
|
1269
1207
|
export interface ResultOptions {
|
|
1270
1208
|
fields?: string[][] | null;
|
|
@@ -1276,6 +1214,69 @@ export interface ResultOptions {
|
|
|
1276
1214
|
* the call's arguments let some styles resume exactly where the cut fell. */
|
|
1277
1215
|
pagination?: OpLike["pagination"];
|
|
1278
1216
|
args?: Record<string, unknown>;
|
|
1217
|
+
/** The operation's effect. When fields match nothing, a write's full
|
|
1218
|
+
* result rides along in the error so the agent never repeats the call to
|
|
1219
|
+
* see what it did. */
|
|
1220
|
+
safety?: "read" | "write" | "destructive";
|
|
1221
|
+
/** Paths the result may lack, such as a server's default projection:
|
|
1222
|
+
* they are left out rather than reported. */
|
|
1223
|
+
optionalFields?: string[];
|
|
1224
|
+
/** The tool's outputSchema. A fields path it declares may be absent from
|
|
1225
|
+
* the result (an optional key) without being reported; a page checks its
|
|
1226
|
+
* items against the schema's items. */
|
|
1227
|
+
outputSchema?: Record<string, unknown>;
|
|
1228
|
+
/** Arguments nextPage must repeat from args (required path parameters
|
|
1229
|
+
* such as owner and repo; fields is repeated too): the next-page
|
|
1230
|
+
* parameters alone, a page_url for instance, would fail validation when
|
|
1231
|
+
* passed back. */
|
|
1232
|
+
carryArgs?: string[];
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** A fields path that selects nothing is an error that names the keys
|
|
1236
|
+
* that exist, not a silent {}. A path the response schema declares is not
|
|
1237
|
+
* one: an optional key no item has is simply absent. The API call already
|
|
1238
|
+
* happened, so the error says so and carries the unprojected result (as
|
|
1239
|
+
* much as fits under half the cap), so neither a read nor a write has to
|
|
1240
|
+
* run again to see it. */
|
|
1241
|
+
function unmatchedFieldsOutcome(value: unknown, fields: string[][] | null, perItem: boolean, options: ResultOptions, schema: unknown, page?: { nextPage: Record<string, unknown> | null }): ToolOutcome | null {
|
|
1242
|
+
if (fields === null) return null;
|
|
1243
|
+
const optional = new Set(options.optionalFields ?? []);
|
|
1244
|
+
const unmatched = unmatchedFields(value, fields, schema).filter((u) => !optional.has(u.path));
|
|
1245
|
+
if (unmatched.length === 0) return null;
|
|
1246
|
+
const write = options.safety !== "read";
|
|
1247
|
+
const maxChars = options.maxChars ?? DEFAULT_MAX_RESULT_CHARS;
|
|
1248
|
+
const retained = retainedResult(value, Math.floor(maxChars / 2));
|
|
1249
|
+
const steps: string[] = [];
|
|
1250
|
+
if (write) steps.push("This operation has already run; do not call it again to change fields." + (retained.whole ? " Its full result is in result." : retained.result !== undefined ? " The first items of its result are in result." : ""));
|
|
1251
|
+
else if (retained.whole) steps.push("The full result is in result; use it rather than calling again.");
|
|
1252
|
+
else if (retained.result !== undefined) steps.push("result holds the first " + (retained.result as unknown[]).length + " of " + (value as unknown[]).length + " items; the rest are over the size cap.");
|
|
1253
|
+
steps.push((write ? "Next time, use" : "To project a later call, use") + " fields from the available keys" + (perItem ? " (fields apply to each item)" : "") + ", or omit fields for the whole result.");
|
|
1254
|
+
const structured = {
|
|
1255
|
+
error: "UnmatchedFields",
|
|
1256
|
+
code: "FIELDS_UNMATCHED",
|
|
1257
|
+
message: unmatchedFieldsMessage(unmatched, perItem),
|
|
1258
|
+
unmatched,
|
|
1259
|
+
...(retained.result !== undefined ? { result: retained.result } : {}),
|
|
1260
|
+
...(retained.omitted ? { result_omitted: retained.omitted } : {}),
|
|
1261
|
+
...(page ? { hasMore: page.nextPage !== null, ...(page.nextPage !== null ? { nextPage: page.nextPage } : {}) } : {}),
|
|
1262
|
+
...(options.requestId ? { request_id: options.requestId } : {}),
|
|
1263
|
+
next_steps: steps,
|
|
1264
|
+
};
|
|
1265
|
+
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** The unprojected result an unmatched-fields error carries: whole when it
|
|
1269
|
+
* fits the budget, the leading items of a list when only some do, and
|
|
1270
|
+
* nothing for an object over the budget. */
|
|
1271
|
+
function retainedResult(value: unknown, budget: number): { result?: unknown; whole: boolean; omitted?: number } {
|
|
1272
|
+
const text = JSON.stringify(value);
|
|
1273
|
+
if (text === undefined) return { whole: false };
|
|
1274
|
+
if (text.length <= budget) return { result: value, whole: true };
|
|
1275
|
+
if (!Array.isArray(value)) return { whole: false };
|
|
1276
|
+
const k = itemsThatFit(value, budget);
|
|
1277
|
+
const head = value.slice(0, k);
|
|
1278
|
+
if (JSON.stringify(head).length > budget) return { whole: false };
|
|
1279
|
+
return { result: head, whole: false, omitted: value.length - k };
|
|
1279
1280
|
}
|
|
1280
1281
|
|
|
1281
1282
|
const fieldsHint = (perItem: boolean) => "Pass fields (dotted paths" + (perItem ? ", applied per item" : "") + ") to keep only the keys you need.";
|
|
@@ -1300,8 +1301,21 @@ function itemsThatFit(items: unknown[], budget: number): number {
|
|
|
1300
1301
|
* last-id styles resume at the cut, cursor and page styles say what the
|
|
1301
1302
|
* caller must do instead. */
|
|
1302
1303
|
export function pageOutcome(items: unknown[], nextPage: Record<string, unknown> | null, options: ResultOptions = {}): ToolOutcome {
|
|
1304
|
+
if (nextPage !== null && options.carryArgs) {
|
|
1305
|
+
const args = options.args ?? {};
|
|
1306
|
+
const carried = options.carryArgs.filter((name) => args[name] !== undefined && !(name in nextPage!));
|
|
1307
|
+
nextPage = {
|
|
1308
|
+
...Object.fromEntries(carried.map((name) => [name, args[name]])),
|
|
1309
|
+
// The same projection on the next page.
|
|
1310
|
+
...(options.fields && !("fields" in nextPage) ? { fields: options.fields.map((path) => path.join(".")) } : {}),
|
|
1311
|
+
...nextPage,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1303
1314
|
const fields = options.fields ?? null;
|
|
1304
1315
|
const maxChars = options.maxChars ?? DEFAULT_MAX_RESULT_CHARS;
|
|
1316
|
+
const pageSchema = options.outputSchema?.properties as Record<string, { items?: unknown }> | undefined;
|
|
1317
|
+
const unmatched = unmatchedFieldsOutcome(items, fields, true, options, pageSchema?.items, { nextPage });
|
|
1318
|
+
if (unmatched) return unmatched;
|
|
1305
1319
|
const shown = projectFields(items, fields) as unknown[];
|
|
1306
1320
|
const full = {
|
|
1307
1321
|
items: shown,
|
|
@@ -1359,7 +1373,9 @@ const omittedMarker = (key: string, chars: number, maxChars: number) => chars >
|
|
|
1359
1373
|
export function dataOutcome(data: unknown, options: ResultOptions = {}): ToolOutcome {
|
|
1360
1374
|
const fields = options.fields ?? null;
|
|
1361
1375
|
const maxChars = options.maxChars ?? DEFAULT_MAX_RESULT_CHARS;
|
|
1362
|
-
const
|
|
1376
|
+
const unmatched = data !== undefined && data !== null ? unmatchedFieldsOutcome(data, fields, Array.isArray(data), options, options.outputSchema) : null;
|
|
1377
|
+
if (unmatched) return unmatched;
|
|
1378
|
+
const value = data !== undefined && data !== null ? projectFields(data, fields) : { ok: true };
|
|
1363
1379
|
if (typeof value === "string") {
|
|
1364
1380
|
if (value.length <= maxChars) return { text: value, isError: false, structured: value };
|
|
1365
1381
|
return { text: value.slice(0, maxChars) + "\n\n[truncated: " + (value.length - maxChars).toLocaleString("en-US") + " more characters; results are capped at " + maxChars.toLocaleString("en-US") + ".]", isError: false };
|
|
@@ -1481,10 +1497,10 @@ export async function binaryOutcome(blob: Blob, options: BinaryOptions = {}): Pr
|
|
|
1481
1497
|
/** Stable codes an agent can branch on (the same vocabulary as the
|
|
1482
1498
|
* generated CLI's error envelope). Additive only. */
|
|
1483
1499
|
export type ErrorCode =
|
|
1484
|
-
| "NO_AUTH" | "AUTH_INVALID" | "PLAN_LIMIT" | "NOT_FOUND" | "INVALID_REQUEST" | "RATE_LIMITED"
|
|
1500
|
+
| "NO_AUTH" | "AUTH_INVALID" | "INSUFFICIENT_SCOPE" | "PLAN_LIMIT" | "NOT_FOUND" | "INVALID_REQUEST" | "RATE_LIMITED"
|
|
1485
1501
|
| "SPEC_INVALID" | "SERVER_ERROR" | "NETWORK_ERROR" | "VALIDATION_FAILED" | "INVALID_ARGUMENTS" | "CONFIRMATION_REQUIRED"
|
|
1486
1502
|
| "REFERENCE_NOT_FOUND" | "REFERENCE_AMBIGUOUS" | "REFERENCE_SCAN_LIMIT" | "NOT_AVAILABLE" | "CALL_FAILED"
|
|
1487
|
-
| "ACCOUNT_LINK_REQUIRED" | "ACCOUNT_LINK_CANCELLED";
|
|
1503
|
+
| "ACCOUNT_LINK_REQUIRED" | "ACCOUNT_LINK_CANCELLED" | "FIELDS_UNMATCHED";
|
|
1488
1504
|
|
|
1489
1505
|
export interface ErrorContext {
|
|
1490
1506
|
/** One sentence on how to supply a credential on this transport. */
|
|
@@ -1493,39 +1509,92 @@ export interface ErrorContext {
|
|
|
1493
1509
|
docsUrl?: string | null;
|
|
1494
1510
|
/** True when a credential was sent with the request (401 means it was rejected). */
|
|
1495
1511
|
hadCredential?: boolean;
|
|
1512
|
+
/** OAuth scopes the operation requires; a 403 then names them. */
|
|
1513
|
+
requiredScopes?: string[];
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function rateLimitNextStep(retryAt: unknown): string {
|
|
1517
|
+
if (retryAt instanceof Date && !Number.isNaN(retryAt.getTime())) {
|
|
1518
|
+
const at = new Date(Math.ceil(retryAt.getTime() / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
1519
|
+
return "Rate limited: wait until " + at + ", then call again. This is not a credential problem.";
|
|
1520
|
+
}
|
|
1521
|
+
return "Rate limited: back off, then call again once; the request already honored any Retry-After within its ceiling.";
|
|
1496
1522
|
}
|
|
1497
1523
|
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
if (
|
|
1502
|
-
|
|
1503
|
-
return
|
|
1524
|
+
/** Error codes APIs report inside a 2xx body (Slack's `error`), mapped to
|
|
1525
|
+
* the stable codes when their meaning is unambiguous. */
|
|
1526
|
+
function payloadFailureCode(code: unknown): ErrorCode | undefined {
|
|
1527
|
+
if (typeof code !== "string") return undefined;
|
|
1528
|
+
if (/^(not_authed|invalid_auth|token_revoked|token_expired|account_inactive|unauthorized|unauthenticated|forbidden|access_denied|missing_scope)$/i.test(code)) return "AUTH_INVALID";
|
|
1529
|
+
if (/^(ratelimited|rate_limited|rate_limit_exceeded|too_many_requests)$/i.test(code)) return "RATE_LIMITED";
|
|
1530
|
+
return undefined;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/** The vendor's own code on an in-band GraphQL error: the first error's
|
|
1534
|
+
* extensions.code, or its type (GitHub). */
|
|
1535
|
+
function graphqlVendorCode(error: unknown): string | undefined {
|
|
1536
|
+
const first = (error as { errors?: { extensions?: { code?: unknown }; type?: unknown }[] } | null)?.errors?.[0];
|
|
1537
|
+
const code = first?.extensions?.code ?? first?.type;
|
|
1538
|
+
return typeof code === "string" && code !== "" ? code : undefined;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/** GraphQL error codes whose meaning is settled (Apollo's standard codes,
|
|
1542
|
+
* GitHub's types, Linear's codes), by recovery class. Any other code is
|
|
1543
|
+
* passed through as is, with no guessed next step. The CLI's
|
|
1544
|
+
* classification (cli-agent.ts) uses the same table. */
|
|
1545
|
+
function graphqlErrorClass(code: string): "not_found" | "unauthenticated" | "forbidden" | "bad_input" | "rate_limited" | undefined {
|
|
1546
|
+
const c = code.toUpperCase();
|
|
1547
|
+
if (c === "NOT_FOUND") return "not_found";
|
|
1548
|
+
if (c === "UNAUTHENTICATED" || c === "AUTHENTICATION_ERROR") return "unauthenticated";
|
|
1549
|
+
if (c === "FORBIDDEN") return "forbidden";
|
|
1550
|
+
if (c === "BAD_USER_INPUT" || c === "GRAPHQL_VALIDATION_FAILED" || c === "GRAPHQL_PARSE_FAILED" || c === "INPUT_ERROR") return "bad_input";
|
|
1551
|
+
if (c === "RATE_LIMITED" || c === "RATELIMITED") return "rate_limited";
|
|
1552
|
+
return undefined;
|
|
1504
1553
|
}
|
|
1505
1554
|
|
|
1506
1555
|
/** Classify an SDK error result by status: the code and what to do next. */
|
|
1507
1556
|
export function classifyError(error: unknown, context: ErrorContext = {}): { code: ErrorCode; nextSteps: string[] } {
|
|
1508
|
-
const e = (error ?? {}) as { name?: string; message?: string; status?: number; body?: unknown; violations?: unknown };
|
|
1557
|
+
const e = (error ?? {}) as { name?: string; message?: string; status?: number; code?: unknown; body?: unknown; violations?: unknown; rateLimit?: { retryAt?: Date } };
|
|
1509
1558
|
const message = e.message ?? String(error);
|
|
1510
1559
|
if (e.violations !== undefined) return { code: "VALIDATION_FAILED", nextSteps: ["Fix the fields named in violations and call again."] };
|
|
1511
|
-
if (e.name === "TransportError" || (typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(message))) {
|
|
1560
|
+
if (e.name === "TransportError" || (e.name !== "PaginationError" && typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(message))) {
|
|
1512
1561
|
return { code: "NETWORK_ERROR", nextSteps: ["The API could not be reached (network, DNS, TLS or timeout). Retry once with backoff; do not loop."] };
|
|
1513
1562
|
}
|
|
1514
1563
|
const status = typeof e.status === "number" ? e.status : 0;
|
|
1515
1564
|
const body = e.body as { errors?: { code?: string; message?: string }[] } | undefined;
|
|
1516
1565
|
const auth = context.authHint ? context.authHint.trim().replace(/[.]?$/, ".") : null;
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1566
|
+
// A rate limit can arrive as a 403 (GitHub); the SDK marks it either way.
|
|
1567
|
+
if (status === 429 || e.rateLimit !== undefined) return { code: "RATE_LIMITED", nextSteps: [rateLimitNextStep(e.rateLimit?.retryAt)] };
|
|
1568
|
+
const scopes = context.requiredScopes ?? [];
|
|
1569
|
+
const scopeFailure = { code: "INSUFFICIENT_SCOPE" as const, nextSteps: ["This operation requires the OAuth scopes: " + scopes.join(", ") + ". Use a credential granted them (with the CLI: login --scopes " + scopes.join(",") + "). If it already has them, the account may lack access to this resource."] };
|
|
1570
|
+
// A failure the API reported inside a 2xx body.
|
|
1571
|
+
if (e.name === "PayloadError") {
|
|
1572
|
+
if (scopes.length && /^missing_scope$/i.test(String(e.code))) return scopeFailure;
|
|
1573
|
+
const reported = payloadFailureCode(e.code);
|
|
1574
|
+
if (reported === "AUTH_INVALID") return { code: "AUTH_INVALID", nextSteps: ["The API rejected the credential (" + String(e.code) + ")." + (auth ? " " + auth : "")] };
|
|
1575
|
+
if (reported === "RATE_LIMITED") return { code: "RATE_LIMITED", nextSteps: [rateLimitNextStep(undefined)] };
|
|
1576
|
+
return { code: "CALL_FAILED", nextSteps: ["The API reported a failure in a successful response; body says why. Do not treat the call as done."] };
|
|
1577
|
+
}
|
|
1578
|
+
const unauthenticated = (): { code: ErrorCode; nextSteps: string[] } => context.hadCredential
|
|
1579
|
+
? { code: "AUTH_INVALID", nextSteps: ["The credential was rejected; it may be expired or for another environment." + (auth ? " " + auth : "")] }
|
|
1580
|
+
: { code: "NO_AUTH", nextSteps: ["No credential was sent." + (auth ? " " + auth : "")] };
|
|
1581
|
+
const forbidden = (): { code: ErrorCode; nextSteps: string[] } => scopes.length ? scopeFailure : { code: "AUTH_INVALID", nextSteps: ["The credential lacks access to this operation; it is not a retryable error."] };
|
|
1582
|
+
// An in-band GraphQL error (HTTP 200): classified by the vendor's code.
|
|
1583
|
+
if (e.name === "GraphQLRequestError") {
|
|
1584
|
+
const vendor = graphqlVendorCode(error);
|
|
1585
|
+
switch (vendor === undefined ? undefined : graphqlErrorClass(vendor)) {
|
|
1586
|
+
case "not_found": return { code: "NOT_FOUND", nextSteps: ["Check the id in the arguments; list the resource first to find the right one."] };
|
|
1587
|
+
case "unauthenticated": return unauthenticated();
|
|
1588
|
+
case "forbidden": return forbidden();
|
|
1589
|
+
case "bad_input": return { code: "INVALID_REQUEST", nextSteps: ["The API rejected an argument or the selection; the errors in body name it (message, path). Fix that and call again."] };
|
|
1590
|
+
case "rate_limited": return { code: "RATE_LIMITED", nextSteps: [rateLimitNextStep(undefined)] };
|
|
1591
|
+
default: return { code: "CALL_FAILED", nextSteps: [] };
|
|
1592
|
+
}
|
|
1521
1593
|
}
|
|
1522
|
-
if (status ===
|
|
1594
|
+
if (status === 401) return unauthenticated();
|
|
1595
|
+
if (status === 403) return forbidden();
|
|
1523
1596
|
if (status === 402) return { code: "PLAN_LIMIT", nextSteps: ["The account's plan stops here; the body may name where to lift the limit. Do not retry the same call as is."] };
|
|
1524
1597
|
if (status === 404) return { code: "NOT_FOUND", nextSteps: notFoundNextSteps(message, e.body) };
|
|
1525
|
-
if (status === 429) {
|
|
1526
|
-
const retryAfter = extractRetryAfter(e);
|
|
1527
|
-
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."] };
|
|
1528
|
-
}
|
|
1529
1598
|
if (status === 422 && body?.errors?.[0]?.code === "spec_error") {
|
|
1530
1599
|
return {
|
|
1531
1600
|
code: "SPEC_INVALID",
|
|
@@ -1560,18 +1629,37 @@ function notFoundNextSteps(message: string, body: unknown): string[] {
|
|
|
1560
1629
|
/** A typed API error as the agent should see it: name, a stable code, the
|
|
1561
1630
|
* message, status, the API's body, where to read more, and what to do. */
|
|
1562
1631
|
export function errorOutcome(error: unknown, context: ErrorContext = {}): ToolOutcome {
|
|
1563
|
-
const e = error as { name?: string; message?: string; status?: number; body?: unknown; response?: { requestId?: string } };
|
|
1632
|
+
const e = error as { name?: string; message?: string; status?: number; body?: unknown; rateLimit?: { retryAt?: Date }; response?: { requestId?: string } };
|
|
1633
|
+
// A GraphQL response with data and errors is a partial success: the agent
|
|
1634
|
+
// gets the data it can use and the errors that explain what is missing.
|
|
1635
|
+
const partial = error as { name?: string; data?: unknown; errors?: unknown[] } | null;
|
|
1636
|
+
if (partial?.name === "GraphQLRequestError" && partial.data !== undefined && partial.data !== null) {
|
|
1637
|
+
const structured = { data: partial.data, errors: partial.errors ?? [], partial: true };
|
|
1638
|
+
return { text: JSON.stringify(structured), isError: false, structured };
|
|
1639
|
+
}
|
|
1640
|
+
// The SDK raises NotModifiedError for a 304: a conditional request
|
|
1641
|
+
// matched. That is a result, not a failure; the CLI prints the same shape.
|
|
1642
|
+
const notModified = error as { name?: string; etag?: string } | null;
|
|
1643
|
+
if (notModified?.name === "NotModifiedError") {
|
|
1644
|
+
const structured = { ok: true, not_modified: true, ...(notModified.etag ? { etag: notModified.etag } : {}) };
|
|
1645
|
+
return { text: JSON.stringify(structured), isError: false, structured };
|
|
1646
|
+
}
|
|
1564
1647
|
const { code, nextSteps } = classifyError(error, context);
|
|
1648
|
+
const retryAt = e?.rateLimit?.retryAt instanceof Date ? new Date(Math.ceil(e.rateLimit.retryAt.getTime() / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z") : undefined;
|
|
1565
1649
|
const bodyRequestId = e?.body && typeof e.body === "object" && !Array.isArray(e.body)
|
|
1566
1650
|
? (e.body as Record<string, unknown>).request_id ?? (e.body as Record<string, unknown>).requestId
|
|
1567
1651
|
: undefined;
|
|
1568
1652
|
const requestId = e?.response?.requestId ?? (typeof bodyRequestId === "string" ? bodyRequestId : undefined);
|
|
1653
|
+
// The vendor's identity for the failure, next to the normalized code.
|
|
1654
|
+
const vendorCode = e?.name === "GraphQLRequestError" ? graphqlVendorCode(error) : undefined;
|
|
1569
1655
|
const structured = {
|
|
1570
1656
|
error: e?.name ?? "Error",
|
|
1571
1657
|
code,
|
|
1658
|
+
...(vendorCode ? { vendor_code: vendorCode } : {}),
|
|
1572
1659
|
message: e?.message,
|
|
1573
1660
|
...(typeof e?.status === "number" ? { status: e.status } : {}),
|
|
1574
1661
|
...(requestId ? { request_id: requestId } : {}),
|
|
1662
|
+
...(retryAt ? { retry_at: retryAt } : {}),
|
|
1575
1663
|
...(e?.body !== undefined ? { body: e.body } : {}),
|
|
1576
1664
|
...(context.docsUrl ? { docs_url: context.docsUrl } : {}),
|
|
1577
1665
|
next_steps: nextSteps,
|
|
@@ -1579,6 +1667,16 @@ export function errorOutcome(error: unknown, context: ErrorContext = {}): ToolOu
|
|
|
1579
1667
|
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1580
1668
|
}
|
|
1581
1669
|
|
|
1670
|
+
/** A required operation whose schemes this server cannot send fails before
|
|
1671
|
+
* any request, instead of calling the API without credentials. */
|
|
1672
|
+
export function unsupportedAuthOutcome(op: OpLike): ToolOutcome | null {
|
|
1673
|
+
if (op.auth !== "required" || !op.credentialOptions || op.credentialOptions.some((alternative) => alternative.length > 0)) return null;
|
|
1674
|
+
const schemes = [...new Set((op.security ?? []).flatMap((requirement) => Object.keys(requirement)))];
|
|
1675
|
+
return textError(`${op.tool} requires ${schemes.length ? schemes.join(" or ") : "an authentication scheme"} authentication, which this MCP server cannot send.`, "NO_AUTH", [
|
|
1676
|
+
"Call this operation from an SDK with a custom HTTP client that adds the credential, or ask the API owner to add a supported scheme.",
|
|
1677
|
+
]);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1582
1680
|
export function textError(text: string, code: ErrorCode = "CALL_FAILED", nextSteps: string[] = []): ToolOutcome {
|
|
1583
1681
|
const structured = { error: "Error", code, message: text, next_steps: nextSteps };
|
|
1584
1682
|
return { text: JSON.stringify(structured), isError: true, structured };
|
|
@@ -1590,8 +1688,12 @@ export interface DocsSource {
|
|
|
1590
1688
|
ops: OpLike[];
|
|
1591
1689
|
/** Operations omitted from a capped generation. */
|
|
1592
1690
|
omittedOps?: OpLike[];
|
|
1691
|
+
/** Operations this server's switches hide (read-only, a tools subset). */
|
|
1692
|
+
hiddenOps?: HiddenOperation[];
|
|
1593
1693
|
/** Count generated before runtime surface filters. */
|
|
1594
1694
|
generatedOperationCount?: number;
|
|
1695
|
+
/** Named input types for type and argument-path lookups. */
|
|
1696
|
+
inputTypes?: InputTypes;
|
|
1595
1697
|
/** Base URL of the docs site, or null when none is configured. */
|
|
1596
1698
|
docsUrl(): string | null;
|
|
1597
1699
|
/** Exact llms.txt URL when it is not at <docsUrl>/llms.txt. */
|
|
@@ -1605,109 +1707,296 @@ async function fetchDocs(source: DocsSource, pathOrFile: string): Promise<string
|
|
|
1605
1707
|
return url === null ? null : source.fetchText(url);
|
|
1606
1708
|
}
|
|
1607
1709
|
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
return omitted.length > 0
|
|
1612
|
-
? "Coverage: generated " + generated + " of " + (generated + omitted.length) + " operations. Omitted by the plan limit: " + omitted.map((op) => op.tool + " (" + op.httpMethod + " " + op.path + ")").join(", ") + "."
|
|
1613
|
-
: null;
|
|
1710
|
+
/** "GraphQL mutation issueCreate" or "POST /v1/issues". */
|
|
1711
|
+
export function operationLabel(op: Pick<OpLike, "httpMethod" | "path" | "graphql">): string {
|
|
1712
|
+
return op.graphql?.field ? "GraphQL " + op.graphql.kind + " " + op.graphql.field : op.httpMethod + " " + op.path;
|
|
1614
1713
|
}
|
|
1615
1714
|
|
|
1616
|
-
function
|
|
1715
|
+
function omittedEntry(op: OpLike): Record<string, string> {
|
|
1716
|
+
return op.graphql?.field
|
|
1717
|
+
? { tool: op.tool, graphql: op.graphql.kind + " " + op.graphql.field }
|
|
1718
|
+
: { tool: op.tool, method: op.httpMethod, path: op.path };
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function omittedPlanLimit(source: DocsSource, ops: OpLike[], requested?: string): ToolOutcome {
|
|
1722
|
+
const generated = source.generatedOperationCount ?? source.ops.length;
|
|
1723
|
+
const total = generated + (source.omittedOps?.length ?? 0);
|
|
1617
1724
|
const structured = {
|
|
1618
1725
|
error: "PlanLimitError",
|
|
1619
1726
|
code: "PLAN_LIMIT",
|
|
1620
|
-
message: requested
|
|
1621
|
-
? "The operation " + requested + "
|
|
1622
|
-
: "Matching operations
|
|
1623
|
-
omitted_operations: ops.
|
|
1624
|
-
next_steps: ["
|
|
1727
|
+
message: (requested
|
|
1728
|
+
? "The operation " + requested + " is in the API but not in this package"
|
|
1729
|
+
: "Matching operations are in the API but not in this package") + ", which was generated with " + generated + " of its " + total + " operations.",
|
|
1730
|
+
omitted_operations: ops.slice(0, SEARCH_PAGE_SIZE).map(omittedEntry),
|
|
1731
|
+
next_steps: ["The package's publisher can regenerate it with every operation.", "Do not invent or retry an omitted operation against this package."],
|
|
1625
1732
|
};
|
|
1626
1733
|
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1627
1734
|
}
|
|
1628
1735
|
|
|
1629
|
-
|
|
1736
|
+
/** Nesting read_docs spells out before pointing at schema: true. */
|
|
1737
|
+
const REFERENCE_DEPTH = 3;
|
|
1738
|
+
/** Characters one top-level argument's nested fields may take; a deep
|
|
1739
|
+
* filter object is shown shallower until it fits. */
|
|
1740
|
+
const REFERENCE_ARGUMENT_BUDGET = 6_000;
|
|
1741
|
+
/** Enum values listed inline; longer enums are cut with a count. */
|
|
1742
|
+
const REFERENCE_ENUM_VALUES = 30;
|
|
1743
|
+
|
|
1744
|
+
/** HTML that API descriptions carry for formatting only. Anything else
|
|
1745
|
+
* in angle brackets (a <placeholder>) is text and stays. */
|
|
1746
|
+
const FORMATTING_TAGS = /<\/?(?:a|abbr|b|br|code|div|em|i|li|ol|p|pre|small|span|strong|sub|sup|u|ul)(?:\s[^<>]*)?\/?>/gi;
|
|
1747
|
+
/** A Markdown link or image: [text](url "title"), where the URL may hold
|
|
1748
|
+
* one level of parentheses (Wikipedia's Foo_(bar)). */
|
|
1749
|
+
const MARKDOWN_LINK = /!?\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\]\((?:[^()\s]|\([^()\s]*\))*(?:\s+"[^"]*")?\)/g;
|
|
1750
|
+
|
|
1751
|
+
/** Prose for one description, as one line: Markdown and HTML links reduced
|
|
1752
|
+
* to their visible text, formatting tags and emphasis dropped, <code> as
|
|
1753
|
+
* inline code (kept, because agents copy it). */
|
|
1754
|
+
export function referenceProse(text: unknown): string {
|
|
1755
|
+
if (typeof text !== "string") return "";
|
|
1756
|
+
return text
|
|
1757
|
+
.replace(/<code>([\s\S]*?)<\/code>/gi, "`$1`")
|
|
1758
|
+
.replace(/<br\s*\/?>/gi, " ")
|
|
1759
|
+
.replace(FORMATTING_TAGS, "")
|
|
1760
|
+
.replace(MARKDOWN_LINK, "$1")
|
|
1761
|
+
.replace(/\[([^\[\]]+)\]\[[^\[\]]*\]/g, "$1")
|
|
1762
|
+
.replace(/(\*\*|__)(?=\S)([^*_]*?\S)\1/g, "$2")
|
|
1763
|
+
.replace(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&")
|
|
1764
|
+
.replace(/\s+/g, " ")
|
|
1765
|
+
.trim();
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/** Abbreviations whose period does not end a sentence. */
|
|
1769
|
+
const ABBREVIATION = /(?:^|[\s(])(?:e\.g|i\.e|etc|vs|approx|incl|cf|no|min|max)\.$/i;
|
|
1770
|
+
|
|
1771
|
+
/** A description's sentences, every character kept: a sentence ends at
|
|
1772
|
+
* . ! or ? (closing quotes and brackets included) before whitespace, not
|
|
1773
|
+
* after an abbreviation, and never inside inline code. */
|
|
1774
|
+
function sentencesOf(prose: string): string[] {
|
|
1775
|
+
const sentences: string[] = [];
|
|
1776
|
+
let current = "";
|
|
1777
|
+
for (const part of prose.split(/(?<=[.!?]["')\]]?)\s+/)) {
|
|
1778
|
+
current = current ? current + " " + part : part;
|
|
1779
|
+
const openCode = (current.match(/`/g) ?? []).length % 2 === 1;
|
|
1780
|
+
if (!openCode && !ABBREVIATION.test(current)) {
|
|
1781
|
+
sentences.push(current);
|
|
1782
|
+
current = "";
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
if (current) sentences.push(current);
|
|
1786
|
+
return sentences;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/** Cut one long sentence at a word boundary, never inside inline code. */
|
|
1790
|
+
function cutSentence(sentence: string, limit: number): string {
|
|
1791
|
+
let cut = sentence.slice(0, limit).replace(/\s+\S*$/, "");
|
|
1792
|
+
if ((cut.match(/`/g) ?? []).length % 2 === 1) cut = cut.slice(0, cut.lastIndexOf("`")).trimEnd();
|
|
1793
|
+
return cut.replace(/[\s,;:(]+$/, "") + "…";
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/** Sentences the generator appends to an argument's description because a
|
|
1797
|
+
* call depends on them: enum meanings, defaults, deprecation, reference
|
|
1798
|
+
* and date forms, file paths. They survive the cut. */
|
|
1799
|
+
const REFERENCE_NOTE = /^(Values:|Default|Deprecated|Accepts|Markdown|Paths? of (a )?local file|Must|Required|Only one of)/;
|
|
1800
|
+
/** Leading prose kept per argument before its notes. */
|
|
1801
|
+
const REFERENCE_ARGUMENT_PROSE = 160;
|
|
1802
|
+
|
|
1803
|
+
/** An argument's description, cut to its leading sentences within the
|
|
1804
|
+
* budget plus every note the generator added. The full text is in the
|
|
1805
|
+
* schema (schema: true). */
|
|
1806
|
+
export function argumentProse(text: unknown): string {
|
|
1807
|
+
const prose = referenceProse(text);
|
|
1808
|
+
if (prose.length <= REFERENCE_ARGUMENT_PROSE) return prose;
|
|
1809
|
+
const kept: string[] = [];
|
|
1810
|
+
let used = 0;
|
|
1811
|
+
let cut = false;
|
|
1812
|
+
for (const sentence of sentencesOf(prose)) {
|
|
1813
|
+
if (REFERENCE_NOTE.test(sentence)) { kept.push(sentence); continue; }
|
|
1814
|
+
if (kept.length === 0 || used + sentence.length <= REFERENCE_ARGUMENT_PROSE) {
|
|
1815
|
+
kept.push(sentence.length > REFERENCE_ARGUMENT_PROSE * 2 ? cutSentence(sentence, REFERENCE_ARGUMENT_PROSE * 2) : sentence);
|
|
1816
|
+
used += sentence.length;
|
|
1817
|
+
} else {
|
|
1818
|
+
cut = true;
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
return kept.join(" ") + (cut ? " …" : "");
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/** A schema's type as an agent writes it: string, integer[], "a"|"b", object. */
|
|
1825
|
+
function referenceType(schema: Record<string, unknown>): string {
|
|
1826
|
+
if (Array.isArray(schema.enum)) {
|
|
1827
|
+
const values = schema.enum.slice(0, REFERENCE_ENUM_VALUES).map((v) => JSON.stringify(v));
|
|
1828
|
+
return values.join("|") + (schema.enum.length > REFERENCE_ENUM_VALUES ? "|… (" + (schema.enum.length - REFERENCE_ENUM_VALUES) + " more)" : "");
|
|
1829
|
+
}
|
|
1830
|
+
if (schema.const !== undefined) return JSON.stringify(schema.const);
|
|
1831
|
+
const variants = (schema.anyOf ?? schema.oneOf) as Record<string, unknown>[] | undefined;
|
|
1832
|
+
if (Array.isArray(variants)) return [...new Set(variants.map((v) => referenceType(v)))].join("|");
|
|
1833
|
+
const types = Array.isArray(schema.type) ? schema.type as string[] : typeof schema.type === "string" ? [schema.type] : [];
|
|
1834
|
+
const one = (t: string): string => {
|
|
1835
|
+
if (t === "array") {
|
|
1836
|
+
const items = schema.items && typeof schema.items === "object" ? referenceType(schema.items as Record<string, unknown>) : "any";
|
|
1837
|
+
return (/[| ]/.test(items) ? "(" + items + ")" : items) + "[]";
|
|
1838
|
+
}
|
|
1839
|
+
return t + (t === "string" && typeof schema.format === "string" ? " (" + schema.format + ")" : "");
|
|
1840
|
+
};
|
|
1841
|
+
if (types.length === 0) return schema.properties ? "object" : "any";
|
|
1842
|
+
return types.map(one).join("|");
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/** The object whose fields an argument lists: itself, its array's items,
|
|
1846
|
+
* or the one object of a nullable union. */
|
|
1847
|
+
function referenceObject(schema: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
1848
|
+
if (schema.properties && typeof schema.properties === "object") return schema;
|
|
1849
|
+
const items = schema.items as Record<string, unknown> | undefined;
|
|
1850
|
+
if (items && typeof items === "object") return referenceObject(items);
|
|
1851
|
+
const variants = (schema.anyOf ?? schema.oneOf) as Record<string, unknown>[] | undefined;
|
|
1852
|
+
if (Array.isArray(variants)) {
|
|
1853
|
+
const objects = variants.map(referenceObject).filter((v) => v !== undefined);
|
|
1854
|
+
if (objects.length === 1) return objects[0];
|
|
1855
|
+
}
|
|
1856
|
+
return undefined;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/** Where an argument line sits: the operation, the dotted path to it,
|
|
1860
|
+
* and the named input type of the object that holds it, when known. */
|
|
1861
|
+
interface ArgumentContext { tool: string; types?: InputTypes; path: string[]; typeName?: string }
|
|
1862
|
+
|
|
1863
|
+
/** One line per argument, nested fields indented beneath their object.
|
|
1864
|
+
* Each top-level argument is spelled out as deep as fits its budget. */
|
|
1865
|
+
function referenceArguments(schema: Record<string, unknown>, lines: string[], context: ArgumentContext): void {
|
|
1866
|
+
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
|
1867
|
+
for (const name of Object.keys(properties)) {
|
|
1868
|
+
const only = { ...schema, properties: { [name]: properties[name] } };
|
|
1869
|
+
let block: string[] = [];
|
|
1870
|
+
// Deepest first; then the same depth without listing the field names
|
|
1871
|
+
// below the cut; then one level with names only.
|
|
1872
|
+
for (const [depth, names] of [[REFERENCE_DEPTH, true], [2, true], [2, false], [1, true]] as const) {
|
|
1873
|
+
block = [];
|
|
1874
|
+
referenceArgumentLines(only, 0, depth, names, " ", block, context);
|
|
1875
|
+
if (block.join("\n").length <= REFERENCE_ARGUMENT_BUDGET) break;
|
|
1876
|
+
}
|
|
1877
|
+
lines.push(...block);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function referenceArgumentLines(schema: Record<string, unknown>, depth: number, maxDepth: number, names: boolean, indent: string, lines: string[], context: ArgumentContext): void {
|
|
1882
|
+
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
|
1883
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required as string[] : []);
|
|
1884
|
+
for (const [name, child] of Object.entries(properties)) {
|
|
1885
|
+
if (!child || typeof child !== "object") continue;
|
|
1886
|
+
const description = argumentProse(child.description);
|
|
1887
|
+
const extras = [
|
|
1888
|
+
...(child.default !== undefined && !/\bdefault\b/i.test(description) ? ["default " + JSON.stringify(child.default)] : []),
|
|
1889
|
+
...(child.deprecated === true && !/deprecated/i.test(description) ? ["deprecated"] : []),
|
|
1890
|
+
];
|
|
1891
|
+
const nested = referenceObject(child);
|
|
1892
|
+
// An object argument reads as its named type (IssueFilter), which
|
|
1893
|
+
// read_docs can look up; everything else keeps its inline type.
|
|
1894
|
+
const expression = context.path.length === 0
|
|
1895
|
+
? context.types?.args[context.tool]?.[name]
|
|
1896
|
+
: context.typeName ? context.types?.types[context.typeName]?.fields?.[name]?.type : undefined;
|
|
1897
|
+
const named = nested ? namedTypesIn(context.types, expression) : [];
|
|
1898
|
+
const type = named.length > 0 ? expression! : referenceType(child);
|
|
1899
|
+
lines.push(indent + name + " (" + type + (required.has(name) ? ", required" : "") + (extras.length ? ", " + extras.join(", ") : "") + ")" + (description ? ": " + description : ""));
|
|
1900
|
+
if (!nested) continue;
|
|
1901
|
+
const path = [...context.path, name];
|
|
1902
|
+
const objects = named.filter((typeName) => context.types!.types[typeName]!.fields);
|
|
1903
|
+
const inner: ArgumentContext = { ...context, path, typeName: objects.length === 1 ? objects[0] : undefined };
|
|
1904
|
+
if (depth + 1 < maxDepth) {
|
|
1905
|
+
referenceArgumentLines(nested, depth + 1, maxDepth, names, indent + " ", lines, inner);
|
|
1906
|
+
continue;
|
|
1907
|
+
}
|
|
1908
|
+
if (!names) continue;
|
|
1909
|
+
const keys = Object.keys(nested.properties as object);
|
|
1910
|
+
// Field types (and any cut names) are one path lookup away.
|
|
1911
|
+
lines.push(indent + " fields: " + keys.slice(0, 40).join(", ") + (keys.length > 40 ? ", … " + (keys.length - 40) + " more: read_docs " + JSON.stringify({ page: context.tool, path: path.join(".") }) + " lists all" : ""));
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
/** A result's shape in one line: keys and types, one level into objects. */
|
|
1916
|
+
function referenceShape(schema: Record<string, unknown> | undefined, depth = 0): string {
|
|
1917
|
+
if (!schema || typeof schema !== "object") return "any";
|
|
1918
|
+
const object = schema.properties && typeof schema.properties === "object" ? schema : undefined;
|
|
1919
|
+
if (object) {
|
|
1920
|
+
if (depth >= 2) return "{…}";
|
|
1921
|
+
const entries = Object.entries(object.properties as Record<string, Record<string, unknown>>);
|
|
1922
|
+
const shown = entries.slice(0, 40).map(([key, child]) => key + ": " + referenceShape(child, depth + 1));
|
|
1923
|
+
return "{" + shown.join(", ") + (entries.length > 40 ? ", … " + (entries.length - 40) + " more" : "") + "}";
|
|
1924
|
+
}
|
|
1925
|
+
const items = schema.items as Record<string, unknown> | undefined;
|
|
1926
|
+
if (items && typeof items === "object" && (items.properties || items.items)) {
|
|
1927
|
+
return "[" + referenceShape(items, depth) + "]";
|
|
1928
|
+
}
|
|
1929
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 8) {
|
|
1930
|
+
return schema.enum.slice(0, 8).map((v) => JSON.stringify(v)).join("|") + "|…";
|
|
1931
|
+
}
|
|
1932
|
+
// Nullability and formats matter for sending, not for reading a result.
|
|
1933
|
+
const type = referenceType({ ...schema, format: undefined });
|
|
1934
|
+
return type.split("|").filter((t) => t !== "null").join("|") || type;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* An operation's reference as read_docs returns it: what it does, whether
|
|
1939
|
+
* it is safe, every argument in prose (types, enums, required, notes, and
|
|
1940
|
+
* nested fields), one example and the result's shape. The complete JSON
|
|
1941
|
+
* Schemas come with `schema: true`, as the CLI's `docs --schema` does; they
|
|
1942
|
+
* cost several times the rest and an agent rarely needs them to call.
|
|
1943
|
+
*/
|
|
1944
|
+
export function referenceText(op: OpLike, options: { schema?: boolean; types?: InputTypes } = {}): string {
|
|
1630
1945
|
const safety = operationSafety(op);
|
|
1631
|
-
|
|
1946
|
+
// A credential-defaulted argument (Twilio's AccountSid) is left out, so the
|
|
1947
|
+
// example shows the call an agent should make.
|
|
1948
|
+
const example = Object.fromEntries(Object.entries(op.exampleArguments ?? {})
|
|
1949
|
+
.filter(([name]) => !op.params.some((p) => p.name === name && p.credential)));
|
|
1632
1950
|
const lines = [
|
|
1633
|
-
op.tool + ": " + op
|
|
1951
|
+
op.tool + ": " + operationLabel(op) + (op.paginated ? " (paginated)" : ""),
|
|
1634
1952
|
...(op.summary ? [op.summary] : []),
|
|
1635
|
-
...(op.description ? ["", op.description.trim()] : []),
|
|
1953
|
+
...(op.description && op.description.trim() !== op.summary?.trim() ? ["", op.description.trim()] : []),
|
|
1636
1954
|
"",
|
|
1637
1955
|
"Safety: " + safety + (safety === "destructive" ? " (execute requires confirm: true)" : ""),
|
|
1638
|
-
...(op.auth ? ["Authentication: " + op.auth] : []),
|
|
1956
|
+
...(op.auth ? ["Authentication: " + (op.authNotDeclared ? "not declared by the API Spec" : op.auth)] : []),
|
|
1957
|
+
...(requiredScopes(op.security).length ? ["Required OAuth scopes: " + requiredScopes(op.security).join(", ")] : []),
|
|
1639
1958
|
];
|
|
1640
|
-
|
|
1959
|
+
const input = toolInputSchema(op);
|
|
1960
|
+
if (Object.keys((input.properties ?? {}) as object).length > 0) {
|
|
1641
1961
|
lines.push("", "Arguments:");
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
}
|
|
1962
|
+
referenceArguments(input, lines, { tool: op.tool, types: options.types, path: [] });
|
|
1963
|
+
}
|
|
1964
|
+
lines.push("", "Example arguments: " + JSON.stringify(example));
|
|
1965
|
+
if (op.outputSchema) {
|
|
1966
|
+
lines.push("", (op.paginated ? "Returns one page: " : "Returns: ") + referenceShape(op.outputSchema));
|
|
1648
1967
|
}
|
|
1649
|
-
if (
|
|
1650
|
-
lines.push("", "
|
|
1968
|
+
if (options.schema) {
|
|
1969
|
+
lines.push("", "Input schema: " + JSON.stringify(input));
|
|
1970
|
+
if (op.outputSchema) lines.push("", "Output schema: " + JSON.stringify(op.outputSchema));
|
|
1971
|
+
} else {
|
|
1972
|
+
// Say how to drill into an object argument: the page above stops at a
|
|
1973
|
+
// depth and a size, and the schema is the whole graph at once.
|
|
1974
|
+
const nested = Object.entries((input.properties ?? {}) as Record<string, Record<string, unknown>>)
|
|
1975
|
+
.find(([, child]) => child && typeof child === "object" && referenceObject(child))?.[0];
|
|
1976
|
+
if (nested) {
|
|
1977
|
+
const named = namedTypesIn(options.types, options.types?.args[op.tool]?.[nested])[0];
|
|
1978
|
+
lines.push("", "Nested arguments: read_docs " + JSON.stringify({ page: op.tool, path: nested }) + " gives an argument's type and all its fields; extend the path (\"" + nested + ".<field>\") to go deeper."
|
|
1979
|
+
+ (named ? " Named types read the same way: read_docs " + JSON.stringify({ page: named }) + "." : ""));
|
|
1980
|
+
}
|
|
1981
|
+
lines.push("", "Full input and output JSON Schemas: read_docs " + JSON.stringify({ page: op.tool, schema: true }) + ".");
|
|
1651
1982
|
}
|
|
1652
|
-
lines.push("", "Input schema:", "```json", JSON.stringify(toolInputSchema(op), null, 2), "```");
|
|
1653
|
-
lines.push("", "Example arguments:", "```json", JSON.stringify(example, null, 2), "```");
|
|
1654
|
-
if (op.outputSchema) lines.push("", "Output schema:", "```json", JSON.stringify(op.outputSchema, null, 2), "```");
|
|
1655
1983
|
return lines.join("\n");
|
|
1656
1984
|
}
|
|
1657
1985
|
|
|
1658
|
-
/** Query terms: lowercase words of two or more characters, with the
|
|
1659
|
-
* snake/kebab/camel seams split so "createAccount" finds accounts_create. */
|
|
1660
|
-
function searchTerms(query: string): string[] {
|
|
1661
|
-
return [...new Set(query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 2))];
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
/** Relevance of one operation to the terms: the tool name counts most,
|
|
1665
|
-
* then summary, path and argument names, then the description. The whole
|
|
1666
|
-
* query as a phrase in the name or summary is a strong signal. */
|
|
1667
|
-
export function searchScore(op: OpLike, query: string): number {
|
|
1668
|
-
const terms = searchTerms(query);
|
|
1669
|
-
if (terms.length === 0) return 0;
|
|
1670
|
-
const tool = op.tool.toLowerCase();
|
|
1671
|
-
const toolWords = tool.split("_");
|
|
1672
|
-
const summary = (op.summary ?? "").toLowerCase();
|
|
1673
|
-
const path = op.path.toLowerCase();
|
|
1674
|
-
const params = op.params.map((p) => p.name.toLowerCase());
|
|
1675
|
-
const description = (op.description ?? "").toLowerCase();
|
|
1676
|
-
let score = 0;
|
|
1677
|
-
for (const term of terms) {
|
|
1678
|
-
if (toolWords.includes(term)) score += 10;
|
|
1679
|
-
else if (tool.includes(term)) score += 6;
|
|
1680
|
-
if (summary.split(/[^a-z0-9]+/).includes(term)) score += 5;
|
|
1681
|
-
else if (summary.includes(term)) score += 3;
|
|
1682
|
-
if (path.includes(term)) score += 3;
|
|
1683
|
-
if (params.some((p) => p === term)) score += 3;
|
|
1684
|
-
else if (params.some((p) => p.includes(term))) score += 1;
|
|
1685
|
-
if (description.includes(term)) score += 1;
|
|
1686
|
-
}
|
|
1687
|
-
const phrase = query.trim().toLowerCase();
|
|
1688
|
-
if (phrase.length >= 3 && (tool.includes(phrase.replace(/[^a-z0-9]+/g, "_")) || summary.includes(phrase))) score += 8;
|
|
1689
|
-
return score;
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
1986
|
export async function docsSearch(source: DocsSource, query: string, page = 1): Promise<ToolOutcome> {
|
|
1693
1987
|
const sections: string[] = [];
|
|
1694
|
-
const ranked = source.ops
|
|
1695
|
-
|
|
1696
|
-
.filter((r) => r.score > 0)
|
|
1697
|
-
.sort((a, b) => b.score - a.score || a.op.tool.localeCompare(b.op.tool));
|
|
1698
|
-
const omittedRanked = (source.omittedOps ?? [])
|
|
1699
|
-
.map((op) => ({ op, score: searchScore(op, query) }))
|
|
1700
|
-
.filter((result) => result.score > 0)
|
|
1701
|
-
.sort((a, b) => b.score - a.score || a.op.tool.localeCompare(b.op.tool));
|
|
1988
|
+
const ranked = rankOperations(source.ops, query);
|
|
1989
|
+
const omittedRanked = rankOperations(source.omittedOps ?? [], query);
|
|
1702
1990
|
const exactOmitted = findOperation(source.omittedOps ?? [], query);
|
|
1703
|
-
if (exactOmitted) return omittedPlanLimit([exactOmitted], exactOmitted.tool);
|
|
1991
|
+
if (exactOmitted) return omittedPlanLimit(source, [exactOmitted], exactOmitted.tool);
|
|
1704
1992
|
const pageIndex = Math.max(1, Math.floor(page)) - 1;
|
|
1705
1993
|
const slice = ranked.slice(pageIndex * SEARCH_PAGE_SIZE, (pageIndex + 1) * SEARCH_PAGE_SIZE);
|
|
1994
|
+
const more = ranked.length - (pageIndex + 1) * SEARCH_PAGE_SIZE;
|
|
1706
1995
|
if (slice.length > 0) {
|
|
1707
|
-
const more = ranked.length - (pageIndex + 1) * SEARCH_PAGE_SIZE;
|
|
1708
1996
|
sections.push("Reference matches (best first" + (ranked.length > SEARCH_PAGE_SIZE ? ", page " + (pageIndex + 1) + " of " + Math.ceil(ranked.length / SEARCH_PAGE_SIZE) : "") + "):\n" +
|
|
1709
|
-
slice.map((r) => "- " + r.op.tool + ": " + (r.op
|
|
1710
|
-
(more > 0 ? "\n(" + more + " more; pass page: " + (pageIndex + 2) + ")" : "")
|
|
1997
|
+
slice.map((r) => "- " + r.op.tool + ": " + searchLabel(r.op)).join("\n") +
|
|
1998
|
+
(more > 0 ? "\n(" + more + " more; pass page: " + (pageIndex + 2) + ")" : "") +
|
|
1999
|
+
"\nread_docs {\"page\": \"<tool>\"} gives an operation's arguments and example.");
|
|
1711
2000
|
} else if (ranked.length > 0) {
|
|
1712
2001
|
sections.push("No reference matches on page " + (pageIndex + 1) + "; there are " + Math.ceil(ranked.length / SEARCH_PAGE_SIZE) + " pages.");
|
|
1713
2002
|
}
|
|
@@ -1715,39 +2004,93 @@ export async function docsSearch(source: DocsSource, query: string, page = 1): P
|
|
|
1715
2004
|
const guideSlice = guides.slice(pageIndex * SEARCH_PAGE_SIZE, (pageIndex + 1) * SEARCH_PAGE_SIZE);
|
|
1716
2005
|
const proseMatchCount = guides.length;
|
|
1717
2006
|
if (guideSlice.length > 0) {
|
|
1718
|
-
sections.push("Guide matches (best first, " + guides.length + " pages):\n" + guideSlice.map((match) =>
|
|
1719
|
-
"- [" + match.title + (match.section ? " / " + match.section : "") + "](" + match.url + "): " + match.excerpt
|
|
2007
|
+
sections.push("Guide matches (best first, " + guides.length + " pages; read_docs {\"page\": \"<url>\"} reads one):\n" + guideSlice.map((match) =>
|
|
2008
|
+
"- [" + match.title + (match.section ? " / " + match.section : "") + "](" + match.url + "): " + match.excerpt).join("\n"));
|
|
1720
2009
|
}
|
|
1721
2010
|
if (status === "unavailable") sections.push("The docs site is unavailable; the API reference was still searched.");
|
|
2011
|
+
// Lean on purpose: the text above is what most clients show the model,
|
|
2012
|
+
// and this mirrors it rather than repeating the read_docs call per hit.
|
|
1722
2013
|
const structured = {
|
|
1723
|
-
schema_version: "
|
|
1724
|
-
reference: slice.map(({ op }) => ({ tool: op.tool,
|
|
1725
|
-
guides: guideSlice
|
|
2014
|
+
schema_version: "2", query, page: pageIndex + 1,
|
|
2015
|
+
reference: slice.map(({ op }) => ({ tool: op.tool, summary: searchLabel(op), ...(searchDeprecated(op) ? { deprecated: true } : {}) })),
|
|
2016
|
+
guides: guideSlice,
|
|
1726
2017
|
totals: { reference: ranked.length, guides: guides.length }, guides_status: status,
|
|
2018
|
+
...(slice.length > 0 || guideSlice.length > 0 ? { read_docs: { page: "<tool name or guide url>" } } : {}),
|
|
2019
|
+
...(more > 0 ? { next_page: pageIndex + 2 } : {}),
|
|
1727
2020
|
};
|
|
1728
2021
|
if (omittedRanked.length > 0 && ranked.length === 0 && proseMatchCount === 0) {
|
|
1729
|
-
return omittedPlanLimit(omittedRanked.map((result) => result.op));
|
|
2022
|
+
return omittedPlanLimit(source, omittedRanked.map((result) => result.op));
|
|
1730
2023
|
}
|
|
1731
2024
|
if (omittedRanked.length > 0) {
|
|
1732
|
-
|
|
2025
|
+
const generated = source.generatedOperationCount ?? source.ops.length;
|
|
2026
|
+
sections.push("Also in the API but not in this package (it includes " + generated + " of " + (generated + (source.omittedOps?.length ?? 0)) + " operations; these return PLAN_LIMIT):\n" + omittedRanked.slice(0, SEARCH_PAGE_SIZE).map((result) => "- " + result.op.tool + ": " + (result.op.summary ?? operationLabel(result.op))).join("\n"));
|
|
1733
2027
|
}
|
|
1734
|
-
const coverage = coverageText(source);
|
|
1735
2028
|
if (sections.length === 0) {
|
|
1736
2029
|
return {
|
|
1737
|
-
text:
|
|
2030
|
+
text: "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)" : ""),
|
|
1738
2031
|
isError: false,
|
|
1739
2032
|
structured,
|
|
1740
2033
|
};
|
|
1741
2034
|
}
|
|
1742
|
-
return { text:
|
|
2035
|
+
return { text: sections.join("\n\n"), isError: false, structured };
|
|
1743
2036
|
}
|
|
1744
2037
|
|
|
1745
|
-
|
|
2038
|
+
function searchDeprecated(op: OpLike): boolean {
|
|
2039
|
+
return op.deprecated === true;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/** One line per hit: the summary (or the wire call), flagged when deprecated. */
|
|
2043
|
+
function searchLabel(op: OpLike): string {
|
|
2044
|
+
return (searchDeprecated(op) ? "(deprecated) " : "") + (op.summary ?? operationLabel(op));
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/** Characters one read_docs call returns; the rest is paged by offset. */
|
|
2048
|
+
export const READ_DOCS_LIMIT = 20_000;
|
|
2049
|
+
|
|
2050
|
+
/** One part of a long page, ending with how to read the next part. */
|
|
2051
|
+
function docsPart(page: string, text: string, offset: number, schema = false): string {
|
|
2052
|
+
return docsPartFor({ page, ...(schema ? { schema: true } : {}) }, text, offset);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/** docsPart for any read_docs arguments (a page, a path, schema). */
|
|
2056
|
+
function docsPartFor(request: Record<string, unknown>, text: string, offset: number): string {
|
|
2057
|
+
if (offset <= 0 && text.length <= READ_DOCS_LIMIT) return text;
|
|
2058
|
+
const start = Math.min(Math.max(0, offset), text.length);
|
|
2059
|
+
const end = Math.min(text.length, start + READ_DOCS_LIMIT);
|
|
2060
|
+
const more = end < text.length
|
|
2061
|
+
? "\n\n[Characters " + start + "-" + end + " of " + text.length + ". Continue with read_docs " + JSON.stringify({ ...request, offset: end }) + ".]"
|
|
2062
|
+
: "\n\n[Characters " + start + "-" + end + " of " + text.length + "; end of page.]";
|
|
2063
|
+
return text.slice(start, end) + more;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/** How read_docs names a type lookup, for the pages that mention types. */
|
|
2067
|
+
function readDocsTypeHint(name: string): string {
|
|
2068
|
+
return "read_docs " + JSON.stringify({ page: name });
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
/** One argument path within an operation or a named type. */
|
|
2072
|
+
function docsPathOutcome(source: DocsSource, root: { tool: string; inputSchema: Record<string, unknown> } | { type: string }, page: string, path: string, offset: number): ToolOutcome {
|
|
2073
|
+
const found = argumentPathText(source.inputTypes, root, path, readDocsTypeHint);
|
|
2074
|
+
if (found.ok) return { text: docsPartFor({ page, path }, found.text, offset), isError: false };
|
|
2075
|
+
const structured = {
|
|
2076
|
+
error: "NotFoundError", code: "NOT_FOUND", message: found.message,
|
|
2077
|
+
...(found.available.length > 0 ? { available: found.available } : {}),
|
|
2078
|
+
next_steps: [found.available.length > 0 ? "Pass one of the available fields as the next path segment." : "read_docs " + JSON.stringify({ page }) + " lists the arguments."],
|
|
2079
|
+
};
|
|
2080
|
+
return { text: JSON.stringify(structured), isError: true, structured };
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
export async function docsRead(source: DocsSource, page: string, offset = 0, options: { schema?: boolean; path?: string } = {}): Promise<ToolOutcome> {
|
|
1746
2084
|
const opMatch = findOperation(source.ops, page);
|
|
1747
|
-
const
|
|
1748
|
-
if (
|
|
2085
|
+
const typeMatch = opMatch ? undefined : findInputType(source.inputTypes, page);
|
|
2086
|
+
if (options.path !== undefined && options.path.trim() !== "") {
|
|
2087
|
+
if (opMatch) return docsPathOutcome(source, opMatch, page, options.path, offset);
|
|
2088
|
+
if (typeMatch) return docsPathOutcome(source, { type: typeMatch }, page, options.path, offset);
|
|
2089
|
+
}
|
|
2090
|
+
if (opMatch) return { text: docsPart(page, referenceText(opMatch, { schema: options.schema, types: source.inputTypes }), offset, options.schema === true), isError: false };
|
|
1749
2091
|
const omittedMatch = findOperation(source.omittedOps ?? [], page);
|
|
1750
|
-
if (omittedMatch) return omittedPlanLimit([omittedMatch], omittedMatch.tool);
|
|
2092
|
+
if (omittedMatch) return omittedPlanLimit(source, [omittedMatch], omittedMatch.tool);
|
|
2093
|
+
if (typeMatch) return { text: docsPartFor({ page }, inputTypeText(source.inputTypes!, typeMatch, readDocsTypeHint), offset), isError: false };
|
|
1751
2094
|
let target = page;
|
|
1752
2095
|
if (!/^https?:\/\//.test(target)) {
|
|
1753
2096
|
const index = await fetchDocs(source, "llms.txt");
|
|
@@ -1759,7 +2102,7 @@ export async function docsRead(source: DocsSource, page: string): Promise<ToolOu
|
|
|
1759
2102
|
? "A docs URL was not provided at generate time, and no generated operation matches \"" + page + "\"."
|
|
1760
2103
|
: "Couldn't fetch \"" + page + "\". Use search_docs to find pages.", "NOT_FOUND", ["search_docs finds operations and guide pages."]);
|
|
1761
2104
|
}
|
|
1762
|
-
return { text:
|
|
2105
|
+
return { text: docsPart(page, text, offset), isError: false };
|
|
1763
2106
|
}
|
|
1764
2107
|
|
|
1765
2108
|
/**
|
|
@@ -1780,16 +2123,39 @@ export async function callSharedTool(
|
|
|
1780
2123
|
return docsSearch(source, args.query, page);
|
|
1781
2124
|
}
|
|
1782
2125
|
if (name === "read_docs") {
|
|
1783
|
-
|
|
2126
|
+
const offset = typeof args.offset === "number" ? args.offset : typeof args.offset === "string" && /^\d+$/.test(args.offset) ? Number(args.offset) : 0;
|
|
2127
|
+
const schema = args.schema === true || args.schema === "true";
|
|
2128
|
+
if (typeof args.page === "string" && !findOperation(source.ops, args.page)) {
|
|
2129
|
+
const hidden = findHidden(source, args.page);
|
|
2130
|
+
if (hidden) return hiddenOutcome(hidden);
|
|
2131
|
+
}
|
|
2132
|
+
const path = typeof args.path === "string" ? args.path : undefined;
|
|
2133
|
+
return typeof args.page === "string" ? docsRead(source, args.page, offset, { schema, path }) : argumentsError({ tool: name }, [{ code: "MISSING_ARGUMENT", argument: "page", message: "read_docs requires a page string." }]);
|
|
1784
2134
|
}
|
|
1785
2135
|
if (name === "execute") {
|
|
1786
2136
|
if (typeof args.operation !== "string") return argumentsError({ tool: name }, [{ code: "MISSING_ARGUMENT", argument: "operation", message: "execute requires an operation name." }]);
|
|
2137
|
+
// Operation arguments (fields included) belong inside arguments; a
|
|
2138
|
+
// stray top-level key would otherwise be ignored without a word.
|
|
2139
|
+
const stray = Object.keys(args).filter((key) => !EXECUTE_KEYS.includes(key) && args[key] !== undefined);
|
|
2140
|
+
if (stray.length > 0) {
|
|
2141
|
+
return argumentsError({ tool: name }, stray.map((key) => ({
|
|
2142
|
+
code: "UNKNOWN_ARGUMENT" as const,
|
|
2143
|
+
argument: key,
|
|
2144
|
+
message: "Unknown argument \"" + key + "\" to execute, which takes " + EXECUTE_KEYS.join(", ") + ". Put operation arguments, including fields, inside arguments: {\"operation\": \"" + args.operation + "\", \"arguments\": {\"" + key + "\": …}}.",
|
|
2145
|
+
})));
|
|
2146
|
+
}
|
|
1787
2147
|
const target = findOperation(source.ops, args.operation);
|
|
1788
2148
|
if (!target) {
|
|
1789
2149
|
const omitted = findOperation(source.omittedOps ?? [], args.operation);
|
|
1790
|
-
return omitted
|
|
1791
|
-
|
|
1792
|
-
|
|
2150
|
+
if (omitted) return omittedPlanLimit(source, [omitted], omitted.tool);
|
|
2151
|
+
const hidden = findHidden(source, args.operation);
|
|
2152
|
+
if (hidden) return hiddenOutcome(hidden);
|
|
2153
|
+
const suggestions = rankOperations(source.ops, args.operation.replace(/[_.]+/g, " ")).slice(0, 3).map((r) => r.op.tool);
|
|
2154
|
+
return textError(
|
|
2155
|
+
"Unknown operation: " + args.operation + "." + (suggestions.length > 0 ? " Did you mean " + suggestions.join(", ") + "?" : ""),
|
|
2156
|
+
"NOT_FOUND",
|
|
2157
|
+
[...(suggestions.length > 0 ? ["read_docs {\"page\": \"" + suggestions[0] + "\"} gives its arguments."] : []), "search_docs finds operations by name, path or description."],
|
|
2158
|
+
);
|
|
1793
2159
|
}
|
|
1794
2160
|
if (operationSafety(target) === "destructive" && args.confirm !== true) {
|
|
1795
2161
|
return textError(
|
|
@@ -1803,5 +2169,12 @@ export async function callSharedTool(
|
|
|
1803
2169
|
: {};
|
|
1804
2170
|
return runOperation(target, opArgs);
|
|
1805
2171
|
}
|
|
1806
|
-
|
|
2172
|
+
// An operation tool the server's switches hide (operations mode).
|
|
2173
|
+
const hidden = source.ops.some((op) => op.tool === name) ? undefined : (source.hiddenOps ?? []).find((h) => h.op.tool === name);
|
|
2174
|
+
return hidden ? hiddenOutcome(hidden) : undefined;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/** Every OAuth scope an operation's security requirements name, in order. */
|
|
2178
|
+
export function requiredScopes(security: Record<string, string[]>[] | undefined): string[] {
|
|
2179
|
+
return [...new Set((security ?? []).flatMap((requirement) => Object.values(requirement).flat()))];
|
|
1807
2180
|
}
|