echelongraph-mcp 3.0.2 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +676 -5
- package/README.md +79 -84
- package/dist/http.js +77 -84
- package/dist/httpPolicy.js +206 -14
- package/dist/index.js +489 -49
- package/dist/inputBounds.js +38 -0
- package/dist/logLines.js +86 -0
- package/dist/prompts.js +122 -23
- package/dist/readBody.js +140 -0
- package/dist/requiredText.js +23 -3
- package/dist/resources.js +3 -3
- package/dist/runtime.js +66 -1
- package/dist/schemaRefusal.js +147 -0
- package/dist/textBudget.js +826 -76
- package/dist/tools/check_affected.js +19 -6
- package/dist/tools/check_sbom.js +223 -25
- package/dist/tools/cve_intel.js +145 -35
- package/dist/tools/cve_remediation.js +101 -19
- package/dist/tools/epss_history.js +41 -5
- package/dist/tools/match_batch.js +57 -10
- package/dist/tools/packageRecords.js +58 -0
- package/dist/tools/scan_manifest.js +13 -4
- package/dist/tools/vendor_advisories.js +27 -4
- package/docs/exposure-radar.md +4 -0
- package/docs/rate-limits.md +9 -5
- package/docs/structured-results.md +23 -3
- package/docs/tools.md +139 -6
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/requiredText.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// dropped, a head naming Shodan without SHODAN_OWNERSHIP, or a tool registered with no entry here
|
|
6
6
|
// fails it. A tool added later adds its entry here.
|
|
7
7
|
//
|
|
8
|
-
// The cut. Claude Code
|
|
9
|
-
// cut at 2,048 characters, with "… [truncated]" appended:
|
|
8
|
+
// The cut. Claude Code from v2.1.280 sent each MCP tool description and each server's instructions
|
|
9
|
+
// to the model cut at 2,048 characters, with "… [truncated]" appended:
|
|
10
10
|
// - Claude Code docs, environment variables (code.claude.com/docs/en/env-vars, read 2026-10-05):
|
|
11
11
|
// CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH, "Maximum length in characters of each MCP tool
|
|
12
12
|
// description and each MCP server's instructions that Claude Code sends to the model (default:
|
|
@@ -14,10 +14,15 @@
|
|
|
14
14
|
// - anthropics/claude-code#87650 (closed, not planned): the minified constant Tpe=2048 gates the
|
|
15
15
|
// cut in v2.1.234; /mcp shows the whole text while the model receives the cut one, and only the
|
|
16
16
|
// instructions' cut is logged ("Server instructions truncated from X to 2048 chars").
|
|
17
|
+
// - Re-read 2026-10-18: the same page now gives default limits of 4,096, and 16,384 for a tool
|
|
18
|
+
// Claude Code loads through tool search, and v2.1.302's --debug log shows "Tool
|
|
19
|
+
// "exposure_radar" description truncated from 11807 to 4096 chars" (2.6.8's description). The
|
|
20
|
+
// budget stays 2,048: builds from v2.1.280 until that change cut there, and they are in use.
|
|
17
21
|
// The other clients looked at cut later or not at all: Cursor cuts tool descriptions at 6,500
|
|
18
22
|
// characters (Cursor staff, forum.cursor.com/t/147465); VS Code and Claude Desktop document no
|
|
19
23
|
// description cap (VS Code caps a tool's name at 64 characters and a request at 128 tools). So
|
|
20
|
-
//
|
|
24
|
+
// the shortest cut a client is known to make is the budget: text that fits it reaches every one of
|
|
25
|
+
// them whole. docs/tools.md ("The client cut") says the same for readers.
|
|
21
26
|
//
|
|
22
27
|
// Kept under src/ so marketing-site lib/mcpToolClaims.test.ts's resolver can fold the two Shodan
|
|
23
28
|
// sentences, which the descriptions and notes take from here.
|
|
@@ -127,7 +132,22 @@ export const REQUIRED_IN_HEAD = {
|
|
|
127
132
|
// none. The sentence says what that pair means and which field is the current reading; it names a
|
|
128
133
|
// field, not a tool (test/description-policy.test.mjs: no schema text names another tool).
|
|
129
134
|
export const PATCH_AVAILABLE_WITHDRAWN = "true beside an empty patch_evidence means evidence of a fix once held has since been withdrawn (patch_available is never set back to false), and fix_status (remediation.fix_status in the CVE's record) is the current reading, not patch_available.";
|
|
135
|
+
// #3016: cve_exposure's three KEV flags. kev_listed is core-backend's deprecated alias of
|
|
136
|
+
// kev_seen_in_observations (kevexposure/store.go CVEExposure.KEVListed): bool_or over the radar's
|
|
137
|
+
// observation rows, so it is false for a CISA-KEV CVE the radar holds no service for, whatever the
|
|
138
|
+
// catalog says. Through 3.0.2 the outputSchema gave all three as booleans with no text, and hosted
|
|
139
|
+
// cve_exposure CVE-2024-3400 (PAN-OS, CISA-KEV) served kev_listed false beside kev_catalog_listed
|
|
140
|
+
// true (2026-10-10), so a client reading data alone could report a KEV CVE as not on KEV. The
|
|
141
|
+
// field stays (dropping it would break a reader, a major version); its text says what it is.
|
|
142
|
+
export const KEV_LISTED_DEPRECATED = "Deprecated: the same value as kev_seen_in_observations, kept for compatibility.";
|
|
143
|
+
export const KEV_OBSERVATION_FLAG = "It is the radar's observation flag, not CISA-KEV catalog status";
|
|
144
|
+
export const KEV_CATALOG_FLAG = "The CISA-KEV catalog flag:";
|
|
130
145
|
export const REQUIRED_IN_OUTPUT_SCHEMA = {
|
|
131
146
|
get_cve: { "data.patch_available": [PATCH_AVAILABLE_WITHDRAWN] },
|
|
132
147
|
search_cves: { "data.cves[].patch_available": [PATCH_AVAILABLE_WITHDRAWN] },
|
|
148
|
+
cve_exposure: {
|
|
149
|
+
"data.kev_listed": [KEV_LISTED_DEPRECATED, KEV_OBSERVATION_FLAG],
|
|
150
|
+
"data.kev_seen_in_observations": [KEV_OBSERVATION_FLAG],
|
|
151
|
+
"data.kev_catalog_listed": [KEV_CATALOG_FLAG],
|
|
152
|
+
},
|
|
133
153
|
};
|
package/dist/resources.js
CHANGED
|
@@ -55,15 +55,15 @@ A count of 0 is a finding of none when the answer measured it. Under assessed: f
|
|
|
55
55
|
|
|
56
56
|
## How each tool measures
|
|
57
57
|
|
|
58
|
-
- cve_summary: measured; measured_at is summary.last_updated, the newest modification time among the active CVE records it counts. freshness is
|
|
58
|
+
- cve_summary: measured; measured_at is summary.last_updated, the newest modification time among the active CVE records it counts. freshness.nvd_poll is the fleet's last completed NVD poll: last_run_at is the answer's nvd_poll.last_completed_at, with scope fleet and the poll's interval. freshness is null where the answer carries no nvd_poll block (an older API, or one holding no fleet NVD poll it could read) or one without a real RFC 3339 time, and the note says which.
|
|
59
59
|
- search_cves: measured, measured_at null (each record carries its own times); coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset.
|
|
60
60
|
- get_cve: measured; measured_at is the record's updated_at, when EchelonGraph last wrote it. score_assessed false means not yet scored, not a score of 0.
|
|
61
61
|
- cve_exposure: not_assessed with measured_at null, every answer: its last_seen is when EchelonGraph last wrote or refreshed a service's row, not an observation time. exposure_state (exposed, measured_zero, not_assessed, tracking_unknown) says what the count is; exposed_hosts counts distinct ip:port services from Shodan data, not machines, and is not an internet-wide census.
|
|
62
62
|
- exposure_radar: not_assessed with measured_at null: no radar's answer gives one time at which what it counts was observed. freshness gives each radar's last_run_at where the API serves one.
|
|
63
63
|
- kev_recent: measured; measured_at is EchelonGraph's last successful fetch of CISA's KEV feed; freshness carries that fetch time, catalog_version and date_released.
|
|
64
64
|
- epss_history: measured_at is current.epss_updated_at, when EchelonGraph last wrote a changed EPSS value; the series is change-only, so a missing point is not recorded, not unchanged.
|
|
65
|
-
- check_affected: measured when the answer says assessed true; not_assessed when it says false or does not say. measured_at and freshness are null.
|
|
66
|
-
- check_sbom: measured when at least one component got a verdict, else not_assessed; measured_at null. not_affected is the one clean verdict; undetermined and not_assessed are not clean.
|
|
65
|
+
- check_affected: measured when the answer says assessed true; not_assessed when it says false or does not say. measured_at and freshness are null. A registry match carries fixed_in, the lowest version above the one asked about that no affected interval of its record holds, or null with fixed_in_reason, which is not a finding that no fix exists. A CPE match carries no fixed_in: the end of the NVD range it matched, cpe_version_end_excluding or cpe_version_end_including, is not a fix.
|
|
66
|
+
- check_sbom: measured when at least one component got a verdict, else not_assessed; measured_at null. not_affected is the one clean verdict; undetermined and not_assessed are not clean. Each match carries fixed_in, or null with fixed_in_reason, as check_affected's registry matches do. A deb, apk or alpine purl with an upstream qualifier (upstream=name, or upstream=name@version) is checked as the source package it names, which is how Debian and Alpine file advisories: its row says matched_via upstream, with that name as matched_package. Any other row the lookup ran for says matched_via purl_name; a refused row has neither. A binary package such as libssl3 sent without the qualifier is looked up by its own name, which the advisories do not use, and comes back not_assessed (package_not_in_advisory_corpus), never clean.
|
|
67
67
|
- scan_manifest: as check_sbom, on the purls this server reads from the lockfiles passed; not_assessed, with no API call, when no entry pins one registry version. Each entry not sent (a range, an unresolved version, a local path, a VCS checkout or a line not read) is listed in data.not_checked with its reason and is not clean; a refused file (package.json, go.sum and the other manifests of ranges) is named in the note, and nothing in it is checked.
|
|
68
68
|
- cve_intel: measured_at null: each row carries its own dates. An exploits row carries source_published_at, the source's own date for the reference, null when the source publishes none (EchelonGraph does not supply one), and eg_first_seen_at, when EchelonGraph recorded the reference; first_seen_at is deprecated and holds the same value as source_published_at. A timeline row carries enriched_at. An empty exploits list is not evidence that no public exploit exists.
|
|
69
69
|
- cve_remediation: measured with measured_at null, or not_assessed for a CVE record rejected by its numbering authority. Every text is its source's, verbatim and untested by EchelonGraph; each vendor item's kind is the vendor's own category. remediation_state none_in_source or not_parsed, and an empty list, are not a finding that no fix exists.
|
package/dist/runtime.js
CHANGED
|
@@ -20,7 +20,72 @@ export function isHttpEntrypoint() {
|
|
|
20
20
|
}
|
|
21
21
|
const clientStore = new AsyncLocalStorage();
|
|
22
22
|
export function withClient(clientIp, fn, opts = {}) {
|
|
23
|
-
return clientStore.run({ clientIp, synthetic: opts.synthetic === true, signal: opts.signal, work: opts.work }, fn);
|
|
23
|
+
return clientStore.run({ clientIp, synthetic: opts.synthetic === true, signal: opts.signal, work: opts.work, rpcMethod: opts.rpcMethod, tool: opts.tool, protocolVersion: opts.protocolVersion }, fn);
|
|
24
|
+
}
|
|
25
|
+
/** #2923: the fixed labels of the request being served, for a line the SDK's onerror writes (it is handed only the
|
|
26
|
+
* error): the JSON-RPC method (MCP_METHODS or "(other)") and the protocol-version label; "(none)" outside a request. */
|
|
27
|
+
export function requestLabels() {
|
|
28
|
+
const scope = clientStore.getStore();
|
|
29
|
+
return { rpcMethod: scope?.rpcMethod ?? "(none)", protocolVersion: scope?.protocolVersion ?? "(none)" };
|
|
30
|
+
}
|
|
31
|
+
let upstreamSink;
|
|
32
|
+
/** http.ts only: where each failed API call is reported. */
|
|
33
|
+
export function onUpstreamFailure(sink) {
|
|
34
|
+
upstreamSink = sink;
|
|
35
|
+
}
|
|
36
|
+
/** Reports one failed API call: on the hosted endpoint to http.ts's line; on stdio, nowhere. */
|
|
37
|
+
export function upstreamFailed(f) {
|
|
38
|
+
if (!httpEntrypoint || !upstreamSink)
|
|
39
|
+
return;
|
|
40
|
+
const scope = clientStore.getStore();
|
|
41
|
+
try {
|
|
42
|
+
upstreamSink({ ...f, tool: scope?.tool ?? "(none)", rpcMethod: scope?.rpcMethod ?? "(none)", synthetic: scope?.synthetic === true });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A log line never fails the call it describes.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const DNS_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "EAI_FAIL", "EAI_NONAME", "EAI_NODATA"]);
|
|
49
|
+
const RESET_CODES = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED", "UND_ERR_SOCKET"]);
|
|
50
|
+
const CONNECT_TIMEOUT_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "ETIMEDOUT"]);
|
|
51
|
+
const TIMEOUT_CODES = new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
|
52
|
+
const UNREACHABLE_CODES = new Set(["EHOSTUNREACH", "ENETUNREACH", "EHOSTDOWN", "ENETDOWN"]);
|
|
53
|
+
const TLS_CODE = /^(?:ERR_TLS_|ERR_SSL_|ERR_OSSL_|CERT_|UNABLE_TO_|DEPTH_ZERO_SELF_SIGNED_CERT$|SELF_SIGNED_CERT_IN_CHAIN$|HOSTNAME_MISMATCH$)/;
|
|
54
|
+
/** The class of a fetch that threw: from the first code in its cause chain this knows, else network. Codes only, never a message. */
|
|
55
|
+
export function networkFailureClass(e) {
|
|
56
|
+
const codes = [];
|
|
57
|
+
let c = e;
|
|
58
|
+
for (let depth = 0; c instanceof Error && depth < 5; depth++) {
|
|
59
|
+
const x = c;
|
|
60
|
+
if (typeof x.code === "string")
|
|
61
|
+
codes.push(x.code);
|
|
62
|
+
// A connection tried on several addresses fails as an AggregateError of each one's error.
|
|
63
|
+
if (Array.isArray(x.errors)) {
|
|
64
|
+
for (const y of x.errors) {
|
|
65
|
+
const code = y?.code;
|
|
66
|
+
if (typeof code === "string")
|
|
67
|
+
codes.push(code);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
c = x.cause;
|
|
71
|
+
}
|
|
72
|
+
for (const code of codes) {
|
|
73
|
+
if (DNS_CODES.has(code))
|
|
74
|
+
return "dns";
|
|
75
|
+
if (code === "ECONNREFUSED")
|
|
76
|
+
return "refused";
|
|
77
|
+
if (RESET_CODES.has(code))
|
|
78
|
+
return "reset";
|
|
79
|
+
if (CONNECT_TIMEOUT_CODES.has(code))
|
|
80
|
+
return "connect_timeout";
|
|
81
|
+
if (TIMEOUT_CODES.has(code))
|
|
82
|
+
return "timeout";
|
|
83
|
+
if (UNREACHABLE_CODES.has(code))
|
|
84
|
+
return "unreachable";
|
|
85
|
+
if (TLS_CODE.test(code))
|
|
86
|
+
return "tls";
|
|
87
|
+
}
|
|
88
|
+
return "network";
|
|
24
89
|
}
|
|
25
90
|
// #2775: a hosted client can go before its answer is ready (its own timeout, a closed tab, a
|
|
26
91
|
// retry). Nobody will read that answer, so the request's API calls are cancelled (index.ts api()
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const REFUSED = Symbol("echelongraph-mcp.schema-refused");
|
|
2
|
+
const isRefused = (v) => typeof v === "object" && v !== null && REFUSED in v;
|
|
3
|
+
// How many issues the note names one by one; the rest are counted.
|
|
4
|
+
const ISSUES_NAMED = 5;
|
|
5
|
+
const keyOf = (p) => (typeof p === "object" && p !== null ? p.key : p);
|
|
6
|
+
// files[0].filename, as a model reads an argument path.
|
|
7
|
+
function pathText(path) {
|
|
8
|
+
let out = "";
|
|
9
|
+
for (const p of path ?? []) {
|
|
10
|
+
const k = keyOf(p);
|
|
11
|
+
out += typeof k === "number" ? `[${k}]` : out === "" ? String(k) : `.${String(k)}`;
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
// The value the caller sent at a path, to name its JSON type (never the value itself).
|
|
16
|
+
function valueAt(input, path) {
|
|
17
|
+
let v = input;
|
|
18
|
+
for (const p of path ?? []) {
|
|
19
|
+
if (v === null || typeof v !== "object")
|
|
20
|
+
return undefined;
|
|
21
|
+
v = v[keyOf(p)];
|
|
22
|
+
}
|
|
23
|
+
return v;
|
|
24
|
+
}
|
|
25
|
+
function jsonType(v) {
|
|
26
|
+
if (v === null)
|
|
27
|
+
return "null";
|
|
28
|
+
if (Array.isArray(v))
|
|
29
|
+
return "an array";
|
|
30
|
+
if (typeof v === "number")
|
|
31
|
+
return Number.isInteger(v) ? "an integer" : "a number";
|
|
32
|
+
if (typeof v === "object")
|
|
33
|
+
return "an object";
|
|
34
|
+
return typeof v === "string" ? "a string" : typeof v === "boolean" ? "a boolean" : typeof v;
|
|
35
|
+
}
|
|
36
|
+
const TYPE_NAMES = {
|
|
37
|
+
string: "a string",
|
|
38
|
+
number: "a number",
|
|
39
|
+
int: "an integer",
|
|
40
|
+
boolean: "a boolean",
|
|
41
|
+
array: "an array",
|
|
42
|
+
object: "an object",
|
|
43
|
+
record: "an object",
|
|
44
|
+
bigint: "an integer",
|
|
45
|
+
};
|
|
46
|
+
const listOf = (items) => (items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`);
|
|
47
|
+
const bound = (n) => (typeof n === "bigint" ? n.toString() : typeof n === "number" ? String(n) : "?");
|
|
48
|
+
// One issue as "<argument> <rule>": the argument named by its path in the arguments, the rule in the
|
|
49
|
+
// inputSchema's terms. An issue this does not know is given in the schema library's own words.
|
|
50
|
+
export function issueText(issue, input) {
|
|
51
|
+
const at = pathText(issue.path);
|
|
52
|
+
const name = at === "" ? "the arguments" : at;
|
|
53
|
+
const sent = valueAt(input, issue.path);
|
|
54
|
+
switch (issue.code) {
|
|
55
|
+
case "invalid_type": {
|
|
56
|
+
const want = TYPE_NAMES[issue.expected ?? ""] ?? issue.expected ?? "another type";
|
|
57
|
+
if (sent === undefined)
|
|
58
|
+
return `${name} is required (${want})`;
|
|
59
|
+
if (issue.expected === "int" && typeof sent === "number")
|
|
60
|
+
return `${name} must be an integer`;
|
|
61
|
+
return `${name} must be ${want}, not ${jsonType(sent)}`;
|
|
62
|
+
}
|
|
63
|
+
case "too_big":
|
|
64
|
+
case "too_small": {
|
|
65
|
+
const big = issue.code === "too_big";
|
|
66
|
+
const n = bound(big ? issue.maximum : issue.minimum);
|
|
67
|
+
const cmp = issue.inclusive === false ? (big ? "less than" : "more than") : big ? "at most" : "at least";
|
|
68
|
+
if (issue.origin === "string")
|
|
69
|
+
return `${name} must be ${cmp} ${n} ${n === "1" ? "character" : "characters"} long`;
|
|
70
|
+
if (issue.origin === "array" || issue.origin === "set")
|
|
71
|
+
return `${name} must hold ${cmp} ${n} ${n === "1" ? "entry" : "entries"}`;
|
|
72
|
+
// #3345: a record's bound (inputBounds.ts boundedRecord), counted in its own members.
|
|
73
|
+
if (issue.origin === "object" || issue.origin === "record")
|
|
74
|
+
return `${name} must hold ${cmp} ${n} ${n === "1" ? "member" : "members"}`;
|
|
75
|
+
return `${name} must be ${cmp} ${n}`;
|
|
76
|
+
}
|
|
77
|
+
case "invalid_value":
|
|
78
|
+
return Array.isArray(issue.values) && issue.values.length
|
|
79
|
+
? `${name} must be ${issue.values.length === 1 ? "" : "one of "}${listOf(issue.values.map((v) => JSON.stringify(v)))}`
|
|
80
|
+
: `${name}: ${issue.message}`;
|
|
81
|
+
case "unrecognized_keys": {
|
|
82
|
+
const keys = (issue.keys ?? []).map((k) => (at === "" ? k : `${at}.${k}`));
|
|
83
|
+
return keys.length ? `${listOf(keys)} ${keys.length === 1 ? "is not an argument" : "are not arguments"} this tool takes` : `${name}: ${issue.message}`;
|
|
84
|
+
}
|
|
85
|
+
case "invalid_format":
|
|
86
|
+
return `${name} must be ${issue.format ? `a valid ${issue.format}` : "in the format the inputSchema gives"}`;
|
|
87
|
+
case "invalid_union": {
|
|
88
|
+
// #3345: the value is of the type one branch takes and broke that branch's rule (an sbom
|
|
89
|
+
// object past its bound): name that branch's issues, at the union's path.
|
|
90
|
+
const ofType = (b) => b.length === 1 && b[0].code === "invalid_type" && !b[0].path?.length;
|
|
91
|
+
const matched = (issue.errors ?? []).filter((b) => b.length > 0 && !ofType(b));
|
|
92
|
+
if (matched.length === 1)
|
|
93
|
+
return matched[0].map((i) => issueText({ ...i, path: [...(issue.path ?? []), ...(i.path ?? [])] }, input)).join("; ");
|
|
94
|
+
// Each branch refused only the value's type: name the types the branches take.
|
|
95
|
+
const types = (issue.errors ?? []).map((b) => (ofType(b) ? TYPE_NAMES[b[0].expected ?? ""] : undefined));
|
|
96
|
+
const named = types.length > 0 && types.every((t) => t !== undefined) ? [...new Set(types)] : undefined;
|
|
97
|
+
return named ? `${name} must be ${listOf(named)}, not ${jsonType(sent)}` : `${name} must take one of the forms the inputSchema allows, not ${jsonType(sent)}`;
|
|
98
|
+
}
|
|
99
|
+
default:
|
|
100
|
+
return `${name}: ${issue.message}`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// The reason a refusal gives, as one clause: each issue in turn, then how many more there are.
|
|
104
|
+
export function refusalReason(issues, input) {
|
|
105
|
+
const named = issues.slice(0, ISSUES_NAMED).map((i) => issueText(i, input));
|
|
106
|
+
const more = issues.length - named.length;
|
|
107
|
+
return `the arguments do not fit this tool's inputSchema (tools/list): ${named.join("; ")}${more > 0 ? `; and ${more} more ${more === 1 ? "issue" : "issues"}` : ""}`;
|
|
108
|
+
}
|
|
109
|
+
// The inputSchema a tool is registered with: `schema` itself for tools/list (its own jsonSchema,
|
|
110
|
+
// so the published JSON is the original's), and a validate that answers what `schema` answers for
|
|
111
|
+
// arguments it accepts, and a Refused marker, not the issues, for arguments it refuses.
|
|
112
|
+
export function lenientSchema(schema) {
|
|
113
|
+
const std = schema["~standard"];
|
|
114
|
+
const settle = (value, r) => (r.issues && r.issues.length ? { value: { [REFUSED]: { issues: r.issues, input: value } } } : r);
|
|
115
|
+
return {
|
|
116
|
+
"~standard": {
|
|
117
|
+
version: std.version,
|
|
118
|
+
vendor: std.vendor,
|
|
119
|
+
jsonSchema: std.jsonSchema,
|
|
120
|
+
validate: (value) => {
|
|
121
|
+
const r = std.validate(value);
|
|
122
|
+
return r instanceof Promise ? r.then((x) => settle(value, x)) : settle(value, r);
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const isStandardSchema = (s) => typeof s === "object" && s !== null && typeof s["~standard"]?.validate === "function";
|
|
128
|
+
/**
|
|
129
|
+
* Makes every tool `server` registers from now on answer arguments that break its inputSchema with
|
|
130
|
+
* `refuse(tool, reason)` (index.ts badInput: the invalid_input envelope), not the SDK's bare error.
|
|
131
|
+
* Call it before the first registerTool. tools/list is unchanged.
|
|
132
|
+
*/
|
|
133
|
+
export function withSchemaRefusal(server, refuse) {
|
|
134
|
+
const register = server.registerTool.bind(server);
|
|
135
|
+
const wrapped = (name, config, cb) => {
|
|
136
|
+
if (!isStandardSchema(config.inputSchema))
|
|
137
|
+
return register(name, config, cb);
|
|
138
|
+
return register(name, { ...config, inputSchema: lenientSchema(config.inputSchema) }, (args, ctx) => {
|
|
139
|
+
if (!isRefused(args))
|
|
140
|
+
return cb(args, ctx);
|
|
141
|
+
const { issues, input } = args[REFUSED];
|
|
142
|
+
return refuse(name, refusalReason(issues, input));
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
server.registerTool = wrapped;
|
|
146
|
+
return server;
|
|
147
|
+
}
|