@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/dist/mcp-protocol.js
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
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.
|
|
15
15
|
*
|
|
16
16
|
* Spec: https://modelcontextprotocol.io/specification/2026-07-28
|
|
17
17
|
*/
|
|
18
|
-
import {
|
|
18
|
+
import { checkValue, closestName, isMeReference, meIdentityField, normalizeName, userShapedReference } from "./arguments.js";
|
|
19
19
|
import { docsReadTarget, resolveDocsContentUrl, searchConnectedGuides } from "./docs.js";
|
|
20
|
+
import { projectFields, unmatchedFields, unmatchedFieldsMessage } from "./fields.js";
|
|
21
|
+
import { SEARCH_PAGE_SIZE, rankOperations } from "./search.js";
|
|
22
|
+
import { argumentPathText, findInputType, inputTypeText, namedTypesIn } from "./type-docs.js";
|
|
23
|
+
export { projectFields } from "./fields.js";
|
|
20
24
|
export const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
21
25
|
export const LEGACY_PROTOCOL_VERSION = "2025-11-25";
|
|
22
26
|
/** The modern model and the supported initialize-handshake wire revision. */
|
|
@@ -52,7 +56,7 @@ export class McpAccountLinkRequired extends Error {
|
|
|
52
56
|
Object.freeze(this);
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
|
-
const API_LINK_INPUT = "
|
|
59
|
+
const API_LINK_INPUT = "api_account";
|
|
56
60
|
/** Does the operation take a file (multipart form or raw binary body)? */
|
|
57
61
|
export function isUploadOp(op) {
|
|
58
62
|
return op.bodyKind === "multipart" || op.bodyKind === "binary";
|
|
@@ -127,17 +131,16 @@ export function checkRequestHeaders(headers, message) {
|
|
|
127
131
|
const id = message.id;
|
|
128
132
|
const version = headers.get("mcp-protocol-version");
|
|
129
133
|
const bodyVersion = message.params?._meta?.[META_VERSION];
|
|
130
|
-
if (version !== null && !SUPPORTED_PROTOCOL_VERSIONS.includes(version))
|
|
131
|
-
return rpcError(id, -32022, "Unsupported protocol version", 400, { supported: SUPPORTED_PROTOCOL_VERSIONS, requested: version });
|
|
132
134
|
if (message.method === "initialize" || version === LEGACY_PROTOCOL_VERSION) {
|
|
133
135
|
if (bodyVersion !== undefined || headers.get("mcp-method") !== null || headers.get("mcp-name") !== null) {
|
|
134
136
|
return rpcError(id, -32020, "Header mismatch: initialize-handshake requests cannot carry modern protocol metadata or routing headers.", 400);
|
|
135
137
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
138
|
+
// initialize negotiates the revision in its body, so a header naming an
|
|
139
|
+
// older one is not an error here.
|
|
139
140
|
return null;
|
|
140
141
|
}
|
|
142
|
+
if (version !== null && !SUPPORTED_PROTOCOL_VERSIONS.includes(version))
|
|
143
|
+
return rpcError(id, -32022, "Unsupported protocol version", 400, { supported: SUPPORTED_PROTOCOL_VERSIONS, requested: version });
|
|
141
144
|
if (version === null)
|
|
142
145
|
return rpcError(id, -32020, "Header mismatch: MCP-Protocol-Version header is required", 400);
|
|
143
146
|
if (typeof bodyVersion === "string" && version !== bodyVersion) {
|
|
@@ -226,8 +229,10 @@ export async function handleRpc(server, incoming, protocolVersion) {
|
|
|
226
229
|
!info || typeof info !== "object" || Array.isArray(info) || typeof info.name !== "string" || typeof info.version !== "string") {
|
|
227
230
|
return rpcError(id, -32602, "initialize requires protocolVersion, capabilities, and clientInfo with name and version.", 400);
|
|
228
231
|
}
|
|
229
|
-
|
|
230
|
-
|
|
232
|
+
// Version negotiation: the server answers with a revision it supports
|
|
233
|
+
// (the requested one when it can), and a client that cannot speak it
|
|
234
|
+
// disconnects. So an older request (2025-06-18, 2025-03-26, 2024-11-05)
|
|
235
|
+
// gets 2025-11-25 rather than an error.
|
|
231
236
|
return { status: 200, message: { jsonrpc: "2.0", id, result: {
|
|
232
237
|
protocolVersion: LEGACY_PROTOCOL_VERSION,
|
|
233
238
|
capabilities: { tools: {} },
|
|
@@ -385,132 +390,6 @@ export function operationSafety(op) {
|
|
|
385
390
|
? "destructive"
|
|
386
391
|
: "write";
|
|
387
392
|
}
|
|
388
|
-
/** A deterministic, schema-valid-enough example for documentation and
|
|
389
|
-
* agent calls. Prefer facts supplied by the API author, then conservative
|
|
390
|
-
* values based on formats and field names. Only required object fields are
|
|
391
|
-
* included, keeping examples useful instead of manufacturing giant bodies. */
|
|
392
|
-
export function exampleFromSchema(schema, field = "value", depth = 0) {
|
|
393
|
-
if (!schema || typeof schema !== "object" || depth > 8)
|
|
394
|
-
return null;
|
|
395
|
-
const node = schema;
|
|
396
|
-
if (node.const !== undefined)
|
|
397
|
-
return node.const;
|
|
398
|
-
if (node.example !== undefined)
|
|
399
|
-
return node.example;
|
|
400
|
-
if (Array.isArray(node.examples) && node.examples.length > 0)
|
|
401
|
-
return node.examples[0];
|
|
402
|
-
if (node.default !== undefined)
|
|
403
|
-
return node.default;
|
|
404
|
-
if (Array.isArray(node.enum) && node.enum.length > 0) {
|
|
405
|
-
if (typeof node.pattern === "string") {
|
|
406
|
-
try {
|
|
407
|
-
const pattern = new RegExp(node.pattern);
|
|
408
|
-
const matching = node.enum.find((value) => typeof value === "string" && pattern.test(value));
|
|
409
|
-
if (matching !== undefined)
|
|
410
|
-
return matching;
|
|
411
|
-
}
|
|
412
|
-
catch { /* malformed patterns are ignored for examples */ }
|
|
413
|
-
}
|
|
414
|
-
return node.enum.find((v) => v !== null) ?? node.enum[0];
|
|
415
|
-
}
|
|
416
|
-
const variants = (Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : null);
|
|
417
|
-
if (variants) {
|
|
418
|
-
const useful = variants.find((v) => v && typeof v === "object" && v.type !== "null") ?? variants[0];
|
|
419
|
-
return exampleFromSchema(useful, field, depth + 1);
|
|
420
|
-
}
|
|
421
|
-
const type = Array.isArray(node.type) ? node.type.find((v) => v !== "null") : node.type;
|
|
422
|
-
if (type === "object" || node.properties || node.additionalProperties) {
|
|
423
|
-
const properties = (node.properties ?? {});
|
|
424
|
-
const required = new Set(Array.isArray(node.required) ? node.required.filter((v) => typeof v === "string") : []);
|
|
425
|
-
// At the operation root, optional really means optional: the most honest
|
|
426
|
-
// runnable example is `{}`. Inside a required object, one representative
|
|
427
|
-
// optional field still makes an otherwise empty nested shape legible.
|
|
428
|
-
const names = required.size > 0 ? [...required] : depth === 0 ? [] : Object.keys(properties).slice(0, 1);
|
|
429
|
-
const value = {};
|
|
430
|
-
for (const name of names) {
|
|
431
|
-
if (properties[name] !== undefined)
|
|
432
|
-
value[name] = exampleFromSchema(properties[name], name, depth + 1);
|
|
433
|
-
}
|
|
434
|
-
if (Object.keys(value).length === 0 && node.additionalProperties && typeof node.additionalProperties === "object") {
|
|
435
|
-
value.key = exampleFromSchema(node.additionalProperties, "key", depth + 1);
|
|
436
|
-
}
|
|
437
|
-
return value;
|
|
438
|
-
}
|
|
439
|
-
if (type === "array" || node.items) {
|
|
440
|
-
const count = typeof node.minItems === "number" && node.minItems > 1 ? Math.min(node.minItems, 3) : 1;
|
|
441
|
-
return Array.from({ length: count }, () => exampleFromSchema(node.items, field, depth + 1));
|
|
442
|
-
}
|
|
443
|
-
if (type === "integer" || type === "number") {
|
|
444
|
-
if (typeof node.minimum === "number")
|
|
445
|
-
return node.minimum;
|
|
446
|
-
if (typeof node.exclusiveMinimum === "number")
|
|
447
|
-
return node.exclusiveMinimum + 1;
|
|
448
|
-
return 1;
|
|
449
|
-
}
|
|
450
|
-
if (type === "boolean")
|
|
451
|
-
return true;
|
|
452
|
-
if (type === "string" || type === undefined) {
|
|
453
|
-
const format = typeof node.format === "string" ? node.format : "";
|
|
454
|
-
const lower = field.toLowerCase();
|
|
455
|
-
const min = typeof node.minLength === "number" ? node.minLength : 0;
|
|
456
|
-
const max = typeof node.maxLength === "number" ? node.maxLength : undefined;
|
|
457
|
-
const patterned = typeof node.pattern === "string" ? exampleMatchingPattern(node.pattern, min, max) : null;
|
|
458
|
-
let value = patterned ?? (format === "date-time" ? "2026-01-15T12:00:00Z"
|
|
459
|
-
: format === "date" ? "2026-01-15"
|
|
460
|
-
: format === "email" || lower.includes("email") ? "person@example.com"
|
|
461
|
-
: (format === "uri" || format === "url" || lower.endsWith("url")) && (lower.includes("webhook") || lower.includes("callback")) ? "https://example.com/webhook"
|
|
462
|
-
: format === "uri" || format === "url" || lower.endsWith("url") ? "https://example.com"
|
|
463
|
-
: format === "uuid" ? "00000000-0000-4000-8000-000000000000"
|
|
464
|
-
: lower.includes("repository") || lower === "repo" ? "acme/api"
|
|
465
|
-
: lower.includes("path") ? "openapi.yaml"
|
|
466
|
-
: lower.includes("version") ? "1.0.0"
|
|
467
|
-
: /(^|_)id$|Id$/.test(field) ? (lower === "id" ? "id" : field.replace(/[_-]?id$/i, "")) + "_123"
|
|
468
|
-
: lower.includes("name") ? "example"
|
|
469
|
-
: "value");
|
|
470
|
-
while (value.length < min)
|
|
471
|
-
value += "x";
|
|
472
|
-
if (max !== undefined)
|
|
473
|
-
value = value.slice(0, max);
|
|
474
|
-
return value;
|
|
475
|
-
}
|
|
476
|
-
return null;
|
|
477
|
-
}
|
|
478
|
-
/** A useful value for the common API-id pattern (`^agt_`, `^src_[a-z0-9]+$`).
|
|
479
|
-
* Full regex generation would be surprising and heavyweight; an anchored
|
|
480
|
-
* literal prefix plus ordinary id suffix covers the schemas that use a
|
|
481
|
-
* pattern to communicate a typed identifier. Every candidate is checked by
|
|
482
|
-
* the actual RegExp before it is returned. */
|
|
483
|
-
function exampleMatchingPattern(pattern, minLength, maxLength) {
|
|
484
|
-
let regex;
|
|
485
|
-
try {
|
|
486
|
-
regex = new RegExp(pattern);
|
|
487
|
-
}
|
|
488
|
-
catch {
|
|
489
|
-
return null;
|
|
490
|
-
}
|
|
491
|
-
const match = /^\^((?:\\.|[A-Za-z0-9_-])+)/.exec(pattern);
|
|
492
|
-
const prefix = match?.[1]?.replace(/\\(.)/g, "$1") ?? "";
|
|
493
|
-
const fit = (candidate) => {
|
|
494
|
-
let value = candidate;
|
|
495
|
-
while (value.length < minLength)
|
|
496
|
-
value += "x";
|
|
497
|
-
if (maxLength !== undefined)
|
|
498
|
-
value = value.slice(0, maxLength);
|
|
499
|
-
return value;
|
|
500
|
-
};
|
|
501
|
-
const candidates = [prefix + "123", prefix + "example", prefix, "resource.method", "example.value", "example_123", "example", "value"];
|
|
502
|
-
for (const candidate of candidates) {
|
|
503
|
-
const value = fit(candidate);
|
|
504
|
-
regex.lastIndex = 0;
|
|
505
|
-
if (regex.test(value))
|
|
506
|
-
return value;
|
|
507
|
-
}
|
|
508
|
-
return null;
|
|
509
|
-
}
|
|
510
|
-
export function exampleArgumentsFromSchema(inputSchema) {
|
|
511
|
-
const value = exampleFromSchema(inputSchema, "arguments");
|
|
512
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
513
|
-
}
|
|
514
393
|
/** The `fields` argument every tool takes unless the API already has one:
|
|
515
394
|
* dotted paths to keep in the result (per item for paginated tools). */
|
|
516
395
|
export const FIELDS_ARGUMENT = "fields";
|
|
@@ -550,23 +429,22 @@ export function operationTool(op) {
|
|
|
550
429
|
...(op.outputSchema ? { outputSchema: op.outputSchema } : {}),
|
|
551
430
|
};
|
|
552
431
|
}
|
|
553
|
-
|
|
554
|
-
export const SEARCH_PAGE_SIZE = 15;
|
|
432
|
+
export { SEARCH_PAGE_SIZE } from "./search.js";
|
|
555
433
|
export const SEARCH_DOCS_TOOL = {
|
|
556
434
|
name: "search_docs",
|
|
557
435
|
description: "Search this API's reference (operations, parameters) and, when a docs site is configured, its guides. Best matches first; page through with page.",
|
|
558
|
-
inputSchema: { type: "object", properties: { query: { type: "string" }, page: { type: "integer", minimum: 1, description: "Page of reference matches (
|
|
436
|
+
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"] },
|
|
559
437
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
560
438
|
};
|
|
561
439
|
export const READ_DOCS_TOOL = {
|
|
562
440
|
name: "read_docs",
|
|
563
|
-
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.',
|
|
564
|
-
inputSchema: { type: "object", properties: { page: { type: "string" } }, required: ["page"] },
|
|
441
|
+
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.',
|
|
442
|
+
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"] },
|
|
565
443
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
566
444
|
};
|
|
567
445
|
export const EXECUTE_TOOL = {
|
|
568
446
|
name: "execute",
|
|
569
|
-
description: "Execute an API operation by name. Discover it with search_docs, then read_docs <operation> for its
|
|
447
|
+
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.",
|
|
570
448
|
inputSchema: {
|
|
571
449
|
type: "object",
|
|
572
450
|
properties: {
|
|
@@ -578,6 +456,8 @@ export const EXECUTE_TOOL = {
|
|
|
578
456
|
},
|
|
579
457
|
annotations: { openWorldHint: false },
|
|
580
458
|
};
|
|
459
|
+
/** The keys execute itself takes; everything else is an operation argument. */
|
|
460
|
+
const EXECUTE_KEYS = ["operation", "arguments", "confirm"];
|
|
581
461
|
/** The operations a server serves under these options: the callable set,
|
|
582
462
|
* not just the listed one, so a hidden write is not reachable by name or
|
|
583
463
|
* through execute either. Deterministic (spec) order. */
|
|
@@ -587,12 +467,49 @@ export function visibleOps(ops, options = {}) {
|
|
|
587
467
|
out = out.filter(isReadOperation);
|
|
588
468
|
if (options.include && options.include.length > 0) {
|
|
589
469
|
const wanted = new Set(options.include.map((s) => s.trim().toLowerCase()).filter(Boolean));
|
|
590
|
-
out = out.filter((op) =>
|
|
591
|
-
(op.resource !== undefined && wanted.has(op.resource.toLowerCase())) ||
|
|
592
|
-
(op.resource !== undefined && op.method !== undefined && wanted.has((op.resource + "." + op.method).toLowerCase())));
|
|
470
|
+
out = out.filter((op) => includedBy(op, wanted));
|
|
593
471
|
}
|
|
594
472
|
return out;
|
|
595
473
|
}
|
|
474
|
+
function includedBy(op, wanted) {
|
|
475
|
+
return wanted.has(op.tool.toLowerCase()) ||
|
|
476
|
+
(op.resource !== undefined && wanted.has(op.resource.toLowerCase())) ||
|
|
477
|
+
(op.resource !== undefined && op.method !== undefined && wanted.has((op.resource + "." + op.method).toLowerCase()));
|
|
478
|
+
}
|
|
479
|
+
/** Entries of an include list that name no operation (a typo such as
|
|
480
|
+
* "pull" for "pulls"). Matched against every generated operation, so an
|
|
481
|
+
* entry the other switches hide still counts as a name. */
|
|
482
|
+
export function unmatchedIncludes(ops, include) {
|
|
483
|
+
return (include ?? []).filter((entry) => !ops.some((op) => includedBy(op, new Set([entry.trim().toLowerCase()]))));
|
|
484
|
+
}
|
|
485
|
+
/** The exposed operations that readOnly or include hide, each explained in
|
|
486
|
+
* terms of the switch the operator set (`switches` names them as the
|
|
487
|
+
* operator wrote them, such as "--read-only"). */
|
|
488
|
+
export function hiddenOperations(ops, options, switches) {
|
|
489
|
+
const visible = new Set(visibleOps(ops, options));
|
|
490
|
+
return ops.filter((op) => !visible.has(op) && mcpExposed(op, { uploads: options.uploads })).map((op) => {
|
|
491
|
+
const included = !options.include?.length || includedBy(op, new Set(options.include.map((entry) => entry.trim().toLowerCase())));
|
|
492
|
+
return included
|
|
493
|
+
? {
|
|
494
|
+
op,
|
|
495
|
+
message: op.tool + " is a write, and this server is read-only (" + switches.readOnly + "), so it cannot be called here.",
|
|
496
|
+
nextSteps: ["Writes need a server started without " + switches.readOnly + "; tell the user if this operation is required."],
|
|
497
|
+
}
|
|
498
|
+
: {
|
|
499
|
+
op,
|
|
500
|
+
message: op.tool + " is not enabled on this server: " + switches.include + " limits it to " + (options.include ?? []).join(", ") + ".",
|
|
501
|
+
nextSteps: ["The operation needs a server whose " + switches.include + " includes " + (op.resource ?? op.tool) + "; tell the user if it is required."],
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
function findHidden(source, wanted) {
|
|
506
|
+
const hidden = source.hiddenOps ?? [];
|
|
507
|
+
const op = findOperation(hidden.map((h) => h.op), wanted);
|
|
508
|
+
return op ? hidden.find((h) => h.op === op) : undefined;
|
|
509
|
+
}
|
|
510
|
+
function hiddenOutcome(hidden) {
|
|
511
|
+
return textError(hidden.message, "NOT_AVAILABLE", hidden.nextSteps);
|
|
512
|
+
}
|
|
596
513
|
/** Parse a comma-separated include list (from an env var or a flag). */
|
|
597
514
|
export function parseIncludeList(value) {
|
|
598
515
|
const list = (value ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
@@ -607,14 +524,15 @@ export function toolDefinitions(ops, mode, omittedOps = []) {
|
|
|
607
524
|
if (mode === "meta") {
|
|
608
525
|
const execute = structuredClone(EXECUTE_TOOL);
|
|
609
526
|
const operation = execute.inputSchema.properties.operation;
|
|
610
|
-
|
|
611
|
-
|
|
527
|
+
const example = ops.find((op) => op.showcase) ?? ops[0];
|
|
528
|
+
if (example)
|
|
529
|
+
operation.examples = [example.tool];
|
|
612
530
|
const coverage = omittedOps.length > 0
|
|
613
|
-
? "
|
|
531
|
+
? " This build includes " + ops.length + " of " + (ops.length + omittedOps.length) + " operations."
|
|
614
532
|
: "";
|
|
615
533
|
return [
|
|
616
534
|
{ ...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 },
|
|
617
|
-
{ ...READ_DOCS_TOOL, description: "Read an operation's
|
|
535
|
+
{ ...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." },
|
|
618
536
|
execute,
|
|
619
537
|
];
|
|
620
538
|
}
|
|
@@ -629,6 +547,58 @@ export function findOperation(ops, wanted) {
|
|
|
629
547
|
export function missingArguments(op, args) {
|
|
630
548
|
return op.params.filter((p) => p.required && args[p.name] === undefined).map((p) => p.name);
|
|
631
549
|
}
|
|
550
|
+
// ---- server instructions ----------------------------------------------------------
|
|
551
|
+
/** Identity fields that name the caller for a login-shaped "me" (GitHub's
|
|
552
|
+
* users_get_authenticated returns login). */
|
|
553
|
+
const IDENTITY_LOGIN_FIELDS = ["login", "username", "handle"];
|
|
554
|
+
const MAX_ME_SCHEMA_DEPTH = 6;
|
|
555
|
+
function acceptsString(schema) {
|
|
556
|
+
if (!schema || typeof schema !== "object")
|
|
557
|
+
return false;
|
|
558
|
+
const record = schema;
|
|
559
|
+
const type = record.type;
|
|
560
|
+
if (type === "string" || (Array.isArray(type) && type.includes("string")))
|
|
561
|
+
return true;
|
|
562
|
+
const variants = record.anyOf ?? record.oneOf;
|
|
563
|
+
return Array.isArray(variants) && variants.some(acceptsString);
|
|
564
|
+
}
|
|
565
|
+
function objectVariants(schema) {
|
|
566
|
+
if (!schema || typeof schema !== "object")
|
|
567
|
+
return [];
|
|
568
|
+
const record = schema;
|
|
569
|
+
const variants = record.anyOf ?? record.oneOf;
|
|
570
|
+
return [record, ...(Array.isArray(variants) ? variants.flatMap(objectVariants) : [])]
|
|
571
|
+
.filter((candidate) => candidate.properties && typeof candidate.properties === "object");
|
|
572
|
+
}
|
|
573
|
+
/** Every argument path where the server resolves "me": a user-shaped string
|
|
574
|
+
* (or a list or union that takes one) at the top level or inside an object
|
|
575
|
+
* argument. The instructions name exactly these. */
|
|
576
|
+
export function meReferenceArguments(ops) {
|
|
577
|
+
const found = new Set();
|
|
578
|
+
const visit = (name, schema, path, depth) => {
|
|
579
|
+
if (!schema || typeof schema !== "object" || depth > MAX_ME_SCHEMA_DEPTH)
|
|
580
|
+
return;
|
|
581
|
+
const record = schema;
|
|
582
|
+
const items = record.items ?? (Array.isArray(record.anyOf ?? record.oneOf) ? (record.anyOf ?? record.oneOf).find((variant) => variant && variant.items)?.items : undefined);
|
|
583
|
+
if (userShapedReference(name) && (acceptsString(record) || acceptsString(items)))
|
|
584
|
+
found.add(path);
|
|
585
|
+
const nested = [[objectVariants(record), path], [objectVariants(items), path + "[]"]];
|
|
586
|
+
for (const [variants, prefix] of nested) {
|
|
587
|
+
for (const variant of variants) {
|
|
588
|
+
for (const [key, child] of Object.entries(variant.properties))
|
|
589
|
+
visit(key, child, prefix + "." + key, depth + 1);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
for (const op of ops) {
|
|
594
|
+
const properties = (op.inputSchema.properties ?? {});
|
|
595
|
+
for (const param of op.params) {
|
|
596
|
+
if (param.resolve !== false)
|
|
597
|
+
visit(param.name, properties[param.name], param.name, 0);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return [...found].sort((a, b) => a.split(".").length - b.split(".").length || a.localeCompare(b));
|
|
601
|
+
}
|
|
632
602
|
/** The server/discover instructions: what the tools are, how arguments and
|
|
633
603
|
* results behave, where credentials come from, plus whatever the project
|
|
634
604
|
* adds. One paragraph; agents read it once per session. */
|
|
@@ -639,13 +609,14 @@ export function serverInstructions(input) {
|
|
|
639
609
|
: 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.");
|
|
640
610
|
if (input.omittedOps?.length) {
|
|
641
611
|
const generated = input.generatedOperationCount ?? input.toolCount;
|
|
642
|
-
parts.push("
|
|
612
|
+
parts.push("This build includes " + generated + " of " + (generated + input.omittedOps.length) + " operations; calling or searching for one of the others returns PLAN_LIMIT.");
|
|
643
613
|
}
|
|
644
614
|
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).");
|
|
645
615
|
if (input.referenceResolution)
|
|
646
616
|
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.");
|
|
647
|
-
|
|
648
|
-
|
|
617
|
+
const meArguments = input.identityTool ? meReferenceArguments(input.ops) : [];
|
|
618
|
+
if (meArguments.length > 0) {
|
|
619
|
+
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(", ") + ".");
|
|
649
620
|
}
|
|
650
621
|
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.");
|
|
651
622
|
parts.push("Errors carry error, code, message, status, the API's body and next_steps.");
|
|
@@ -662,160 +633,31 @@ export function serverInstructions(input) {
|
|
|
662
633
|
parts.push(input.custom.trim());
|
|
663
634
|
return parts.join(" ");
|
|
664
635
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
for (let j = 1; j <= b.length; j++) {
|
|
672
|
-
const tmp = prev[j];
|
|
673
|
-
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
674
|
-
diag = tmp;
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
return prev[b.length];
|
|
678
|
-
}
|
|
679
|
-
/** The closest accepted name: same letters ignoring case/punctuation first,
|
|
680
|
-
* then a small edit distance. Undefined when nothing is close. */
|
|
681
|
-
export function closestName(name, known) {
|
|
682
|
-
const exact = known.filter((k) => normalizeName(k) === normalizeName(name));
|
|
683
|
-
if (exact.length === 1)
|
|
684
|
-
return exact[0];
|
|
685
|
-
if (exact.length > 1)
|
|
686
|
-
return undefined;
|
|
687
|
-
let best;
|
|
688
|
-
for (const k of known) {
|
|
689
|
-
const d = editDistance(name.toLowerCase(), k.toLowerCase());
|
|
690
|
-
if (d <= Math.max(1, Math.floor(k.length / 4)) && (best === undefined || d < best.d))
|
|
691
|
-
best = { name: k, d };
|
|
692
|
-
}
|
|
693
|
-
return best?.name;
|
|
694
|
-
}
|
|
695
|
-
function schemaTypes(schema) {
|
|
696
|
-
const t = schema.type;
|
|
697
|
-
if (typeof t === "string")
|
|
698
|
-
return [t];
|
|
699
|
-
if (Array.isArray(t))
|
|
700
|
-
return t.filter((x) => typeof x === "string");
|
|
701
|
-
return [];
|
|
702
|
-
}
|
|
703
|
-
/**
|
|
704
|
-
* Coerce one value toward its schema when the intent is unambiguous: the
|
|
705
|
-
* strings agents produce for booleans and numbers, a JSON string for an
|
|
706
|
-
* object or array, a scalar for a one-element array, an enum member in the
|
|
707
|
-
* wrong case. Returns the value to send, or a message when it can't be made
|
|
708
|
-
* to fit. Untyped schemas (unions, anything) pass through.
|
|
709
|
-
*/
|
|
710
|
-
export function coerceValue(value, schema) {
|
|
711
|
-
if (value === null || value === undefined)
|
|
712
|
-
return { value };
|
|
713
|
-
// Date-shaped arguments take relative forms (-P7D, 7 days ago, today),
|
|
714
|
-
// resolved here so the API sees an absolute value.
|
|
715
|
-
const dateKind = dateKindOf(schema.format);
|
|
716
|
-
if (dateKind && typeof value === "string") {
|
|
717
|
-
const resolved = relativeDate(value, dateKind);
|
|
718
|
-
if (resolved && "error" in resolved)
|
|
719
|
-
return { error: resolved.error };
|
|
720
|
-
if (resolved)
|
|
721
|
-
value = resolved.value;
|
|
722
|
-
}
|
|
723
|
-
const types = schemaTypes(schema);
|
|
724
|
-
const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
|
|
725
|
-
const accepts = (t) => types.length === 0 || types.includes(t);
|
|
726
|
-
const kind = Array.isArray(value) ? "array" : typeof value;
|
|
727
|
-
let out = value;
|
|
728
|
-
if (types.length > 0) {
|
|
729
|
-
if (kind === "boolean" && !accepts("boolean")) {
|
|
730
|
-
if (accepts("string"))
|
|
731
|
-
out = String(value);
|
|
732
|
-
else
|
|
733
|
-
return { error: "expected " + types.join(" or ") + ", got boolean" };
|
|
734
|
-
}
|
|
735
|
-
else if (kind === "number" && !accepts("number") && !accepts("integer")) {
|
|
736
|
-
if (accepts("string"))
|
|
737
|
-
out = String(value);
|
|
738
|
-
else if (accepts("array"))
|
|
739
|
-
out = [value];
|
|
740
|
-
else
|
|
741
|
-
return { error: "expected " + types.join(" or ") + ", got number" };
|
|
742
|
-
}
|
|
743
|
-
else if (kind === "number" && accepts("integer") && !accepts("number") && !Number.isInteger(value)) {
|
|
744
|
-
return { error: "expected an integer, got " + String(value) };
|
|
745
|
-
}
|
|
746
|
-
else if (kind === "string" && !accepts("string")) {
|
|
747
|
-
const s = value.trim();
|
|
748
|
-
if (accepts("boolean") && /^(true|false|yes|no|1|0)$/i.test(s))
|
|
749
|
-
out = /^(true|yes|1)$/i.test(s);
|
|
750
|
-
else if ((accepts("integer") || accepts("number")) && s !== "" && !Number.isNaN(Number(s))) {
|
|
751
|
-
const n = Number(s);
|
|
752
|
-
if (accepts("integer") && !accepts("number") && !Number.isInteger(n))
|
|
753
|
-
return { error: "expected an integer, got \"" + s + "\"" };
|
|
754
|
-
out = n;
|
|
755
|
-
}
|
|
756
|
-
else if ((accepts("object") || accepts("array")) && /^[[{]/.test(s)) {
|
|
757
|
-
try {
|
|
758
|
-
const parsed = JSON.parse(s);
|
|
759
|
-
const parsedKind = Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed;
|
|
760
|
-
if (!accepts(parsedKind))
|
|
761
|
-
return { error: "expected " + types.join(" or ") + ", got a JSON " + parsedKind + " in a string" };
|
|
762
|
-
out = parsed;
|
|
763
|
-
}
|
|
764
|
-
catch {
|
|
765
|
-
return { error: "expected " + types.join(" or ") + ", got a string that is not valid JSON" };
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
else if (accepts("array")) {
|
|
769
|
-
out = [value];
|
|
770
|
-
}
|
|
771
|
-
else {
|
|
772
|
-
return { error: "expected " + types.join(" or ") + ", got string" };
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
else if (kind === "object" && !accepts("object")) {
|
|
776
|
-
if (accepts("array"))
|
|
777
|
-
out = [value];
|
|
778
|
-
else
|
|
779
|
-
return { error: "expected " + types.join(" or ") + ", got object" };
|
|
780
|
-
}
|
|
781
|
-
else if (kind === "array" && !accepts("array")) {
|
|
782
|
-
return { error: "expected " + types.join(" or ") + ", got array" };
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
// Array items: coerce each against the items schema when it has one.
|
|
786
|
-
if (Array.isArray(out) && schema.items && typeof schema.items === "object" && !Array.isArray(schema.items)) {
|
|
787
|
-
const itemSchema = schema.items;
|
|
788
|
-
const items = [];
|
|
789
|
-
for (let i = 0; i < out.length; i++) {
|
|
790
|
-
const r = coerceValue(out[i], itemSchema);
|
|
791
|
-
if ("error" in r)
|
|
792
|
-
return { error: "item " + i + ": " + r.error };
|
|
793
|
-
items.push(r.value);
|
|
794
|
-
}
|
|
795
|
-
out = items;
|
|
796
|
-
}
|
|
797
|
-
if (enumValues && typeof out === "string" && !enumValues.includes(out)) {
|
|
798
|
-
const match = enumValues.filter((e) => typeof e === "string" && e.toLowerCase() === out.toLowerCase());
|
|
799
|
-
if (match.length === 1)
|
|
800
|
-
out = match[0];
|
|
801
|
-
else
|
|
802
|
-
return { error: "must be one of " + enumValues.map((e) => JSON.stringify(e)).join(", ") + ", got " + JSON.stringify(out) };
|
|
803
|
-
}
|
|
804
|
-
return { value: out };
|
|
805
|
-
}
|
|
636
|
+
// ---- argument validation + coercion ---------------------------------------------
|
|
637
|
+
// The checks themselves live in ./arguments, shared with the CLI.
|
|
638
|
+
export { checkValue, closestName, coerceValue } from "./arguments.js";
|
|
639
|
+
/** Field paths, and which of them may be absent: a leading "?" marks a
|
|
640
|
+
* path the result may lack, as reference resolution's list calls ask for
|
|
641
|
+
* match keys an API can omit. Any other path must match something. */
|
|
806
642
|
function parseFields(value) {
|
|
807
643
|
const raw = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : null;
|
|
808
644
|
if (raw === null)
|
|
809
645
|
return { error: "expected an array of field paths, e.g. [\"id\",\"name\"]" };
|
|
810
646
|
const paths = [];
|
|
647
|
+
const optional = [];
|
|
811
648
|
for (const entry of raw) {
|
|
812
649
|
if (typeof entry !== "string")
|
|
813
650
|
return { error: "expected an array of strings" };
|
|
814
|
-
|
|
651
|
+
let path = entry.trim();
|
|
652
|
+
if (path.startsWith("?")) {
|
|
653
|
+
path = path.slice(1).trim();
|
|
654
|
+
if (path !== "")
|
|
655
|
+
optional.push(path);
|
|
656
|
+
}
|
|
815
657
|
if (path !== "")
|
|
816
658
|
paths.push(path.split("."));
|
|
817
659
|
}
|
|
818
|
-
return paths;
|
|
660
|
+
return { paths, optional };
|
|
819
661
|
}
|
|
820
662
|
/**
|
|
821
663
|
* Check a tool call's arguments against the tool's input schema before
|
|
@@ -852,23 +694,40 @@ export function prepareCall(op, rawArgs, options = {}) {
|
|
|
852
694
|
});
|
|
853
695
|
}
|
|
854
696
|
let fields = null;
|
|
697
|
+
let optionalFields = [];
|
|
855
698
|
if (!hasOwnFieldsParam(op) && args[FIELDS_ARGUMENT] !== undefined) {
|
|
856
699
|
const parsed = parseFields(args[FIELDS_ARGUMENT]);
|
|
857
700
|
if ("error" in parsed)
|
|
858
701
|
issues.push({ code: "INVALID_ARGUMENT", argument: FIELDS_ARGUMENT, message: "fields: " + parsed.error });
|
|
859
|
-
else
|
|
860
|
-
fields
|
|
702
|
+
else if (parsed.paths.length > 0)
|
|
703
|
+
({ paths: fields, optional: optionalFields } = parsed);
|
|
861
704
|
delete args[FIELDS_ARGUMENT];
|
|
862
705
|
}
|
|
863
706
|
for (const [name, value] of Object.entries(args)) {
|
|
864
707
|
const propSchema = properties[name];
|
|
865
708
|
if (!propSchema)
|
|
866
709
|
continue;
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
710
|
+
// Patterns apply inside objects and arrays. A top-level argument's own
|
|
711
|
+
// pattern is left to the API: its documented example values do not all
|
|
712
|
+
// satisfy it yet, and a name or "me" is only resolved to an ID later.
|
|
713
|
+
args[name] = checkValue(value, propSchema, name, issues, { skipPattern: true, meName: name });
|
|
714
|
+
}
|
|
715
|
+
// A path argument that is the configured credential's username (Twilio's
|
|
716
|
+
// AccountSid) defaults to it; without one it is an ordinary missing argument
|
|
717
|
+
// whose message says where the default would come from.
|
|
718
|
+
for (const param of op.params) {
|
|
719
|
+
if (!param.credential || args[param.name] !== undefined)
|
|
720
|
+
continue;
|
|
721
|
+
const username = options.credentialUsername ?? undefined;
|
|
722
|
+
if (username !== undefined && username !== "") {
|
|
723
|
+
args[param.name] = username;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
issues.push({
|
|
727
|
+
code: "MISSING_ARGUMENT",
|
|
728
|
+
argument: param.name,
|
|
729
|
+
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.",
|
|
730
|
+
});
|
|
872
731
|
}
|
|
873
732
|
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
874
733
|
for (const name of required) {
|
|
@@ -878,7 +737,7 @@ export function prepareCall(op, rawArgs, options = {}) {
|
|
|
878
737
|
}
|
|
879
738
|
if (issues.length > 0)
|
|
880
739
|
return { ok: false, outcome: argumentsError(op, issues) };
|
|
881
|
-
return { ok: true, call: { args, fields, maxChars: options.maxChars ?? DEFAULT_MAX_RESULT_CHARS } };
|
|
740
|
+
return { ok: true, call: { args, fields, optionalFields, maxChars: options.maxChars ?? DEFAULT_MAX_RESULT_CHARS } };
|
|
882
741
|
}
|
|
883
742
|
function referenceError(code, message, argument, nextSteps, candidates) {
|
|
884
743
|
const structured = {
|
|
@@ -921,10 +780,6 @@ function referencePage(value) {
|
|
|
921
780
|
: null,
|
|
922
781
|
};
|
|
923
782
|
}
|
|
924
|
-
function userShapedReference(name) {
|
|
925
|
-
const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/id$/, "");
|
|
926
|
-
return ["user", "assignee", "owner", "member", "actor", "creator", "account", "profile"].includes(normalized);
|
|
927
|
-
}
|
|
928
783
|
function referenceMatchLabel(fields) {
|
|
929
784
|
return fields.length === 1 ? fields[0] : fields.slice(0, -1).join(", ") + " or " + fields.at(-1);
|
|
930
785
|
}
|
|
@@ -961,32 +816,82 @@ function candidateRecord(item, resolver) {
|
|
|
961
816
|
export async function resolveReferences(op, preparedArgs, options) {
|
|
962
817
|
const args = { ...preparedArgs };
|
|
963
818
|
const maxPages = Math.max(1, Math.floor(options.maxPages ?? 5));
|
|
819
|
+
const identity = options.identityTool ? findOperation(options.ops, options.identityTool) : undefined;
|
|
820
|
+
let identityBody;
|
|
821
|
+
/** The caller's ID, or login for a login-shaped name (GitHub's owner and
|
|
822
|
+
* assignees), for "me": one identity call per cache. */
|
|
823
|
+
const callerId = async (argument, key) => {
|
|
824
|
+
const field = meIdentityField(key);
|
|
825
|
+
const cacheKey = "me:" + identity.tool + (field === "id" ? "" : ":" + field);
|
|
826
|
+
const cached = options.cache.get(cacheKey);
|
|
827
|
+
if (cached !== undefined)
|
|
828
|
+
return { id: cached };
|
|
829
|
+
if (!identityBody) {
|
|
830
|
+
const outcome = await options.runOperation(identity, {});
|
|
831
|
+
if (outcome.isError)
|
|
832
|
+
return { outcome };
|
|
833
|
+
const body = outcomeValue(outcome);
|
|
834
|
+
identityBody = body && typeof body === "object" && !Array.isArray(body) ? body : {};
|
|
835
|
+
}
|
|
836
|
+
const login = IDENTITY_LOGIN_FIELDS.map((name) => identityBody[name]).find((value) => typeof value === "string" && value !== "");
|
|
837
|
+
const id = field === "login" && login !== undefined ? login : identityBody.id;
|
|
838
|
+
if (typeof id !== "string" && typeof id !== "number") {
|
|
839
|
+
const expected = field === "id" ? "a top-level id" : "a top-level " + IDENTITY_LOGIN_FIELDS.join(", ") + " or id";
|
|
840
|
+
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."]) };
|
|
841
|
+
}
|
|
842
|
+
putReferenceCache(options.cache, cacheKey, id);
|
|
843
|
+
return { id };
|
|
844
|
+
};
|
|
845
|
+
/** "me" inside an object or array argument (a GraphQL input's assigneeId,
|
|
846
|
+
* each of its subscriberIds, each of GitHub's assignees): the same
|
|
847
|
+
* resolution, keyed by property name. */
|
|
848
|
+
const resolveNestedMe = async (value, key, path) => {
|
|
849
|
+
if (typeof value === "string") {
|
|
850
|
+
if (!isMeReference(key, value))
|
|
851
|
+
return { value };
|
|
852
|
+
const caller = await callerId(path, key);
|
|
853
|
+
return "outcome" in caller ? caller : { value: caller.id };
|
|
854
|
+
}
|
|
855
|
+
if (Array.isArray(value)) {
|
|
856
|
+
const items = [];
|
|
857
|
+
for (let i = 0; i < value.length; i++) {
|
|
858
|
+
const item = await resolveNestedMe(value[i], key, path + "[" + i + "]");
|
|
859
|
+
if ("outcome" in item)
|
|
860
|
+
return item;
|
|
861
|
+
items.push(item.value);
|
|
862
|
+
}
|
|
863
|
+
return { value: items };
|
|
864
|
+
}
|
|
865
|
+
if (value && typeof value === "object") {
|
|
866
|
+
const out = {};
|
|
867
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
868
|
+
const resolved = await resolveNestedMe(entry, name, path + "." + name);
|
|
869
|
+
if ("outcome" in resolved)
|
|
870
|
+
return resolved;
|
|
871
|
+
out[name] = resolved.value;
|
|
872
|
+
}
|
|
873
|
+
return { value: out };
|
|
874
|
+
}
|
|
875
|
+
return { value };
|
|
876
|
+
};
|
|
964
877
|
for (const param of op.params) {
|
|
965
878
|
const raw = args[param.name];
|
|
879
|
+
if (identity && raw && typeof raw === "object" && param.resolve !== false) {
|
|
880
|
+
const resolved = await resolveNestedMe(raw, param.name, param.name);
|
|
881
|
+
if ("outcome" in resolved)
|
|
882
|
+
return { ok: false, outcome: resolved.outcome };
|
|
883
|
+
args[param.name] = resolved.value;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
966
886
|
if (typeof raw !== "string" || param.resolve === false)
|
|
967
887
|
continue;
|
|
968
888
|
const value = raw.trim();
|
|
969
|
-
if (
|
|
970
|
-
const
|
|
971
|
-
if (
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
args[param.name] = cached;
|
|
976
|
-
continue;
|
|
977
|
-
}
|
|
978
|
-
const outcome = await options.runOperation(identity, {});
|
|
979
|
-
if (outcome.isError)
|
|
980
|
-
return { ok: false, outcome };
|
|
981
|
-
const body = outcomeValue(outcome);
|
|
982
|
-
const id = body && typeof body === "object" && !Array.isArray(body) ? body.id : undefined;
|
|
983
|
-
if (typeof id !== "string" && typeof id !== "number") {
|
|
984
|
-
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."]) };
|
|
985
|
-
}
|
|
986
|
-
putReferenceCache(options.cache, cacheKey, id);
|
|
987
|
-
args[param.name] = id;
|
|
988
|
-
continue;
|
|
989
|
-
}
|
|
889
|
+
if (identity && isMeReference(param.name, value)) {
|
|
890
|
+
const caller = await callerId(param.name, param.name);
|
|
891
|
+
if ("outcome" in caller)
|
|
892
|
+
return { ok: false, outcome: caller.outcome };
|
|
893
|
+
args[param.name] = caller.id;
|
|
894
|
+
continue;
|
|
990
895
|
}
|
|
991
896
|
const resolver = param.resolve && typeof param.resolve === "object" ? param.resolve : undefined;
|
|
992
897
|
if (!resolver || looksLikeIdentifier(value, resolver))
|
|
@@ -1009,7 +914,7 @@ export async function resolveReferences(op, preparedArgs, options) {
|
|
|
1009
914
|
if (resolver.filterParam)
|
|
1010
915
|
pageArgs[resolver.filterParam] = value;
|
|
1011
916
|
if (!hasOwnFieldsParam(source))
|
|
1012
|
-
pageArgs.fields = [resolver.id, ...resolver.match];
|
|
917
|
+
pageArgs.fields = [resolver.id, ...resolver.match.map((field) => "?" + field)];
|
|
1013
918
|
let exhausted = false;
|
|
1014
919
|
for (let pageNumber = 1; pageNumber <= maxPages; pageNumber++) {
|
|
1015
920
|
const outcome = await options.runOperation(source, pageArgs);
|
|
@@ -1064,38 +969,59 @@ export function argumentsError(op, issues) {
|
|
|
1064
969
|
};
|
|
1065
970
|
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1066
971
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
if (
|
|
1075
|
-
return
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
972
|
+
/** A fields path that selects nothing is an error that names the keys
|
|
973
|
+
* that exist, not a silent {}. A path the response schema declares is not
|
|
974
|
+
* one: an optional key no item has is simply absent. The API call already
|
|
975
|
+
* happened, so the error says so and carries the unprojected result (as
|
|
976
|
+
* much as fits under half the cap), so neither a read nor a write has to
|
|
977
|
+
* run again to see it. */
|
|
978
|
+
function unmatchedFieldsOutcome(value, fields, perItem, options, schema, page) {
|
|
979
|
+
if (fields === null)
|
|
980
|
+
return null;
|
|
981
|
+
const optional = new Set(options.optionalFields ?? []);
|
|
982
|
+
const unmatched = unmatchedFields(value, fields, schema).filter((u) => !optional.has(u.path));
|
|
983
|
+
if (unmatched.length === 0)
|
|
984
|
+
return null;
|
|
985
|
+
const write = options.safety !== "read";
|
|
986
|
+
const maxChars = options.maxChars ?? DEFAULT_MAX_RESULT_CHARS;
|
|
987
|
+
const retained = retainedResult(value, Math.floor(maxChars / 2));
|
|
988
|
+
const steps = [];
|
|
989
|
+
if (write)
|
|
990
|
+
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." : ""));
|
|
991
|
+
else if (retained.whole)
|
|
992
|
+
steps.push("The full result is in result; use it rather than calling again.");
|
|
993
|
+
else if (retained.result !== undefined)
|
|
994
|
+
steps.push("result holds the first " + retained.result.length + " of " + value.length + " items; the rest are over the size cap.");
|
|
995
|
+
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.");
|
|
996
|
+
const structured = {
|
|
997
|
+
error: "UnmatchedFields",
|
|
998
|
+
code: "FIELDS_UNMATCHED",
|
|
999
|
+
message: unmatchedFieldsMessage(unmatched, perItem),
|
|
1000
|
+
unmatched,
|
|
1001
|
+
...(retained.result !== undefined ? { result: retained.result } : {}),
|
|
1002
|
+
...(retained.omitted ? { result_omitted: retained.omitted } : {}),
|
|
1003
|
+
...(page ? { hasMore: page.nextPage !== null, ...(page.nextPage !== null ? { nextPage: page.nextPage } : {}) } : {}),
|
|
1004
|
+
...(options.requestId ? { request_id: options.requestId } : {}),
|
|
1005
|
+
next_steps: steps,
|
|
1006
|
+
};
|
|
1007
|
+
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1008
|
+
}
|
|
1009
|
+
/** The unprojected result an unmatched-fields error carries: whole when it
|
|
1010
|
+
* fits the budget, the leading items of a list when only some do, and
|
|
1011
|
+
* nothing for an object over the budget. */
|
|
1012
|
+
function retainedResult(value, budget) {
|
|
1013
|
+
const text = JSON.stringify(value);
|
|
1014
|
+
if (text === undefined)
|
|
1015
|
+
return { whole: false };
|
|
1016
|
+
if (text.length <= budget)
|
|
1017
|
+
return { result: value, whole: true };
|
|
1018
|
+
if (!Array.isArray(value))
|
|
1019
|
+
return { whole: false };
|
|
1020
|
+
const k = itemsThatFit(value, budget);
|
|
1021
|
+
const head = value.slice(0, k);
|
|
1022
|
+
if (JSON.stringify(head).length > budget)
|
|
1023
|
+
return { whole: false };
|
|
1024
|
+
return { result: head, whole: false, omitted: value.length - k };
|
|
1099
1025
|
}
|
|
1100
1026
|
const fieldsHint = (perItem) => "Pass fields (dotted paths" + (perItem ? ", applied per item" : "") + ") to keep only the keys you need.";
|
|
1101
1027
|
/** How many leading items fit under `budget` characters once serialized
|
|
@@ -1118,8 +1044,22 @@ function itemsThatFit(items, budget) {
|
|
|
1118
1044
|
* last-id styles resume at the cut, cursor and page styles say what the
|
|
1119
1045
|
* caller must do instead. */
|
|
1120
1046
|
export function pageOutcome(items, nextPage, options = {}) {
|
|
1047
|
+
if (nextPage !== null && options.carryArgs) {
|
|
1048
|
+
const args = options.args ?? {};
|
|
1049
|
+
const carried = options.carryArgs.filter((name) => args[name] !== undefined && !(name in nextPage));
|
|
1050
|
+
nextPage = {
|
|
1051
|
+
...Object.fromEntries(carried.map((name) => [name, args[name]])),
|
|
1052
|
+
// The same projection on the next page.
|
|
1053
|
+
...(options.fields && !("fields" in nextPage) ? { fields: options.fields.map((path) => path.join(".")) } : {}),
|
|
1054
|
+
...nextPage,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1121
1057
|
const fields = options.fields ?? null;
|
|
1122
1058
|
const maxChars = options.maxChars ?? DEFAULT_MAX_RESULT_CHARS;
|
|
1059
|
+
const pageSchema = options.outputSchema?.properties;
|
|
1060
|
+
const unmatched = unmatchedFieldsOutcome(items, fields, true, options, pageSchema?.items, { nextPage });
|
|
1061
|
+
if (unmatched)
|
|
1062
|
+
return unmatched;
|
|
1123
1063
|
const shown = projectFields(items, fields);
|
|
1124
1064
|
const full = {
|
|
1125
1065
|
items: shown,
|
|
@@ -1177,7 +1117,10 @@ const omittedMarker = (key, chars, maxChars) => chars > maxChars
|
|
|
1177
1117
|
export function dataOutcome(data, options = {}) {
|
|
1178
1118
|
const fields = options.fields ?? null;
|
|
1179
1119
|
const maxChars = options.maxChars ?? DEFAULT_MAX_RESULT_CHARS;
|
|
1180
|
-
const
|
|
1120
|
+
const unmatched = data !== undefined && data !== null ? unmatchedFieldsOutcome(data, fields, Array.isArray(data), options, options.outputSchema) : null;
|
|
1121
|
+
if (unmatched)
|
|
1122
|
+
return unmatched;
|
|
1123
|
+
const value = data !== undefined && data !== null ? projectFields(data, fields) : { ok: true };
|
|
1181
1124
|
if (typeof value === "string") {
|
|
1182
1125
|
if (value.length <= maxChars)
|
|
1183
1126
|
return { text: value, isError: false, structured: value };
|
|
@@ -1286,13 +1229,48 @@ export async function binaryOutcome(blob, options = {}) {
|
|
|
1286
1229
|
isError: false,
|
|
1287
1230
|
};
|
|
1288
1231
|
}
|
|
1289
|
-
function
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1232
|
+
function rateLimitNextStep(retryAt) {
|
|
1233
|
+
if (retryAt instanceof Date && !Number.isNaN(retryAt.getTime())) {
|
|
1234
|
+
const at = new Date(Math.ceil(retryAt.getTime() / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
1235
|
+
return "Rate limited: wait until " + at + ", then call again. This is not a credential problem.";
|
|
1236
|
+
}
|
|
1237
|
+
return "Rate limited: back off, then call again once; the request already honored any Retry-After within its ceiling.";
|
|
1238
|
+
}
|
|
1239
|
+
/** Error codes APIs report inside a 2xx body (Slack's `error`), mapped to
|
|
1240
|
+
* the stable codes when their meaning is unambiguous. */
|
|
1241
|
+
function payloadFailureCode(code) {
|
|
1242
|
+
if (typeof code !== "string")
|
|
1243
|
+
return undefined;
|
|
1244
|
+
if (/^(not_authed|invalid_auth|token_revoked|token_expired|account_inactive|unauthorized|unauthenticated|forbidden|access_denied|missing_scope)$/i.test(code))
|
|
1245
|
+
return "AUTH_INVALID";
|
|
1246
|
+
if (/^(ratelimited|rate_limited|rate_limit_exceeded|too_many_requests)$/i.test(code))
|
|
1247
|
+
return "RATE_LIMITED";
|
|
1248
|
+
return undefined;
|
|
1249
|
+
}
|
|
1250
|
+
/** The vendor's own code on an in-band GraphQL error: the first error's
|
|
1251
|
+
* extensions.code, or its type (GitHub). */
|
|
1252
|
+
function graphqlVendorCode(error) {
|
|
1253
|
+
const first = error?.errors?.[0];
|
|
1254
|
+
const code = first?.extensions?.code ?? first?.type;
|
|
1255
|
+
return typeof code === "string" && code !== "" ? code : undefined;
|
|
1256
|
+
}
|
|
1257
|
+
/** GraphQL error codes whose meaning is settled (Apollo's standard codes,
|
|
1258
|
+
* GitHub's types, Linear's codes), by recovery class. Any other code is
|
|
1259
|
+
* passed through as is, with no guessed next step. The CLI's
|
|
1260
|
+
* classification (cli-agent.ts) uses the same table. */
|
|
1261
|
+
function graphqlErrorClass(code) {
|
|
1262
|
+
const c = code.toUpperCase();
|
|
1263
|
+
if (c === "NOT_FOUND")
|
|
1264
|
+
return "not_found";
|
|
1265
|
+
if (c === "UNAUTHENTICATED" || c === "AUTHENTICATION_ERROR")
|
|
1266
|
+
return "unauthenticated";
|
|
1267
|
+
if (c === "FORBIDDEN")
|
|
1268
|
+
return "forbidden";
|
|
1269
|
+
if (c === "BAD_USER_INPUT" || c === "GRAPHQL_VALIDATION_FAILED" || c === "GRAPHQL_PARSE_FAILED" || c === "INPUT_ERROR")
|
|
1270
|
+
return "bad_input";
|
|
1271
|
+
if (c === "RATE_LIMITED" || c === "RATELIMITED")
|
|
1272
|
+
return "rate_limited";
|
|
1273
|
+
return undefined;
|
|
1296
1274
|
}
|
|
1297
1275
|
/** Classify an SDK error result by status: the code and what to do next. */
|
|
1298
1276
|
export function classifyError(error, context = {}) {
|
|
@@ -1300,27 +1278,52 @@ export function classifyError(error, context = {}) {
|
|
|
1300
1278
|
const message = e.message ?? String(error);
|
|
1301
1279
|
if (e.violations !== undefined)
|
|
1302
1280
|
return { code: "VALIDATION_FAILED", nextSteps: ["Fix the fields named in violations and call again."] };
|
|
1303
|
-
if (e.name === "TransportError" || (typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(message))) {
|
|
1281
|
+
if (e.name === "TransportError" || (e.name !== "PaginationError" && typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(message))) {
|
|
1304
1282
|
return { code: "NETWORK_ERROR", nextSteps: ["The API could not be reached (network, DNS, TLS or timeout). Retry once with backoff; do not loop."] };
|
|
1305
1283
|
}
|
|
1306
1284
|
const status = typeof e.status === "number" ? e.status : 0;
|
|
1307
1285
|
const body = e.body;
|
|
1308
1286
|
const auth = context.authHint ? context.authHint.trim().replace(/[.]?$/, ".") : null;
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1287
|
+
// A rate limit can arrive as a 403 (GitHub); the SDK marks it either way.
|
|
1288
|
+
if (status === 429 || e.rateLimit !== undefined)
|
|
1289
|
+
return { code: "RATE_LIMITED", nextSteps: [rateLimitNextStep(e.rateLimit?.retryAt)] };
|
|
1290
|
+
const scopes = context.requiredScopes ?? [];
|
|
1291
|
+
const scopeFailure = { code: "INSUFFICIENT_SCOPE", 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."] };
|
|
1292
|
+
// A failure the API reported inside a 2xx body.
|
|
1293
|
+
if (e.name === "PayloadError") {
|
|
1294
|
+
if (scopes.length && /^missing_scope$/i.test(String(e.code)))
|
|
1295
|
+
return scopeFailure;
|
|
1296
|
+
const reported = payloadFailureCode(e.code);
|
|
1297
|
+
if (reported === "AUTH_INVALID")
|
|
1298
|
+
return { code: "AUTH_INVALID", nextSteps: ["The API rejected the credential (" + String(e.code) + ")." + (auth ? " " + auth : "")] };
|
|
1299
|
+
if (reported === "RATE_LIMITED")
|
|
1300
|
+
return { code: "RATE_LIMITED", nextSteps: [rateLimitNextStep(undefined)] };
|
|
1301
|
+
return { code: "CALL_FAILED", nextSteps: ["The API reported a failure in a successful response; body says why. Do not treat the call as done."] };
|
|
1302
|
+
}
|
|
1303
|
+
const unauthenticated = () => context.hadCredential
|
|
1304
|
+
? { code: "AUTH_INVALID", nextSteps: ["The credential was rejected; it may be expired or for another environment." + (auth ? " " + auth : "")] }
|
|
1305
|
+
: { code: "NO_AUTH", nextSteps: ["No credential was sent." + (auth ? " " + auth : "")] };
|
|
1306
|
+
const forbidden = () => scopes.length ? scopeFailure : { code: "AUTH_INVALID", nextSteps: ["The credential lacks access to this operation; it is not a retryable error."] };
|
|
1307
|
+
// An in-band GraphQL error (HTTP 200): classified by the vendor's code.
|
|
1308
|
+
if (e.name === "GraphQLRequestError") {
|
|
1309
|
+
const vendor = graphqlVendorCode(error);
|
|
1310
|
+
switch (vendor === undefined ? undefined : graphqlErrorClass(vendor)) {
|
|
1311
|
+
case "not_found": return { code: "NOT_FOUND", nextSteps: ["Check the id in the arguments; list the resource first to find the right one."] };
|
|
1312
|
+
case "unauthenticated": return unauthenticated();
|
|
1313
|
+
case "forbidden": return forbidden();
|
|
1314
|
+
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."] };
|
|
1315
|
+
case "rate_limited": return { code: "RATE_LIMITED", nextSteps: [rateLimitNextStep(undefined)] };
|
|
1316
|
+
default: return { code: "CALL_FAILED", nextSteps: [] };
|
|
1317
|
+
}
|
|
1313
1318
|
}
|
|
1319
|
+
if (status === 401)
|
|
1320
|
+
return unauthenticated();
|
|
1314
1321
|
if (status === 403)
|
|
1315
|
-
return
|
|
1322
|
+
return forbidden();
|
|
1316
1323
|
if (status === 402)
|
|
1317
1324
|
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."] };
|
|
1318
1325
|
if (status === 404)
|
|
1319
1326
|
return { code: "NOT_FOUND", nextSteps: notFoundNextSteps(message, e.body) };
|
|
1320
|
-
if (status === 429) {
|
|
1321
|
-
const retryAfter = extractRetryAfter(e);
|
|
1322
|
-
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."] };
|
|
1323
|
-
}
|
|
1324
1327
|
if (status === 422 && body?.errors?.[0]?.code === "spec_error") {
|
|
1325
1328
|
return {
|
|
1326
1329
|
code: "SPEC_INVALID",
|
|
@@ -1355,23 +1358,52 @@ function notFoundNextSteps(message, body) {
|
|
|
1355
1358
|
* message, status, the API's body, where to read more, and what to do. */
|
|
1356
1359
|
export function errorOutcome(error, context = {}) {
|
|
1357
1360
|
const e = error;
|
|
1361
|
+
// A GraphQL response with data and errors is a partial success: the agent
|
|
1362
|
+
// gets the data it can use and the errors that explain what is missing.
|
|
1363
|
+
const partial = error;
|
|
1364
|
+
if (partial?.name === "GraphQLRequestError" && partial.data !== undefined && partial.data !== null) {
|
|
1365
|
+
const structured = { data: partial.data, errors: partial.errors ?? [], partial: true };
|
|
1366
|
+
return { text: JSON.stringify(structured), isError: false, structured };
|
|
1367
|
+
}
|
|
1368
|
+
// The SDK raises NotModifiedError for a 304: a conditional request
|
|
1369
|
+
// matched. That is a result, not a failure; the CLI prints the same shape.
|
|
1370
|
+
const notModified = error;
|
|
1371
|
+
if (notModified?.name === "NotModifiedError") {
|
|
1372
|
+
const structured = { ok: true, not_modified: true, ...(notModified.etag ? { etag: notModified.etag } : {}) };
|
|
1373
|
+
return { text: JSON.stringify(structured), isError: false, structured };
|
|
1374
|
+
}
|
|
1358
1375
|
const { code, nextSteps } = classifyError(error, context);
|
|
1376
|
+
const retryAt = e?.rateLimit?.retryAt instanceof Date ? new Date(Math.ceil(e.rateLimit.retryAt.getTime() / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z") : undefined;
|
|
1359
1377
|
const bodyRequestId = e?.body && typeof e.body === "object" && !Array.isArray(e.body)
|
|
1360
1378
|
? e.body.request_id ?? e.body.requestId
|
|
1361
1379
|
: undefined;
|
|
1362
1380
|
const requestId = e?.response?.requestId ?? (typeof bodyRequestId === "string" ? bodyRequestId : undefined);
|
|
1381
|
+
// The vendor's identity for the failure, next to the normalized code.
|
|
1382
|
+
const vendorCode = e?.name === "GraphQLRequestError" ? graphqlVendorCode(error) : undefined;
|
|
1363
1383
|
const structured = {
|
|
1364
1384
|
error: e?.name ?? "Error",
|
|
1365
1385
|
code,
|
|
1386
|
+
...(vendorCode ? { vendor_code: vendorCode } : {}),
|
|
1366
1387
|
message: e?.message,
|
|
1367
1388
|
...(typeof e?.status === "number" ? { status: e.status } : {}),
|
|
1368
1389
|
...(requestId ? { request_id: requestId } : {}),
|
|
1390
|
+
...(retryAt ? { retry_at: retryAt } : {}),
|
|
1369
1391
|
...(e?.body !== undefined ? { body: e.body } : {}),
|
|
1370
1392
|
...(context.docsUrl ? { docs_url: context.docsUrl } : {}),
|
|
1371
1393
|
next_steps: nextSteps,
|
|
1372
1394
|
};
|
|
1373
1395
|
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1374
1396
|
}
|
|
1397
|
+
/** A required operation whose schemes this server cannot send fails before
|
|
1398
|
+
* any request, instead of calling the API without credentials. */
|
|
1399
|
+
export function unsupportedAuthOutcome(op) {
|
|
1400
|
+
if (op.auth !== "required" || !op.credentialOptions || op.credentialOptions.some((alternative) => alternative.length > 0))
|
|
1401
|
+
return null;
|
|
1402
|
+
const schemes = [...new Set((op.security ?? []).flatMap((requirement) => Object.keys(requirement)))];
|
|
1403
|
+
return textError(`${op.tool} requires ${schemes.length ? schemes.join(" or ") : "an authentication scheme"} authentication, which this MCP server cannot send.`, "NO_AUTH", [
|
|
1404
|
+
"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.",
|
|
1405
|
+
]);
|
|
1406
|
+
}
|
|
1375
1407
|
export function textError(text, code = "CALL_FAILED", nextSteps = []) {
|
|
1376
1408
|
const structured = { error: "Error", code, message: text, next_steps: nextSteps };
|
|
1377
1409
|
return { text: JSON.stringify(structured), isError: true, structured };
|
|
@@ -1380,114 +1412,298 @@ async function fetchDocs(source, pathOrFile) {
|
|
|
1380
1412
|
const url = resolveDocsContentUrl(source.docsUrl(), source.docsIndexUrl?.() ?? null, pathOrFile);
|
|
1381
1413
|
return url === null ? null : source.fetchText(url);
|
|
1382
1414
|
}
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1415
|
+
/** "GraphQL mutation issueCreate" or "POST /v1/issues". */
|
|
1416
|
+
export function operationLabel(op) {
|
|
1417
|
+
return op.graphql?.field ? "GraphQL " + op.graphql.kind + " " + op.graphql.field : op.httpMethod + " " + op.path;
|
|
1418
|
+
}
|
|
1419
|
+
function omittedEntry(op) {
|
|
1420
|
+
return op.graphql?.field
|
|
1421
|
+
? { tool: op.tool, graphql: op.graphql.kind + " " + op.graphql.field }
|
|
1422
|
+
: { tool: op.tool, method: op.httpMethod, path: op.path };
|
|
1389
1423
|
}
|
|
1390
|
-
function omittedPlanLimit(ops, requested) {
|
|
1424
|
+
function omittedPlanLimit(source, ops, requested) {
|
|
1425
|
+
const generated = source.generatedOperationCount ?? source.ops.length;
|
|
1426
|
+
const total = generated + (source.omittedOps?.length ?? 0);
|
|
1391
1427
|
const structured = {
|
|
1392
1428
|
error: "PlanLimitError",
|
|
1393
1429
|
code: "PLAN_LIMIT",
|
|
1394
|
-
message: requested
|
|
1395
|
-
? "The operation " + requested + "
|
|
1396
|
-
: "Matching operations
|
|
1397
|
-
omitted_operations: ops.
|
|
1398
|
-
next_steps: ["
|
|
1430
|
+
message: (requested
|
|
1431
|
+
? "The operation " + requested + " is in the API but not in this package"
|
|
1432
|
+
: "Matching operations are in the API but not in this package") + ", which was generated with " + generated + " of its " + total + " operations.",
|
|
1433
|
+
omitted_operations: ops.slice(0, SEARCH_PAGE_SIZE).map(omittedEntry),
|
|
1434
|
+
next_steps: ["The package's publisher can regenerate it with every operation.", "Do not invent or retry an omitted operation against this package."],
|
|
1399
1435
|
};
|
|
1400
1436
|
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1401
1437
|
}
|
|
1402
|
-
|
|
1438
|
+
/** Nesting read_docs spells out before pointing at schema: true. */
|
|
1439
|
+
const REFERENCE_DEPTH = 3;
|
|
1440
|
+
/** Characters one top-level argument's nested fields may take; a deep
|
|
1441
|
+
* filter object is shown shallower until it fits. */
|
|
1442
|
+
const REFERENCE_ARGUMENT_BUDGET = 6_000;
|
|
1443
|
+
/** Enum values listed inline; longer enums are cut with a count. */
|
|
1444
|
+
const REFERENCE_ENUM_VALUES = 30;
|
|
1445
|
+
/** HTML that API descriptions carry for formatting only. Anything else
|
|
1446
|
+
* in angle brackets (a <placeholder>) is text and stays. */
|
|
1447
|
+
const FORMATTING_TAGS = /<\/?(?:a|abbr|b|br|code|div|em|i|li|ol|p|pre|small|span|strong|sub|sup|u|ul)(?:\s[^<>]*)?\/?>/gi;
|
|
1448
|
+
/** A Markdown link or image: [text](url "title"), where the URL may hold
|
|
1449
|
+
* one level of parentheses (Wikipedia's Foo_(bar)). */
|
|
1450
|
+
const MARKDOWN_LINK = /!?\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\]\((?:[^()\s]|\([^()\s]*\))*(?:\s+"[^"]*")?\)/g;
|
|
1451
|
+
/** Prose for one description, as one line: Markdown and HTML links reduced
|
|
1452
|
+
* to their visible text, formatting tags and emphasis dropped, <code> as
|
|
1453
|
+
* inline code (kept, because agents copy it). */
|
|
1454
|
+
export function referenceProse(text) {
|
|
1455
|
+
if (typeof text !== "string")
|
|
1456
|
+
return "";
|
|
1457
|
+
return text
|
|
1458
|
+
.replace(/<code>([\s\S]*?)<\/code>/gi, "`$1`")
|
|
1459
|
+
.replace(/<br\s*\/?>/gi, " ")
|
|
1460
|
+
.replace(FORMATTING_TAGS, "")
|
|
1461
|
+
.replace(MARKDOWN_LINK, "$1")
|
|
1462
|
+
.replace(/\[([^\[\]]+)\]\[[^\[\]]*\]/g, "$1")
|
|
1463
|
+
.replace(/(\*\*|__)(?=\S)([^*_]*?\S)\1/g, "$2")
|
|
1464
|
+
.replace(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&")
|
|
1465
|
+
.replace(/\s+/g, " ")
|
|
1466
|
+
.trim();
|
|
1467
|
+
}
|
|
1468
|
+
/** Abbreviations whose period does not end a sentence. */
|
|
1469
|
+
const ABBREVIATION = /(?:^|[\s(])(?:e\.g|i\.e|etc|vs|approx|incl|cf|no|min|max)\.$/i;
|
|
1470
|
+
/** A description's sentences, every character kept: a sentence ends at
|
|
1471
|
+
* . ! or ? (closing quotes and brackets included) before whitespace, not
|
|
1472
|
+
* after an abbreviation, and never inside inline code. */
|
|
1473
|
+
function sentencesOf(prose) {
|
|
1474
|
+
const sentences = [];
|
|
1475
|
+
let current = "";
|
|
1476
|
+
for (const part of prose.split(/(?<=[.!?]["')\]]?)\s+/)) {
|
|
1477
|
+
current = current ? current + " " + part : part;
|
|
1478
|
+
const openCode = (current.match(/`/g) ?? []).length % 2 === 1;
|
|
1479
|
+
if (!openCode && !ABBREVIATION.test(current)) {
|
|
1480
|
+
sentences.push(current);
|
|
1481
|
+
current = "";
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
if (current)
|
|
1485
|
+
sentences.push(current);
|
|
1486
|
+
return sentences;
|
|
1487
|
+
}
|
|
1488
|
+
/** Cut one long sentence at a word boundary, never inside inline code. */
|
|
1489
|
+
function cutSentence(sentence, limit) {
|
|
1490
|
+
let cut = sentence.slice(0, limit).replace(/\s+\S*$/, "");
|
|
1491
|
+
if ((cut.match(/`/g) ?? []).length % 2 === 1)
|
|
1492
|
+
cut = cut.slice(0, cut.lastIndexOf("`")).trimEnd();
|
|
1493
|
+
return cut.replace(/[\s,;:(]+$/, "") + "…";
|
|
1494
|
+
}
|
|
1495
|
+
/** Sentences the generator appends to an argument's description because a
|
|
1496
|
+
* call depends on them: enum meanings, defaults, deprecation, reference
|
|
1497
|
+
* and date forms, file paths. They survive the cut. */
|
|
1498
|
+
const REFERENCE_NOTE = /^(Values:|Default|Deprecated|Accepts|Markdown|Paths? of (a )?local file|Must|Required|Only one of)/;
|
|
1499
|
+
/** Leading prose kept per argument before its notes. */
|
|
1500
|
+
const REFERENCE_ARGUMENT_PROSE = 160;
|
|
1501
|
+
/** An argument's description, cut to its leading sentences within the
|
|
1502
|
+
* budget plus every note the generator added. The full text is in the
|
|
1503
|
+
* schema (schema: true). */
|
|
1504
|
+
export function argumentProse(text) {
|
|
1505
|
+
const prose = referenceProse(text);
|
|
1506
|
+
if (prose.length <= REFERENCE_ARGUMENT_PROSE)
|
|
1507
|
+
return prose;
|
|
1508
|
+
const kept = [];
|
|
1509
|
+
let used = 0;
|
|
1510
|
+
let cut = false;
|
|
1511
|
+
for (const sentence of sentencesOf(prose)) {
|
|
1512
|
+
if (REFERENCE_NOTE.test(sentence)) {
|
|
1513
|
+
kept.push(sentence);
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
if (kept.length === 0 || used + sentence.length <= REFERENCE_ARGUMENT_PROSE) {
|
|
1517
|
+
kept.push(sentence.length > REFERENCE_ARGUMENT_PROSE * 2 ? cutSentence(sentence, REFERENCE_ARGUMENT_PROSE * 2) : sentence);
|
|
1518
|
+
used += sentence.length;
|
|
1519
|
+
}
|
|
1520
|
+
else {
|
|
1521
|
+
cut = true;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return kept.join(" ") + (cut ? " …" : "");
|
|
1525
|
+
}
|
|
1526
|
+
/** A schema's type as an agent writes it: string, integer[], "a"|"b", object. */
|
|
1527
|
+
function referenceType(schema) {
|
|
1528
|
+
if (Array.isArray(schema.enum)) {
|
|
1529
|
+
const values = schema.enum.slice(0, REFERENCE_ENUM_VALUES).map((v) => JSON.stringify(v));
|
|
1530
|
+
return values.join("|") + (schema.enum.length > REFERENCE_ENUM_VALUES ? "|… (" + (schema.enum.length - REFERENCE_ENUM_VALUES) + " more)" : "");
|
|
1531
|
+
}
|
|
1532
|
+
if (schema.const !== undefined)
|
|
1533
|
+
return JSON.stringify(schema.const);
|
|
1534
|
+
const variants = (schema.anyOf ?? schema.oneOf);
|
|
1535
|
+
if (Array.isArray(variants))
|
|
1536
|
+
return [...new Set(variants.map((v) => referenceType(v)))].join("|");
|
|
1537
|
+
const types = Array.isArray(schema.type) ? schema.type : typeof schema.type === "string" ? [schema.type] : [];
|
|
1538
|
+
const one = (t) => {
|
|
1539
|
+
if (t === "array") {
|
|
1540
|
+
const items = schema.items && typeof schema.items === "object" ? referenceType(schema.items) : "any";
|
|
1541
|
+
return (/[| ]/.test(items) ? "(" + items + ")" : items) + "[]";
|
|
1542
|
+
}
|
|
1543
|
+
return t + (t === "string" && typeof schema.format === "string" ? " (" + schema.format + ")" : "");
|
|
1544
|
+
};
|
|
1545
|
+
if (types.length === 0)
|
|
1546
|
+
return schema.properties ? "object" : "any";
|
|
1547
|
+
return types.map(one).join("|");
|
|
1548
|
+
}
|
|
1549
|
+
/** The object whose fields an argument lists: itself, its array's items,
|
|
1550
|
+
* or the one object of a nullable union. */
|
|
1551
|
+
function referenceObject(schema) {
|
|
1552
|
+
if (schema.properties && typeof schema.properties === "object")
|
|
1553
|
+
return schema;
|
|
1554
|
+
const items = schema.items;
|
|
1555
|
+
if (items && typeof items === "object")
|
|
1556
|
+
return referenceObject(items);
|
|
1557
|
+
const variants = (schema.anyOf ?? schema.oneOf);
|
|
1558
|
+
if (Array.isArray(variants)) {
|
|
1559
|
+
const objects = variants.map(referenceObject).filter((v) => v !== undefined);
|
|
1560
|
+
if (objects.length === 1)
|
|
1561
|
+
return objects[0];
|
|
1562
|
+
}
|
|
1563
|
+
return undefined;
|
|
1564
|
+
}
|
|
1565
|
+
/** One line per argument, nested fields indented beneath their object.
|
|
1566
|
+
* Each top-level argument is spelled out as deep as fits its budget. */
|
|
1567
|
+
function referenceArguments(schema, lines, context) {
|
|
1568
|
+
const properties = (schema.properties ?? {});
|
|
1569
|
+
for (const name of Object.keys(properties)) {
|
|
1570
|
+
const only = { ...schema, properties: { [name]: properties[name] } };
|
|
1571
|
+
let block = [];
|
|
1572
|
+
// Deepest first; then the same depth without listing the field names
|
|
1573
|
+
// below the cut; then one level with names only.
|
|
1574
|
+
for (const [depth, names] of [[REFERENCE_DEPTH, true], [2, true], [2, false], [1, true]]) {
|
|
1575
|
+
block = [];
|
|
1576
|
+
referenceArgumentLines(only, 0, depth, names, " ", block, context);
|
|
1577
|
+
if (block.join("\n").length <= REFERENCE_ARGUMENT_BUDGET)
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
lines.push(...block);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
function referenceArgumentLines(schema, depth, maxDepth, names, indent, lines, context) {
|
|
1584
|
+
const properties = (schema.properties ?? {});
|
|
1585
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
1586
|
+
for (const [name, child] of Object.entries(properties)) {
|
|
1587
|
+
if (!child || typeof child !== "object")
|
|
1588
|
+
continue;
|
|
1589
|
+
const description = argumentProse(child.description);
|
|
1590
|
+
const extras = [
|
|
1591
|
+
...(child.default !== undefined && !/\bdefault\b/i.test(description) ? ["default " + JSON.stringify(child.default)] : []),
|
|
1592
|
+
...(child.deprecated === true && !/deprecated/i.test(description) ? ["deprecated"] : []),
|
|
1593
|
+
];
|
|
1594
|
+
const nested = referenceObject(child);
|
|
1595
|
+
// An object argument reads as its named type (IssueFilter), which
|
|
1596
|
+
// read_docs can look up; everything else keeps its inline type.
|
|
1597
|
+
const expression = context.path.length === 0
|
|
1598
|
+
? context.types?.args[context.tool]?.[name]
|
|
1599
|
+
: context.typeName ? context.types?.types[context.typeName]?.fields?.[name]?.type : undefined;
|
|
1600
|
+
const named = nested ? namedTypesIn(context.types, expression) : [];
|
|
1601
|
+
const type = named.length > 0 ? expression : referenceType(child);
|
|
1602
|
+
lines.push(indent + name + " (" + type + (required.has(name) ? ", required" : "") + (extras.length ? ", " + extras.join(", ") : "") + ")" + (description ? ": " + description : ""));
|
|
1603
|
+
if (!nested)
|
|
1604
|
+
continue;
|
|
1605
|
+
const path = [...context.path, name];
|
|
1606
|
+
const objects = named.filter((typeName) => context.types.types[typeName].fields);
|
|
1607
|
+
const inner = { ...context, path, typeName: objects.length === 1 ? objects[0] : undefined };
|
|
1608
|
+
if (depth + 1 < maxDepth) {
|
|
1609
|
+
referenceArgumentLines(nested, depth + 1, maxDepth, names, indent + " ", lines, inner);
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
if (!names)
|
|
1613
|
+
continue;
|
|
1614
|
+
const keys = Object.keys(nested.properties);
|
|
1615
|
+
// Field types (and any cut names) are one path lookup away.
|
|
1616
|
+
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" : ""));
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
/** A result's shape in one line: keys and types, one level into objects. */
|
|
1620
|
+
function referenceShape(schema, depth = 0) {
|
|
1621
|
+
if (!schema || typeof schema !== "object")
|
|
1622
|
+
return "any";
|
|
1623
|
+
const object = schema.properties && typeof schema.properties === "object" ? schema : undefined;
|
|
1624
|
+
if (object) {
|
|
1625
|
+
if (depth >= 2)
|
|
1626
|
+
return "{…}";
|
|
1627
|
+
const entries = Object.entries(object.properties);
|
|
1628
|
+
const shown = entries.slice(0, 40).map(([key, child]) => key + ": " + referenceShape(child, depth + 1));
|
|
1629
|
+
return "{" + shown.join(", ") + (entries.length > 40 ? ", … " + (entries.length - 40) + " more" : "") + "}";
|
|
1630
|
+
}
|
|
1631
|
+
const items = schema.items;
|
|
1632
|
+
if (items && typeof items === "object" && (items.properties || items.items)) {
|
|
1633
|
+
return "[" + referenceShape(items, depth) + "]";
|
|
1634
|
+
}
|
|
1635
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 8) {
|
|
1636
|
+
return schema.enum.slice(0, 8).map((v) => JSON.stringify(v)).join("|") + "|…";
|
|
1637
|
+
}
|
|
1638
|
+
// Nullability and formats matter for sending, not for reading a result.
|
|
1639
|
+
const type = referenceType({ ...schema, format: undefined });
|
|
1640
|
+
return type.split("|").filter((t) => t !== "null").join("|") || type;
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* An operation's reference as read_docs returns it: what it does, whether
|
|
1644
|
+
* it is safe, every argument in prose (types, enums, required, notes, and
|
|
1645
|
+
* nested fields), one example and the result's shape. The complete JSON
|
|
1646
|
+
* Schemas come with `schema: true`, as the CLI's `docs --schema` does; they
|
|
1647
|
+
* cost several times the rest and an agent rarely needs them to call.
|
|
1648
|
+
*/
|
|
1649
|
+
export function referenceText(op, options = {}) {
|
|
1403
1650
|
const safety = operationSafety(op);
|
|
1404
|
-
|
|
1651
|
+
// A credential-defaulted argument (Twilio's AccountSid) is left out, so the
|
|
1652
|
+
// example shows the call an agent should make.
|
|
1653
|
+
const example = Object.fromEntries(Object.entries(op.exampleArguments ?? {})
|
|
1654
|
+
.filter(([name]) => !op.params.some((p) => p.name === name && p.credential)));
|
|
1405
1655
|
const lines = [
|
|
1406
|
-
op.tool + ": " + op
|
|
1656
|
+
op.tool + ": " + operationLabel(op) + (op.paginated ? " (paginated)" : ""),
|
|
1407
1657
|
...(op.summary ? [op.summary] : []),
|
|
1408
|
-
...(op.description ? ["", op.description.trim()] : []),
|
|
1658
|
+
...(op.description && op.description.trim() !== op.summary?.trim() ? ["", op.description.trim()] : []),
|
|
1409
1659
|
"",
|
|
1410
1660
|
"Safety: " + safety + (safety === "destructive" ? " (execute requires confirm: true)" : ""),
|
|
1411
|
-
...(op.auth ? ["Authentication: " + op.auth] : []),
|
|
1661
|
+
...(op.auth ? ["Authentication: " + (op.authNotDeclared ? "not declared by the API Spec" : op.auth)] : []),
|
|
1662
|
+
...(requiredScopes(op.security).length ? ["Required OAuth scopes: " + requiredScopes(op.security).join(", ")] : []),
|
|
1412
1663
|
];
|
|
1413
|
-
|
|
1664
|
+
const input = toolInputSchema(op);
|
|
1665
|
+
if (Object.keys((input.properties ?? {})).length > 0) {
|
|
1414
1666
|
lines.push("", "Arguments:");
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1667
|
+
referenceArguments(input, lines, { tool: op.tool, types: options.types, path: [] });
|
|
1668
|
+
}
|
|
1669
|
+
lines.push("", "Example arguments: " + JSON.stringify(example));
|
|
1670
|
+
if (op.outputSchema) {
|
|
1671
|
+
lines.push("", (op.paginated ? "Returns one page: " : "Returns: ") + referenceShape(op.outputSchema));
|
|
1419
1672
|
}
|
|
1420
|
-
if (
|
|
1421
|
-
lines.push("", "
|
|
1673
|
+
if (options.schema) {
|
|
1674
|
+
lines.push("", "Input schema: " + JSON.stringify(input));
|
|
1675
|
+
if (op.outputSchema)
|
|
1676
|
+
lines.push("", "Output schema: " + JSON.stringify(op.outputSchema));
|
|
1677
|
+
}
|
|
1678
|
+
else {
|
|
1679
|
+
// Say how to drill into an object argument: the page above stops at a
|
|
1680
|
+
// depth and a size, and the schema is the whole graph at once.
|
|
1681
|
+
const nested = Object.entries((input.properties ?? {}))
|
|
1682
|
+
.find(([, child]) => child && typeof child === "object" && referenceObject(child))?.[0];
|
|
1683
|
+
if (nested) {
|
|
1684
|
+
const named = namedTypesIn(options.types, options.types?.args[op.tool]?.[nested])[0];
|
|
1685
|
+
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."
|
|
1686
|
+
+ (named ? " Named types read the same way: read_docs " + JSON.stringify({ page: named }) + "." : ""));
|
|
1687
|
+
}
|
|
1688
|
+
lines.push("", "Full input and output JSON Schemas: read_docs " + JSON.stringify({ page: op.tool, schema: true }) + ".");
|
|
1422
1689
|
}
|
|
1423
|
-
lines.push("", "Input schema:", "```json", JSON.stringify(toolInputSchema(op), null, 2), "```");
|
|
1424
|
-
lines.push("", "Example arguments:", "```json", JSON.stringify(example, null, 2), "```");
|
|
1425
|
-
if (op.outputSchema)
|
|
1426
|
-
lines.push("", "Output schema:", "```json", JSON.stringify(op.outputSchema, null, 2), "```");
|
|
1427
1690
|
return lines.join("\n");
|
|
1428
1691
|
}
|
|
1429
|
-
/** Query terms: lowercase words of two or more characters, with the
|
|
1430
|
-
* snake/kebab/camel seams split so "createAccount" finds accounts_create. */
|
|
1431
|
-
function searchTerms(query) {
|
|
1432
|
-
return [...new Set(query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 2))];
|
|
1433
|
-
}
|
|
1434
|
-
/** Relevance of one operation to the terms: the tool name counts most,
|
|
1435
|
-
* then summary, path and argument names, then the description. The whole
|
|
1436
|
-
* query as a phrase in the name or summary is a strong signal. */
|
|
1437
|
-
export function searchScore(op, query) {
|
|
1438
|
-
const terms = searchTerms(query);
|
|
1439
|
-
if (terms.length === 0)
|
|
1440
|
-
return 0;
|
|
1441
|
-
const tool = op.tool.toLowerCase();
|
|
1442
|
-
const toolWords = tool.split("_");
|
|
1443
|
-
const summary = (op.summary ?? "").toLowerCase();
|
|
1444
|
-
const path = op.path.toLowerCase();
|
|
1445
|
-
const params = op.params.map((p) => p.name.toLowerCase());
|
|
1446
|
-
const description = (op.description ?? "").toLowerCase();
|
|
1447
|
-
let score = 0;
|
|
1448
|
-
for (const term of terms) {
|
|
1449
|
-
if (toolWords.includes(term))
|
|
1450
|
-
score += 10;
|
|
1451
|
-
else if (tool.includes(term))
|
|
1452
|
-
score += 6;
|
|
1453
|
-
if (summary.split(/[^a-z0-9]+/).includes(term))
|
|
1454
|
-
score += 5;
|
|
1455
|
-
else if (summary.includes(term))
|
|
1456
|
-
score += 3;
|
|
1457
|
-
if (path.includes(term))
|
|
1458
|
-
score += 3;
|
|
1459
|
-
if (params.some((p) => p === term))
|
|
1460
|
-
score += 3;
|
|
1461
|
-
else if (params.some((p) => p.includes(term)))
|
|
1462
|
-
score += 1;
|
|
1463
|
-
if (description.includes(term))
|
|
1464
|
-
score += 1;
|
|
1465
|
-
}
|
|
1466
|
-
const phrase = query.trim().toLowerCase();
|
|
1467
|
-
if (phrase.length >= 3 && (tool.includes(phrase.replace(/[^a-z0-9]+/g, "_")) || summary.includes(phrase)))
|
|
1468
|
-
score += 8;
|
|
1469
|
-
return score;
|
|
1470
|
-
}
|
|
1471
1692
|
export async function docsSearch(source, query, page = 1) {
|
|
1472
1693
|
const sections = [];
|
|
1473
|
-
const ranked = source.ops
|
|
1474
|
-
|
|
1475
|
-
.filter((r) => r.score > 0)
|
|
1476
|
-
.sort((a, b) => b.score - a.score || a.op.tool.localeCompare(b.op.tool));
|
|
1477
|
-
const omittedRanked = (source.omittedOps ?? [])
|
|
1478
|
-
.map((op) => ({ op, score: searchScore(op, query) }))
|
|
1479
|
-
.filter((result) => result.score > 0)
|
|
1480
|
-
.sort((a, b) => b.score - a.score || a.op.tool.localeCompare(b.op.tool));
|
|
1694
|
+
const ranked = rankOperations(source.ops, query);
|
|
1695
|
+
const omittedRanked = rankOperations(source.omittedOps ?? [], query);
|
|
1481
1696
|
const exactOmitted = findOperation(source.omittedOps ?? [], query);
|
|
1482
1697
|
if (exactOmitted)
|
|
1483
|
-
return omittedPlanLimit([exactOmitted], exactOmitted.tool);
|
|
1698
|
+
return omittedPlanLimit(source, [exactOmitted], exactOmitted.tool);
|
|
1484
1699
|
const pageIndex = Math.max(1, Math.floor(page)) - 1;
|
|
1485
1700
|
const slice = ranked.slice(pageIndex * SEARCH_PAGE_SIZE, (pageIndex + 1) * SEARCH_PAGE_SIZE);
|
|
1701
|
+
const more = ranked.length - (pageIndex + 1) * SEARCH_PAGE_SIZE;
|
|
1486
1702
|
if (slice.length > 0) {
|
|
1487
|
-
const more = ranked.length - (pageIndex + 1) * SEARCH_PAGE_SIZE;
|
|
1488
1703
|
sections.push("Reference matches (best first" + (ranked.length > SEARCH_PAGE_SIZE ? ", page " + (pageIndex + 1) + " of " + Math.ceil(ranked.length / SEARCH_PAGE_SIZE) : "") + "):\n" +
|
|
1489
|
-
slice.map((r) => "- " + r.op.tool + ": " + (r.op
|
|
1490
|
-
(more > 0 ? "\n(" + more + " more; pass page: " + (pageIndex + 2) + ")" : "")
|
|
1704
|
+
slice.map((r) => "- " + r.op.tool + ": " + searchLabel(r.op)).join("\n") +
|
|
1705
|
+
(more > 0 ? "\n(" + more + " more; pass page: " + (pageIndex + 2) + ")" : "") +
|
|
1706
|
+
"\nread_docs {\"page\": \"<tool>\"} gives an operation's arguments and example.");
|
|
1491
1707
|
}
|
|
1492
1708
|
else if (ranked.length > 0) {
|
|
1493
1709
|
sections.push("No reference matches on page " + (pageIndex + 1) + "; there are " + Math.ceil(ranked.length / SEARCH_PAGE_SIZE) + " pages.");
|
|
@@ -1496,40 +1712,92 @@ export async function docsSearch(source, query, page = 1) {
|
|
|
1496
1712
|
const guideSlice = guides.slice(pageIndex * SEARCH_PAGE_SIZE, (pageIndex + 1) * SEARCH_PAGE_SIZE);
|
|
1497
1713
|
const proseMatchCount = guides.length;
|
|
1498
1714
|
if (guideSlice.length > 0) {
|
|
1499
|
-
sections.push("Guide matches (best first, " + guides.length + " pages):\n" + guideSlice.map((match) => "- [" + match.title + (match.section ? " / " + match.section : "") + "](" + match.url + "): " + match.excerpt
|
|
1715
|
+
sections.push("Guide matches (best first, " + guides.length + " pages; read_docs {\"page\": \"<url>\"} reads one):\n" + guideSlice.map((match) => "- [" + match.title + (match.section ? " / " + match.section : "") + "](" + match.url + "): " + match.excerpt).join("\n"));
|
|
1500
1716
|
}
|
|
1501
1717
|
if (status === "unavailable")
|
|
1502
1718
|
sections.push("The docs site is unavailable; the API reference was still searched.");
|
|
1719
|
+
// Lean on purpose: the text above is what most clients show the model,
|
|
1720
|
+
// and this mirrors it rather than repeating the read_docs call per hit.
|
|
1503
1721
|
const structured = {
|
|
1504
|
-
schema_version: "
|
|
1505
|
-
reference: slice.map(({ op }) => ({ tool: op.tool,
|
|
1506
|
-
guides: guideSlice
|
|
1722
|
+
schema_version: "2", query, page: pageIndex + 1,
|
|
1723
|
+
reference: slice.map(({ op }) => ({ tool: op.tool, summary: searchLabel(op), ...(searchDeprecated(op) ? { deprecated: true } : {}) })),
|
|
1724
|
+
guides: guideSlice,
|
|
1507
1725
|
totals: { reference: ranked.length, guides: guides.length }, guides_status: status,
|
|
1726
|
+
...(slice.length > 0 || guideSlice.length > 0 ? { read_docs: { page: "<tool name or guide url>" } } : {}),
|
|
1727
|
+
...(more > 0 ? { next_page: pageIndex + 2 } : {}),
|
|
1508
1728
|
};
|
|
1509
1729
|
if (omittedRanked.length > 0 && ranked.length === 0 && proseMatchCount === 0) {
|
|
1510
|
-
return omittedPlanLimit(omittedRanked.map((result) => result.op));
|
|
1730
|
+
return omittedPlanLimit(source, omittedRanked.map((result) => result.op));
|
|
1511
1731
|
}
|
|
1512
1732
|
if (omittedRanked.length > 0) {
|
|
1513
|
-
|
|
1733
|
+
const generated = source.generatedOperationCount ?? source.ops.length;
|
|
1734
|
+
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"));
|
|
1514
1735
|
}
|
|
1515
|
-
const coverage = coverageText(source);
|
|
1516
1736
|
if (sections.length === 0) {
|
|
1517
1737
|
return {
|
|
1518
|
-
text:
|
|
1738
|
+
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)" : ""),
|
|
1519
1739
|
isError: false,
|
|
1520
1740
|
structured,
|
|
1521
1741
|
};
|
|
1522
1742
|
}
|
|
1523
|
-
return { text:
|
|
1743
|
+
return { text: sections.join("\n\n"), isError: false, structured };
|
|
1744
|
+
}
|
|
1745
|
+
function searchDeprecated(op) {
|
|
1746
|
+
return op.deprecated === true;
|
|
1747
|
+
}
|
|
1748
|
+
/** One line per hit: the summary (or the wire call), flagged when deprecated. */
|
|
1749
|
+
function searchLabel(op) {
|
|
1750
|
+
return (searchDeprecated(op) ? "(deprecated) " : "") + (op.summary ?? operationLabel(op));
|
|
1751
|
+
}
|
|
1752
|
+
/** Characters one read_docs call returns; the rest is paged by offset. */
|
|
1753
|
+
export const READ_DOCS_LIMIT = 20_000;
|
|
1754
|
+
/** One part of a long page, ending with how to read the next part. */
|
|
1755
|
+
function docsPart(page, text, offset, schema = false) {
|
|
1756
|
+
return docsPartFor({ page, ...(schema ? { schema: true } : {}) }, text, offset);
|
|
1757
|
+
}
|
|
1758
|
+
/** docsPart for any read_docs arguments (a page, a path, schema). */
|
|
1759
|
+
function docsPartFor(request, text, offset) {
|
|
1760
|
+
if (offset <= 0 && text.length <= READ_DOCS_LIMIT)
|
|
1761
|
+
return text;
|
|
1762
|
+
const start = Math.min(Math.max(0, offset), text.length);
|
|
1763
|
+
const end = Math.min(text.length, start + READ_DOCS_LIMIT);
|
|
1764
|
+
const more = end < text.length
|
|
1765
|
+
? "\n\n[Characters " + start + "-" + end + " of " + text.length + ". Continue with read_docs " + JSON.stringify({ ...request, offset: end }) + ".]"
|
|
1766
|
+
: "\n\n[Characters " + start + "-" + end + " of " + text.length + "; end of page.]";
|
|
1767
|
+
return text.slice(start, end) + more;
|
|
1768
|
+
}
|
|
1769
|
+
/** How read_docs names a type lookup, for the pages that mention types. */
|
|
1770
|
+
function readDocsTypeHint(name) {
|
|
1771
|
+
return "read_docs " + JSON.stringify({ page: name });
|
|
1772
|
+
}
|
|
1773
|
+
/** One argument path within an operation or a named type. */
|
|
1774
|
+
function docsPathOutcome(source, root, page, path, offset) {
|
|
1775
|
+
const found = argumentPathText(source.inputTypes, root, path, readDocsTypeHint);
|
|
1776
|
+
if (found.ok)
|
|
1777
|
+
return { text: docsPartFor({ page, path }, found.text, offset), isError: false };
|
|
1778
|
+
const structured = {
|
|
1779
|
+
error: "NotFoundError", code: "NOT_FOUND", message: found.message,
|
|
1780
|
+
...(found.available.length > 0 ? { available: found.available } : {}),
|
|
1781
|
+
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."],
|
|
1782
|
+
};
|
|
1783
|
+
return { text: JSON.stringify(structured), isError: true, structured };
|
|
1524
1784
|
}
|
|
1525
|
-
export async function docsRead(source, page) {
|
|
1785
|
+
export async function docsRead(source, page, offset = 0, options = {}) {
|
|
1526
1786
|
const opMatch = findOperation(source.ops, page);
|
|
1527
|
-
const
|
|
1787
|
+
const typeMatch = opMatch ? undefined : findInputType(source.inputTypes, page);
|
|
1788
|
+
if (options.path !== undefined && options.path.trim() !== "") {
|
|
1789
|
+
if (opMatch)
|
|
1790
|
+
return docsPathOutcome(source, opMatch, page, options.path, offset);
|
|
1791
|
+
if (typeMatch)
|
|
1792
|
+
return docsPathOutcome(source, { type: typeMatch }, page, options.path, offset);
|
|
1793
|
+
}
|
|
1528
1794
|
if (opMatch)
|
|
1529
|
-
return { text:
|
|
1795
|
+
return { text: docsPart(page, referenceText(opMatch, { schema: options.schema, types: source.inputTypes }), offset, options.schema === true), isError: false };
|
|
1530
1796
|
const omittedMatch = findOperation(source.omittedOps ?? [], page);
|
|
1531
1797
|
if (omittedMatch)
|
|
1532
|
-
return omittedPlanLimit([omittedMatch], omittedMatch.tool);
|
|
1798
|
+
return omittedPlanLimit(source, [omittedMatch], omittedMatch.tool);
|
|
1799
|
+
if (typeMatch)
|
|
1800
|
+
return { text: docsPartFor({ page }, inputTypeText(source.inputTypes, typeMatch, readDocsTypeHint), offset), isError: false };
|
|
1533
1801
|
let target = page;
|
|
1534
1802
|
if (!/^https?:\/\//.test(target)) {
|
|
1535
1803
|
const index = await fetchDocs(source, "llms.txt");
|
|
@@ -1541,7 +1809,7 @@ export async function docsRead(source, page) {
|
|
|
1541
1809
|
? "A docs URL was not provided at generate time, and no generated operation matches \"" + page + "\"."
|
|
1542
1810
|
: "Couldn't fetch \"" + page + "\". Use search_docs to find pages.", "NOT_FOUND", ["search_docs finds operations and guide pages."]);
|
|
1543
1811
|
}
|
|
1544
|
-
return { text:
|
|
1812
|
+
return { text: docsPart(page, text, offset), isError: false };
|
|
1545
1813
|
}
|
|
1546
1814
|
/**
|
|
1547
1815
|
* Dispatch for the shared tools (search_docs, read_docs, execute); returns
|
|
@@ -1557,17 +1825,39 @@ export async function callSharedTool(name, args, source, runOperation) {
|
|
|
1557
1825
|
return docsSearch(source, args.query, page);
|
|
1558
1826
|
}
|
|
1559
1827
|
if (name === "read_docs") {
|
|
1560
|
-
|
|
1828
|
+
const offset = typeof args.offset === "number" ? args.offset : typeof args.offset === "string" && /^\d+$/.test(args.offset) ? Number(args.offset) : 0;
|
|
1829
|
+
const schema = args.schema === true || args.schema === "true";
|
|
1830
|
+
if (typeof args.page === "string" && !findOperation(source.ops, args.page)) {
|
|
1831
|
+
const hidden = findHidden(source, args.page);
|
|
1832
|
+
if (hidden)
|
|
1833
|
+
return hiddenOutcome(hidden);
|
|
1834
|
+
}
|
|
1835
|
+
const path = typeof args.path === "string" ? args.path : undefined;
|
|
1836
|
+
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." }]);
|
|
1561
1837
|
}
|
|
1562
1838
|
if (name === "execute") {
|
|
1563
1839
|
if (typeof args.operation !== "string")
|
|
1564
1840
|
return argumentsError({ tool: name }, [{ code: "MISSING_ARGUMENT", argument: "operation", message: "execute requires an operation name." }]);
|
|
1841
|
+
// Operation arguments (fields included) belong inside arguments; a
|
|
1842
|
+
// stray top-level key would otherwise be ignored without a word.
|
|
1843
|
+
const stray = Object.keys(args).filter((key) => !EXECUTE_KEYS.includes(key) && args[key] !== undefined);
|
|
1844
|
+
if (stray.length > 0) {
|
|
1845
|
+
return argumentsError({ tool: name }, stray.map((key) => ({
|
|
1846
|
+
code: "UNKNOWN_ARGUMENT",
|
|
1847
|
+
argument: key,
|
|
1848
|
+
message: "Unknown argument \"" + key + "\" to execute, which takes " + EXECUTE_KEYS.join(", ") + ". Put operation arguments, including fields, inside arguments: {\"operation\": \"" + args.operation + "\", \"arguments\": {\"" + key + "\": …}}.",
|
|
1849
|
+
})));
|
|
1850
|
+
}
|
|
1565
1851
|
const target = findOperation(source.ops, args.operation);
|
|
1566
1852
|
if (!target) {
|
|
1567
1853
|
const omitted = findOperation(source.omittedOps ?? [], args.operation);
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1854
|
+
if (omitted)
|
|
1855
|
+
return omittedPlanLimit(source, [omitted], omitted.tool);
|
|
1856
|
+
const hidden = findHidden(source, args.operation);
|
|
1857
|
+
if (hidden)
|
|
1858
|
+
return hiddenOutcome(hidden);
|
|
1859
|
+
const suggestions = rankOperations(source.ops, args.operation.replace(/[_.]+/g, " ")).slice(0, 3).map((r) => r.op.tool);
|
|
1860
|
+
return textError("Unknown operation: " + args.operation + "." + (suggestions.length > 0 ? " Did you mean " + suggestions.join(", ") + "?" : ""), "NOT_FOUND", [...(suggestions.length > 0 ? ["read_docs {\"page\": \"" + suggestions[0] + "\"} gives its arguments."] : []), "search_docs finds operations by name, path or description."]);
|
|
1571
1861
|
}
|
|
1572
1862
|
if (operationSafety(target) === "destructive" && args.confirm !== true) {
|
|
1573
1863
|
return textError("The destructive operation " + target.tool + " requires explicit confirmation.", "CONFIRMATION_REQUIRED", ["Review read_docs " + target.tool + ", then retry execute with confirm: true if the destructive effect is intended."]);
|
|
@@ -1577,5 +1867,11 @@ export async function callSharedTool(name, args, source, runOperation) {
|
|
|
1577
1867
|
: {};
|
|
1578
1868
|
return runOperation(target, opArgs);
|
|
1579
1869
|
}
|
|
1580
|
-
|
|
1870
|
+
// An operation tool the server's switches hide (operations mode).
|
|
1871
|
+
const hidden = source.ops.some((op) => op.tool === name) ? undefined : (source.hiddenOps ?? []).find((h) => h.op.tool === name);
|
|
1872
|
+
return hidden ? hiddenOutcome(hidden) : undefined;
|
|
1873
|
+
}
|
|
1874
|
+
/** Every OAuth scope an operation's security requirements name, in order. */
|
|
1875
|
+
export function requiredScopes(security) {
|
|
1876
|
+
return [...new Set((security ?? []).flatMap((requirement) => Object.values(requirement).flat()))];
|
|
1581
1877
|
}
|