codex-plugin-doctor 1.75.0 → 1.77.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/README.md +20 -3
- package/dist/core/mcp-prompt-catalog.d.ts +36 -0
- package/dist/core/mcp-prompt-catalog.js +53 -0
- package/dist/core/mcp-prompt-definition.d.ts +24 -0
- package/dist/core/mcp-prompt-definition.js +107 -0
- package/dist/core/mcp-prompt-diff.d.ts +43 -0
- package/dist/core/mcp-prompt-diff.js +132 -0
- package/dist/core/mcp-prompt-file.d.ts +31 -0
- package/dist/core/mcp-prompt-file.js +118 -0
- package/dist/core/mcp-prompt-impact.d.ts +21 -0
- package/dist/core/mcp-prompt-impact.js +93 -0
- package/dist/core/mcp-stdio-tool-catalog.d.ts +9 -3
- package/dist/core/mcp-stdio-tool-catalog.js +74 -55
- package/dist/core/mcp-tool-catalog.d.ts +44 -5
- package/dist/core/mcp-tool-catalog.js +76 -31
- package/dist/core/mcp-tool-diff.d.ts +8 -2
- package/dist/core/mcp-tool-diff.js +7 -5
- package/dist/core/mcp-tool-file.d.ts +20 -0
- package/dist/core/mcp-tool-file.js +1 -1
- package/dist/core/mcp-tool-impact.d.ts +10 -1
- package/dist/core/mcp-tool-impact.js +74 -17
- package/dist/core/mcp-tool-response-file.d.ts +6 -4
- package/dist/core/mcp-tool-response-file.js +8 -8
- package/dist/core/output-contract.js +189 -2
- package/dist/reporting/quote-server-text.d.ts +3 -0
- package/dist/reporting/quote-server-text.js +20 -0
- package/dist/reporting/render-mcp-prompt-catalog-report.d.ts +2 -0
- package/dist/reporting/render-mcp-prompt-catalog-report.js +31 -0
- package/dist/reporting/render-mcp-prompt-diff-markdown.d.ts +2 -0
- package/dist/reporting/render-mcp-prompt-diff-markdown.js +39 -0
- package/dist/reporting/render-mcp-prompt-diff-report.d.ts +2 -0
- package/dist/reporting/render-mcp-prompt-diff-report.js +48 -0
- package/dist/reporting/render-mcp-prompt-file-report.d.ts +5 -0
- package/dist/reporting/render-mcp-prompt-file-report.js +33 -0
- package/dist/reporting/render-mcp-tool-diff-markdown.js +10 -2
- package/dist/reporting/render-mcp-tool-diff-report.js +4 -1
- package/dist/run-cli.d.ts +7 -0
- package/dist/run-cli.js +206 -137
- package/package.json +1 -1
|
@@ -29,18 +29,18 @@ export function hasMatchingEnvelope(message, requestId) {
|
|
|
29
29
|
return message.jsonrpc === "2.0" && message.id === requestId
|
|
30
30
|
&& Object.hasOwn(message, "result") !== Object.hasOwn(message, "error");
|
|
31
31
|
}
|
|
32
|
-
export function
|
|
32
|
+
export function parseListResult(message, requestId, resultKey) {
|
|
33
33
|
if (!hasMatchingEnvelope(message, requestId)) {
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
|
-
if (!Object.hasOwn(message, "result") || !isPlainObject(message.result) || !validCacheMetadata(message.result) || !Array.isArray(message.result
|
|
36
|
+
if (!Object.hasOwn(message, "result") || !isPlainObject(message.result) || !validCacheMetadata(message.result) || !Array.isArray(message.result[resultKey])) {
|
|
37
37
|
return null;
|
|
38
38
|
}
|
|
39
39
|
if (Object.hasOwn(message.result, "nextCursor") && typeof message.result.nextCursor !== "string") {
|
|
40
40
|
return null;
|
|
41
41
|
}
|
|
42
42
|
return {
|
|
43
|
-
|
|
43
|
+
items: message.result[resultKey],
|
|
44
44
|
cursor: message.result.nextCursor,
|
|
45
45
|
cache: { ttlMs: message.result.ttlMs, cacheScope: message.result.cacheScope }
|
|
46
46
|
};
|
|
@@ -70,6 +70,18 @@ export function finalCatalogStatus(findings) {
|
|
|
70
70
|
? "warn"
|
|
71
71
|
: "pass";
|
|
72
72
|
}
|
|
73
|
+
export function listMethodUnsupported(list, server) {
|
|
74
|
+
return catalogFinding(`plugin.catalog.${list.capability}_list.method_unsupported`, "warn", `The ${server} advertised ${list.capability} but does not implement ${list.method}.`, `${list.noun === "tool" ? "Tool" : "Prompt"} definitions cannot be enumerated from the advertised capability.`, `Implement ${list.method} for the advertised ${server === "endpoint" ? "modern " : ""}MCP ${list.capability} capability.`);
|
|
75
|
+
}
|
|
76
|
+
export function modernInvalidFix(list) {
|
|
77
|
+
return `Return a matching JSON-RPC complete result with valid ${list.capability} and cache metadata.`;
|
|
78
|
+
}
|
|
79
|
+
export function listResponseInvalid(list, server, suggestedFix) {
|
|
80
|
+
return catalogFinding("plugin.catalog.response.invalid", "fail", `The ${server} returned an invalid ${list.method} response.`, `The ${list.noun} catalog cannot be safely enumerated from this response.`, suggestedFix);
|
|
81
|
+
}
|
|
82
|
+
export function listCursorLoop(list, server) {
|
|
83
|
+
return catalogFinding("plugin.catalog.cursor.loop", "warn", `The ${server} repeated a ${list.method} cursor.`, "The bounded catalog enumeration cannot determine a complete result set.", "Return each continuation cursor at most once for a single enumeration.");
|
|
84
|
+
}
|
|
73
85
|
function isAuthorizationFinding(discovery) {
|
|
74
86
|
return discovery.findings.some((item) => item.id === "plugin.discovery.authorization.required" || item.id === "plugin.discovery.authorization.rejected");
|
|
75
87
|
}
|
|
@@ -124,11 +136,36 @@ export function createMcpToolCatalogInspector() {
|
|
|
124
136
|
}
|
|
125
137
|
};
|
|
126
138
|
}
|
|
127
|
-
export
|
|
139
|
+
export const MCP_TOOLS_LIST = {
|
|
140
|
+
method: "tools/list",
|
|
141
|
+
capability: "tools",
|
|
142
|
+
noun: "tool",
|
|
143
|
+
createInspector() {
|
|
144
|
+
const inspector = createMcpToolCatalogInspector();
|
|
145
|
+
return {
|
|
146
|
+
findings: inspector.findings,
|
|
147
|
+
items: inspector.tools,
|
|
148
|
+
get checked() {
|
|
149
|
+
return inspector.toolsChecked;
|
|
150
|
+
},
|
|
151
|
+
addFindings: inspector.addFindings,
|
|
152
|
+
inspectPage: inspector.inspectPage
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
function toolCatalogSnapshot(capture) {
|
|
157
|
+
const { complete, pagesRead, checked, reason } = capture.catalog;
|
|
158
|
+
return {
|
|
159
|
+
report: buildMcpToolCatalogReport(capture.discovery, capture.status, { complete, pagesRead, toolsChecked: checked, reason }, capture.enumeration, capture.findings),
|
|
160
|
+
tools: capture.items,
|
|
161
|
+
cache: capture.cache
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export function serializeListRequest(method, id, cursor) {
|
|
128
165
|
return JSON.stringify({
|
|
129
166
|
jsonrpc: "2.0",
|
|
130
167
|
id,
|
|
131
|
-
method
|
|
168
|
+
method,
|
|
132
169
|
params: {
|
|
133
170
|
...(cursor === undefined ? {} : { cursor }),
|
|
134
171
|
_meta: {
|
|
@@ -144,6 +181,10 @@ export async function inspectMcpToolCatalog(rawUrl, options = {}) {
|
|
|
144
181
|
}
|
|
145
182
|
// Tools and cache metadata are returned only for a complete enumeration.
|
|
146
183
|
export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
184
|
+
return toolCatalogSnapshot(await captureMcpCatalog(rawUrl, MCP_TOOLS_LIST, options));
|
|
185
|
+
}
|
|
186
|
+
// Definitions and cache metadata are returned only for a complete enumeration.
|
|
187
|
+
export async function captureMcpCatalog(rawUrl, list, options = {}) {
|
|
147
188
|
const request = options.request ?? requestBoundedHttp;
|
|
148
189
|
const now = options.now ?? (() => performance.now());
|
|
149
190
|
const deadline = now() + TOTAL_TIMEOUT_MS;
|
|
@@ -186,31 +227,31 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
186
227
|
return response;
|
|
187
228
|
}
|
|
188
229
|
});
|
|
189
|
-
const partial = (
|
|
190
|
-
const emptyCatalog = { complete: false, pagesRead: 0,
|
|
230
|
+
const partial = (status, catalog, enumeration, findings) => ({ discovery, status, catalog, enumeration, findings, items: null, cache: null });
|
|
231
|
+
const emptyCatalog = { complete: false, pagesRead: 0, checked: 0, reason: null };
|
|
191
232
|
if (discovery.status === "blocked") {
|
|
192
233
|
const reason = discovery.findings.some((item) => item.id === "plugin.discovery.credentials.insecure_transport")
|
|
193
234
|
? "credentials-insecure-transport"
|
|
194
235
|
: "network-blocked";
|
|
195
|
-
return partial(
|
|
236
|
+
return partial("blocked", { ...emptyCatalog, reason }, "not-tested", []);
|
|
196
237
|
}
|
|
197
238
|
if (isAuthorizationFinding(discovery)) {
|
|
198
|
-
return partial(
|
|
239
|
+
return partial("incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []);
|
|
199
240
|
}
|
|
200
241
|
if (discovery.status === "unsupported") {
|
|
201
|
-
return partial(
|
|
242
|
+
return partial("unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []);
|
|
202
243
|
}
|
|
203
244
|
if (discovery.status !== "discovered") {
|
|
204
|
-
return partial(
|
|
245
|
+
return partial("incomplete", { ...emptyCatalog, reason: budgetReason ?? "discovery-failed" }, "not-tested", []);
|
|
205
246
|
}
|
|
206
247
|
const capabilities = discoveryResponse === null ? null : discoverMcpCapabilities(discoveryResponse);
|
|
207
248
|
if (capabilities === null) {
|
|
208
|
-
return partial(
|
|
249
|
+
return partial("incomplete", { ...emptyCatalog, reason: "discovery-response-unavailable" }, "not-tested", []);
|
|
209
250
|
}
|
|
210
|
-
if (!Object.hasOwn(capabilities,
|
|
211
|
-
return partial(
|
|
251
|
+
if (!Object.hasOwn(capabilities, list.capability)) {
|
|
252
|
+
return partial("not-applicable", emptyCatalog, "not-tested", []);
|
|
212
253
|
}
|
|
213
|
-
const inspector =
|
|
254
|
+
const inspector = list.createInspector();
|
|
214
255
|
const findings = inspector.findings;
|
|
215
256
|
const seenCursors = new Set();
|
|
216
257
|
let pagesRead = 0;
|
|
@@ -233,7 +274,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
233
274
|
incompleteReason = "total-time-limit";
|
|
234
275
|
break;
|
|
235
276
|
}
|
|
236
|
-
const requestId =
|
|
277
|
+
const requestId = `${list.capability}-list-${pagesRead + 1}`;
|
|
237
278
|
let response;
|
|
238
279
|
try {
|
|
239
280
|
response = await requestWithinBudget(rawUrl, {
|
|
@@ -241,12 +282,12 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
241
282
|
lookup: options.lookup,
|
|
242
283
|
bearerToken: options.bearerToken,
|
|
243
284
|
method: "POST",
|
|
244
|
-
body:
|
|
285
|
+
body: serializeListRequest(list.method, requestId, cursor),
|
|
245
286
|
headers: {
|
|
246
287
|
Accept: "application/json, text/event-stream",
|
|
247
288
|
"Content-Type": "application/json",
|
|
248
289
|
"MCP-Protocol-Version": REQUESTED_VERSION,
|
|
249
|
-
"Mcp-Method":
|
|
290
|
+
"Mcp-Method": list.method
|
|
250
291
|
},
|
|
251
292
|
stopAfter: (received) => findMcpSseResponse(received, requestId) !== null
|
|
252
293
|
});
|
|
@@ -273,18 +314,18 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
273
314
|
const message = parseMcpResponse(response, requestId);
|
|
274
315
|
if (message !== null && hasMatchingEnvelope(message, requestId) && Object.hasOwn(message, "error")
|
|
275
316
|
&& validJsonRpcError(message.error) && message.error.code === -32601 && (response.statusCode === 200 || response.statusCode === 404)) {
|
|
276
|
-
if (addFindings([
|
|
277
|
-
incompleteReason =
|
|
317
|
+
if (addFindings([listMethodUnsupported(list, "endpoint")]))
|
|
318
|
+
incompleteReason = `${list.capability}-list-method-unsupported`;
|
|
278
319
|
break;
|
|
279
320
|
}
|
|
280
321
|
if (response.statusCode !== 200) {
|
|
281
|
-
if (addFindings([catalogFinding("plugin.catalog.http_status.invalid", "fail",
|
|
322
|
+
if (addFindings([catalogFinding("plugin.catalog.http_status.invalid", "fail", `The endpoint returned an unexpected ${list.method} HTTP status.`, `The ${list.noun} catalog cannot be safely enumerated from this response.`, `Return HTTP 200 for a ${list.method} response after authorization succeeds.`)]))
|
|
282
323
|
incompleteReason = "http-status-invalid";
|
|
283
324
|
break;
|
|
284
325
|
}
|
|
285
|
-
const page = message === null ? null :
|
|
326
|
+
const page = message === null ? null : parseListResult(message, requestId, list.capability);
|
|
286
327
|
if (page === null) {
|
|
287
|
-
if (addFindings([
|
|
328
|
+
if (addFindings([listResponseInvalid(list, "endpoint", modernInvalidFix(list))]))
|
|
288
329
|
incompleteReason = "response-invalid";
|
|
289
330
|
break;
|
|
290
331
|
}
|
|
@@ -293,32 +334,36 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
293
334
|
ttlMs: Math.min(cache.ttlMs, page.cache.ttlMs),
|
|
294
335
|
cacheScope: cache.cacheScope === "private" ? "private" : page.cache.cacheScope
|
|
295
336
|
};
|
|
296
|
-
incompleteReason = inspector.inspectPage(page.
|
|
337
|
+
incompleteReason = inspector.inspectPage(page.items, pagesRead, () => deadline - now() <= 0);
|
|
297
338
|
if (incompleteReason !== null)
|
|
298
339
|
break;
|
|
299
340
|
if (page.cursor === undefined)
|
|
300
341
|
break;
|
|
301
342
|
if (seenCursors.has(page.cursor)) {
|
|
302
|
-
if (addFindings([
|
|
343
|
+
if (addFindings([listCursorLoop(list, "endpoint")]))
|
|
303
344
|
incompleteReason = "cursor-loop";
|
|
304
345
|
break;
|
|
305
346
|
}
|
|
306
347
|
seenCursors.add(page.cursor);
|
|
307
348
|
cursor = page.cursor;
|
|
308
349
|
}
|
|
309
|
-
const
|
|
350
|
+
const checked = inspector.checked;
|
|
310
351
|
if (blocked) {
|
|
311
|
-
return partial(
|
|
352
|
+
return partial("blocked", { complete: false, pagesRead, checked, reason: "network-blocked" }, "incomplete", findings);
|
|
312
353
|
}
|
|
313
354
|
if (incompleteReason !== null) {
|
|
314
|
-
return partial(
|
|
355
|
+
return partial("incomplete", { complete: false, pagesRead, checked, reason: incompleteReason }, "incomplete", findings);
|
|
315
356
|
}
|
|
316
357
|
if (deadline - now() <= 0) {
|
|
317
|
-
return partial(
|
|
358
|
+
return partial("incomplete", { complete: false, pagesRead, checked, reason: "total-time-limit" }, "incomplete", findings);
|
|
318
359
|
}
|
|
319
360
|
return {
|
|
320
|
-
|
|
321
|
-
|
|
361
|
+
discovery,
|
|
362
|
+
status: finalCatalogStatus(findings),
|
|
363
|
+
catalog: { complete: true, pagesRead, checked, reason: null },
|
|
364
|
+
enumeration: "complete",
|
|
365
|
+
findings,
|
|
366
|
+
items: inspector.items,
|
|
322
367
|
cache
|
|
323
368
|
};
|
|
324
369
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ToolChangeImpact, type ToolChangeReason } from "./mcp-tool-impact.js";
|
|
1
|
+
import { type ToolChangeImpact, type ToolChangeLocation, type ToolChangeReason } from "./mcp-tool-impact.js";
|
|
2
2
|
import { type McpToolFileReport } from "./mcp-tool-file.js";
|
|
3
3
|
type DiffField = "inputSchema" | "outputSchema" | "description" | "title" | "annotations" | "other";
|
|
4
4
|
export interface McpToolDiffReport {
|
|
@@ -32,7 +32,13 @@ export interface McpToolDiffReport {
|
|
|
32
32
|
fields: DiffField[];
|
|
33
33
|
impact: ToolChangeImpact;
|
|
34
34
|
reasons: ToolChangeReason[];
|
|
35
|
+
name?: string;
|
|
36
|
+
locations?: ToolChangeLocation[];
|
|
35
37
|
}>;
|
|
36
38
|
}
|
|
37
|
-
export
|
|
39
|
+
export interface McpToolDiffOptions {
|
|
40
|
+
showNames?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function structurallyEqual(left: unknown, right: unknown): boolean;
|
|
43
|
+
export declare function compareMcpToolFiles(beforePath: string, afterPath: string, options?: McpToolDiffOptions): Promise<McpToolDiffReport>;
|
|
38
44
|
export {};
|
|
@@ -11,7 +11,7 @@ const EXCLUDED_OTHER_FIELDS = new Set(["name", ...FIXED_FIELDS]);
|
|
|
11
11
|
function isJsonObject(value) {
|
|
12
12
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
13
|
}
|
|
14
|
-
function structurallyEqual(left, right) {
|
|
14
|
+
export function structurallyEqual(left, right) {
|
|
15
15
|
const pending = [[left, right]];
|
|
16
16
|
while (pending.length > 0) {
|
|
17
17
|
const [currentLeft, currentRight] = pending.pop();
|
|
@@ -85,7 +85,7 @@ function incompleteReport(before, after, status, reason) {
|
|
|
85
85
|
changes: []
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
-
export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
88
|
+
export async function compareMcpToolFiles(beforePath, afterPath, options = {}) {
|
|
89
89
|
const [beforeSnapshot, afterSnapshot] = await Promise.all([
|
|
90
90
|
loadMcpToolFileSnapshot(beforePath),
|
|
91
91
|
loadMcpToolFileSnapshot(afterPath)
|
|
@@ -106,6 +106,7 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
|
106
106
|
if (beforeDefinitions === null || afterDefinitions === null) {
|
|
107
107
|
return incompleteReport(before, after, "incomplete", "ambiguous-names");
|
|
108
108
|
}
|
|
109
|
+
const named = (name) => (options.showNames === true ? { name } : {});
|
|
109
110
|
const changes = [];
|
|
110
111
|
let added = 0;
|
|
111
112
|
let removed = 0;
|
|
@@ -115,7 +116,7 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
|
115
116
|
const afterDefinition = afterDefinitions.get(name);
|
|
116
117
|
if (afterDefinition === undefined) {
|
|
117
118
|
removed += 1;
|
|
118
|
-
changes.push({ kind: "removed", beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [], ...classifyRemovedTool() });
|
|
119
|
+
changes.push({ kind: "removed", ...named(name), beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [], ...classifyRemovedTool() });
|
|
119
120
|
continue;
|
|
120
121
|
}
|
|
121
122
|
const fields = changedFields(beforeDefinition.value, afterDefinition.value);
|
|
@@ -126,17 +127,18 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
|
126
127
|
changed += 1;
|
|
127
128
|
changes.push({
|
|
128
129
|
kind: "changed",
|
|
130
|
+
...named(name),
|
|
129
131
|
beforeToolIndex: beforeDefinition.index,
|
|
130
132
|
afterToolIndex: afterDefinition.index,
|
|
131
133
|
fields,
|
|
132
|
-
...classifyChangedTool(beforeDefinition.value, afterDefinition.value, fields, structurallyEqual)
|
|
134
|
+
...classifyChangedTool(beforeDefinition.value, afterDefinition.value, fields, structurallyEqual, { locate: options.showNames === true })
|
|
133
135
|
});
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
for (const [name, afterDefinition] of afterDefinitions) {
|
|
137
139
|
if (!beforeDefinitions.has(name)) {
|
|
138
140
|
added += 1;
|
|
139
|
-
changes.push({ kind: "added", beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [], ...classifyAddedTool() });
|
|
141
|
+
changes.push({ kind: "added", ...named(name), beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [], ...classifyAddedTool() });
|
|
140
142
|
}
|
|
141
143
|
}
|
|
142
144
|
return {
|
|
@@ -30,5 +30,25 @@ export interface McpToolFileSnapshot {
|
|
|
30
30
|
tools: unknown[] | null;
|
|
31
31
|
}
|
|
32
32
|
export declare function isBlockedOfflinePath(filePath: string): boolean;
|
|
33
|
+
export declare function readBoundedRegularFile(filePath: string): Promise<{
|
|
34
|
+
kind: "content";
|
|
35
|
+
content: Buffer;
|
|
36
|
+
bytesRead: number;
|
|
37
|
+
} | {
|
|
38
|
+
kind: "unavailable";
|
|
39
|
+
bytesRead: number;
|
|
40
|
+
} | {
|
|
41
|
+
kind: "too-large";
|
|
42
|
+
bytesRead: number;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "changed";
|
|
45
|
+
bytesRead: number;
|
|
46
|
+
} | {
|
|
47
|
+
kind: "symlink";
|
|
48
|
+
bytesRead: number;
|
|
49
|
+
} | {
|
|
50
|
+
kind: "not-regular";
|
|
51
|
+
bytesRead: number;
|
|
52
|
+
}>;
|
|
33
53
|
export declare function loadMcpToolFileSnapshot(filePath: string): Promise<McpToolFileSnapshot>;
|
|
34
54
|
export declare function inspectMcpToolFile(filePath: string): Promise<McpToolFileReport>;
|
|
@@ -90,7 +90,7 @@ function parseToolsResult(value) {
|
|
|
90
90
|
return null;
|
|
91
91
|
return { tools: value.result.tools, hasNextCursor };
|
|
92
92
|
}
|
|
93
|
-
async function readBoundedRegularFile(filePath) {
|
|
93
|
+
export async function readBoundedRegularFile(filePath) {
|
|
94
94
|
let initial;
|
|
95
95
|
try {
|
|
96
96
|
initial = await lstat(filePath);
|
|
@@ -2,12 +2,21 @@ type JsonObject = Record<string, unknown>;
|
|
|
2
2
|
export type ToolChangeImpact = "breaking" | "compatible" | "unclassified";
|
|
3
3
|
export declare const TOOL_CHANGE_REASONS: readonly ["tool-removed", "input-type-narrowed", "input-required-added", "input-property-removed", "input-property-type-narrowed", "input-property-enum-narrowed", "input-constraint-tightened", "input-additional-properties-closed", "input-schema-unclassified", "output-schema-removed", "output-type-widened", "output-property-removed", "output-guarantee-removed", "output-property-type-widened", "output-property-enum-widened", "output-constraint-loosened", "output-schema-unclassified", "annotation-safety-reduced", "other-fields-unclassified"];
|
|
4
4
|
export type ToolChangeReason = (typeof TOOL_CHANGE_REASONS)[number];
|
|
5
|
+
export interface ToolChangeLocation {
|
|
6
|
+
reason: ToolChangeReason;
|
|
7
|
+
pointer: string;
|
|
8
|
+
}
|
|
5
9
|
export interface ToolChangeClassification {
|
|
6
10
|
impact: ToolChangeImpact;
|
|
7
11
|
reasons: ToolChangeReason[];
|
|
12
|
+
locations?: ToolChangeLocation[];
|
|
8
13
|
}
|
|
14
|
+
export declare const MAX_TOOL_CHANGE_LOCATIONS = 100;
|
|
15
|
+
export declare const MAX_TOOL_CHANGE_POINTER_LENGTH = 1000;
|
|
9
16
|
type Equal = (left: unknown, right: unknown) => boolean;
|
|
10
17
|
export declare function classifyRemovedTool(): ToolChangeClassification;
|
|
11
18
|
export declare function classifyAddedTool(): ToolChangeClassification;
|
|
12
|
-
export declare function classifyChangedTool(before: JsonObject, after: JsonObject, fields: readonly string[], equal: Equal
|
|
19
|
+
export declare function classifyChangedTool(before: JsonObject, after: JsonObject, fields: readonly string[], equal: Equal, options?: {
|
|
20
|
+
locate?: boolean;
|
|
21
|
+
}): ToolChangeClassification;
|
|
13
22
|
export {};
|
|
@@ -19,6 +19,59 @@ export const TOOL_CHANGE_REASONS = [
|
|
|
19
19
|
"annotation-safety-reduced",
|
|
20
20
|
"other-fields-unclassified"
|
|
21
21
|
];
|
|
22
|
+
export const MAX_TOOL_CHANGE_LOCATIONS = 100;
|
|
23
|
+
// Longer pointers are not recorded, so a long property name cannot make locating costly or the report large.
|
|
24
|
+
export const MAX_TOOL_CHANGE_POINTER_LENGTH = 1_000;
|
|
25
|
+
/**
|
|
26
|
+
* A reason set that also records, for each reason, the JSON Pointer of the schema position being
|
|
27
|
+
* compared when it was added. A shared definition is compared once, so it is located at the first
|
|
28
|
+
* reference that reached it.
|
|
29
|
+
*/
|
|
30
|
+
class LocatingReasons extends Set {
|
|
31
|
+
locations = [];
|
|
32
|
+
// Escaped pointer segments of the position being compared, and the pointer length at each one;
|
|
33
|
+
// a segment that would pass the length limit is left empty, with an infinite length.
|
|
34
|
+
segments = [];
|
|
35
|
+
lengths = [0];
|
|
36
|
+
seen = new Set();
|
|
37
|
+
enter(tokens) {
|
|
38
|
+
for (const token of tokens) {
|
|
39
|
+
const previous = this.lengths[this.lengths.length - 1];
|
|
40
|
+
const segment = previous + 1 + token.length > MAX_TOOL_CHANGE_POINTER_LENGTH ? "" : `/${token.replaceAll("~", "~0").replaceAll("/", "~1")}`;
|
|
41
|
+
this.segments.push(segment);
|
|
42
|
+
this.lengths.push(segment === "" || previous + segment.length > MAX_TOOL_CHANGE_POINTER_LENGTH ? Infinity : previous + segment.length);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
leave(count) {
|
|
46
|
+
this.segments.length -= count;
|
|
47
|
+
this.lengths.length -= count;
|
|
48
|
+
}
|
|
49
|
+
add(reason) {
|
|
50
|
+
if (this.locations.length < MAX_TOOL_CHANGE_LOCATIONS && this.lengths[this.lengths.length - 1] !== Infinity) {
|
|
51
|
+
const pointer = this.segments.join("");
|
|
52
|
+
const key = `${reason} ${pointer}`;
|
|
53
|
+
if (!this.seen.has(key)) {
|
|
54
|
+
this.seen.add(key);
|
|
55
|
+
this.locations.push({ reason, pointer });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return super.add(reason);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Runs `compare` with `tokens` appended to the recorded location, when locations are recorded. */
|
|
62
|
+
function within(reasons, tokens, compare) {
|
|
63
|
+
if (!(reasons instanceof LocatingReasons)) {
|
|
64
|
+
compare();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
reasons.enter(tokens);
|
|
68
|
+
try {
|
|
69
|
+
compare();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
reasons.leave(tokens.length);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
22
75
|
const UNCLASSIFIED_REASONS = new Set([
|
|
23
76
|
"input-schema-unclassified",
|
|
24
77
|
"output-schema-unclassified",
|
|
@@ -752,12 +805,14 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
|
|
|
752
805
|
compareNumericAndArrayConstraints(inner, outer, codes, reasons);
|
|
753
806
|
compareStringConstraints(inner, outer, codes, reasons);
|
|
754
807
|
if (!sameMeaning(before.items, after.items, equal, scope)) {
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
808
|
+
within(reasons, ["items"], () => {
|
|
809
|
+
if (unevaluated)
|
|
810
|
+
reasons.add(codes.unclassified);
|
|
811
|
+
else if (outer.items !== undefined && inner.items === undefined)
|
|
812
|
+
reasons.add(codes.constraint);
|
|
813
|
+
else if (outer.items !== undefined)
|
|
814
|
+
compareSchema(before.items, after.items, direction, depth + 1, equal, reasons, scope);
|
|
815
|
+
});
|
|
761
816
|
}
|
|
762
817
|
}
|
|
763
818
|
const innerRequired = stringSet(inner.required);
|
|
@@ -770,13 +825,15 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
|
|
|
770
825
|
}
|
|
771
826
|
for (const key of outerRequired) {
|
|
772
827
|
if (!innerRequired.has(key))
|
|
773
|
-
reasons.add(codes.required);
|
|
828
|
+
within(reasons, ["properties", key], () => reasons.add(codes.required));
|
|
774
829
|
}
|
|
775
830
|
for (const key of Object.keys(beforeProperties)) {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
831
|
+
within(reasons, ["properties", key], () => {
|
|
832
|
+
if (!Object.hasOwn(afterProperties, key))
|
|
833
|
+
reasons.add(codes.propertyRemoved);
|
|
834
|
+
else
|
|
835
|
+
compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons, scope);
|
|
836
|
+
});
|
|
780
837
|
}
|
|
781
838
|
// In an object whose undeclared keys were governed by other keywords, or a nested input object
|
|
782
839
|
// that left them open, a newly declared property can reject values callers send (input) or
|
|
@@ -834,7 +891,7 @@ function result(reasons) {
|
|
|
834
891
|
const impact = ordered.some((reason) => !UNCLASSIFIED_REASONS.has(reason))
|
|
835
892
|
? "breaking"
|
|
836
893
|
: ordered.length > 0 ? "unclassified" : "compatible";
|
|
837
|
-
return { impact, reasons: ordered };
|
|
894
|
+
return reasons instanceof LocatingReasons ? { impact, reasons: ordered, locations: reasons.locations } : { impact, reasons: ordered };
|
|
838
895
|
}
|
|
839
896
|
export function classifyRemovedTool() {
|
|
840
897
|
return { impact: "breaking", reasons: ["tool-removed"] };
|
|
@@ -842,15 +899,15 @@ export function classifyRemovedTool() {
|
|
|
842
899
|
export function classifyAddedTool() {
|
|
843
900
|
return { impact: "compatible", reasons: [] };
|
|
844
901
|
}
|
|
845
|
-
export function classifyChangedTool(before, after, fields, equal) {
|
|
846
|
-
const reasons = new Set();
|
|
902
|
+
export function classifyChangedTool(before, after, fields, equal, options = {}) {
|
|
903
|
+
const reasons = options.locate ? new LocatingReasons() : new Set();
|
|
847
904
|
if (fields.includes("inputSchema")) {
|
|
848
|
-
compareSchema(before.inputSchema, after.inputSchema, "input", 0, equal, reasons, referenceScope(before.inputSchema, after.inputSchema));
|
|
905
|
+
within(reasons, ["inputSchema"], () => compareSchema(before.inputSchema, after.inputSchema, "input", 0, equal, reasons, referenceScope(before.inputSchema, after.inputSchema)));
|
|
849
906
|
}
|
|
850
907
|
if (fields.includes("outputSchema"))
|
|
851
|
-
classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons);
|
|
908
|
+
within(reasons, ["outputSchema"], () => classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons));
|
|
852
909
|
if (fields.includes("annotations"))
|
|
853
|
-
classifyAnnotations(before.annotations, after.annotations, reasons);
|
|
910
|
+
within(reasons, ["annotations"], () => classifyAnnotations(before.annotations, after.annotations, reasons));
|
|
854
911
|
if (fields.includes("other"))
|
|
855
912
|
reasons.add("other-fields-unclassified");
|
|
856
913
|
return result(reasons);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { McpToolCatalogCache } from "./mcp-tool-catalog.js";
|
|
2
|
-
export type
|
|
2
|
+
export type McpListResponseSaveResult = {
|
|
3
3
|
kind: "saved";
|
|
4
4
|
bytes: number;
|
|
5
|
-
|
|
5
|
+
count: number;
|
|
6
6
|
} | {
|
|
7
7
|
kind: "path-blocked" | "not-regular" | "too-large" | "write-failed";
|
|
8
8
|
};
|
|
9
|
-
|
|
10
|
-
export declare function
|
|
9
|
+
type ListKey = "tools" | "prompts";
|
|
10
|
+
export declare function serializeMcpListResponse(key: ListKey, items: unknown[], cache: McpToolCatalogCache): string;
|
|
11
|
+
export declare function saveMcpListResponse(filePath: string, key: ListKey, items: unknown[], cache: McpToolCatalogCache): Promise<McpListResponseSaveResult>;
|
|
12
|
+
export {};
|
|
@@ -2,21 +2,21 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { chmod, lstat, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { isBlockedOfflinePath, MAX_TOOL_FILE_BYTES } from "./mcp-tool-file.js";
|
|
5
|
-
// Serializes an enumerated catalog as one complete tools/list response that
|
|
6
|
-
export function
|
|
5
|
+
// Serializes an enumerated catalog as one complete tools/list or prompts/list response that the offline file and diff commands accept.
|
|
6
|
+
export function serializeMcpListResponse(key, items, cache) {
|
|
7
7
|
return `${JSON.stringify({
|
|
8
8
|
jsonrpc: "2.0",
|
|
9
|
-
id:
|
|
10
|
-
result: { resultType: "complete",
|
|
9
|
+
id: `${key}-list`,
|
|
10
|
+
result: { resultType: "complete", [key]: items, ttlMs: cache.ttlMs, cacheScope: cache.cacheScope }
|
|
11
11
|
}, null, 2)}\n`;
|
|
12
12
|
}
|
|
13
|
-
export async function
|
|
13
|
+
export async function saveMcpListResponse(filePath, key, items, cache) {
|
|
14
14
|
if (isBlockedOfflinePath(filePath))
|
|
15
15
|
return { kind: "path-blocked" };
|
|
16
16
|
let content;
|
|
17
17
|
let bytes;
|
|
18
18
|
try {
|
|
19
|
-
content =
|
|
19
|
+
content = serializeMcpListResponse(key, items, cache);
|
|
20
20
|
bytes = Buffer.byteLength(content);
|
|
21
21
|
}
|
|
22
22
|
catch {
|
|
@@ -38,7 +38,7 @@ export async function saveMcpToolsListResponse(filePath, tools, cache) {
|
|
|
38
38
|
// Write beside the target and rename so a failed write never leaves a partial response file.
|
|
39
39
|
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
40
40
|
try {
|
|
41
|
-
// Create with the replaced baseline's permission bits, which may protect private
|
|
41
|
+
// Create with the replaced baseline's permission bits, which may protect private definitions.
|
|
42
42
|
await writeFile(tempPath, content, { encoding: "utf8", flag: "wx", mode: existingMode ?? 0o666 });
|
|
43
43
|
if (existingMode !== null)
|
|
44
44
|
await chmod(tempPath, existingMode);
|
|
@@ -48,5 +48,5 @@ export async function saveMcpToolsListResponse(filePath, tools, cache) {
|
|
|
48
48
|
await rm(tempPath, { force: true }).catch(() => undefined);
|
|
49
49
|
return { kind: "write-failed" };
|
|
50
50
|
}
|
|
51
|
-
return { kind: "saved", bytes,
|
|
51
|
+
return { kind: "saved", bytes, count: items.length };
|
|
52
52
|
}
|