codex-plugin-doctor 1.76.0 → 1.78.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 +21 -2
- package/dist/core/init-ci.d.ts +1 -0
- package/dist/core/init-ci.js +16 -2
- 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 +1 -0
- package/dist/core/mcp-tool-diff.js +1 -1
- package/dist/core/mcp-tool-file.d.ts +20 -0
- package/dist/core/mcp-tool-file.js +1 -1
- 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 +177 -0
- package/dist/reporting/quote-server-text.d.ts +1 -0
- package/dist/reporting/quote-server-text.js +13 -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 +3 -16
- package/dist/run-cli.d.ts +7 -0
- package/dist/run-cli.js +223 -150
- 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
|
}
|
|
@@ -39,5 +39,6 @@ export interface McpToolDiffReport {
|
|
|
39
39
|
export interface McpToolDiffOptions {
|
|
40
40
|
showNames?: boolean;
|
|
41
41
|
}
|
|
42
|
+
export declare function structurallyEqual(left: unknown, right: unknown): boolean;
|
|
42
43
|
export declare function compareMcpToolFiles(beforePath: string, afterPath: string, options?: McpToolDiffOptions): Promise<McpToolDiffReport>;
|
|
43
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();
|
|
@@ -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);
|
|
@@ -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
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { packageVersion } from "../version.js";
|
|
3
|
+
import { MAX_PROMPT_CHANGE_LOCATIONS, PROMPT_CHANGE_REASONS } from "./mcp-prompt-impact.js";
|
|
3
4
|
import { MAX_TOOL_CHANGE_LOCATIONS, MAX_TOOL_CHANGE_POINTER_LENGTH, TOOL_CHANGE_REASONS } from "./mcp-tool-impact.js";
|
|
4
5
|
import { ruleCatalog } from "../rules/rule-catalog.js";
|
|
5
6
|
const runtimeCapabilityStatusSchema = {
|
|
@@ -852,6 +853,182 @@ publicSchemaDefinitions.push({
|
|
|
852
853
|
}
|
|
853
854
|
}
|
|
854
855
|
});
|
|
856
|
+
// The prompt catalog shares discovery, protocol, and execution evidence with the tool catalog.
|
|
857
|
+
const toolCatalogProperties = publicSchemaDefinitions.find((definition) => definition.id === "doctor.tools.json").properties;
|
|
858
|
+
publicSchemaDefinitions.push({
|
|
859
|
+
id: "doctor.prompts.json",
|
|
860
|
+
command: "codex-plugin-doctor doctor prompts <url> --allow-network --json",
|
|
861
|
+
required: ["schemaVersion", "scope", "status", "discovery", "catalog", "coverage", "findings"],
|
|
862
|
+
properties: {
|
|
863
|
+
schemaVersion: { const: 1 },
|
|
864
|
+
scope: { const: "prompt-catalog-structure" },
|
|
865
|
+
// transport, protocol, and execution appear only on reports from `doctor prompts <path> --server <name> --runtime`.
|
|
866
|
+
transport: { const: "stdio" },
|
|
867
|
+
status: toolCatalogProperties.status,
|
|
868
|
+
protocol: toolCatalogProperties.protocol,
|
|
869
|
+
execution: toolCatalogProperties.execution,
|
|
870
|
+
discovery: toolCatalogProperties.discovery,
|
|
871
|
+
catalog: {
|
|
872
|
+
type: "object", required: ["complete", "pagesRead", "promptsChecked", "reason"],
|
|
873
|
+
properties: {
|
|
874
|
+
complete: { type: "boolean" },
|
|
875
|
+
pagesRead: { type: "integer", minimum: 0, maximum: 5 },
|
|
876
|
+
promptsChecked: { type: "integer", minimum: 0, maximum: 500 },
|
|
877
|
+
reason: { type: ["string", "null"] }
|
|
878
|
+
},
|
|
879
|
+
additionalProperties: false
|
|
880
|
+
},
|
|
881
|
+
coverage: {
|
|
882
|
+
type: "object", required: ["discovery", "catalogEnumeration", "definitions", "promptRetrieval", "fullConformance"],
|
|
883
|
+
properties: {
|
|
884
|
+
discovery: { enum: ["pass", "fail", "skipped"] },
|
|
885
|
+
catalogEnumeration: { enum: ["complete", "incomplete", "not-tested"] },
|
|
886
|
+
definitions: { const: "shape-only" },
|
|
887
|
+
promptRetrieval: { const: "not-tested" },
|
|
888
|
+
fullConformance: { const: "not-tested" }
|
|
889
|
+
},
|
|
890
|
+
additionalProperties: false
|
|
891
|
+
},
|
|
892
|
+
findings: {
|
|
893
|
+
type: "array", maxItems: 100,
|
|
894
|
+
items: {
|
|
895
|
+
type: "object", required: ["id", "severity", "message", "impact", "suggestedFix"],
|
|
896
|
+
properties: {
|
|
897
|
+
id: { type: "string", pattern: "^plugin\\.catalog\\." },
|
|
898
|
+
severity: { enum: ["warn", "fail"] },
|
|
899
|
+
message: { type: "string" }, impact: { type: "string" }, suggestedFix: { type: "string" },
|
|
900
|
+
location: {
|
|
901
|
+
type: "object", required: ["page", "promptIndex"],
|
|
902
|
+
properties: {
|
|
903
|
+
page: { type: "integer", minimum: 1, maximum: 5 },
|
|
904
|
+
promptIndex: { type: "integer", minimum: 1, maximum: 500 },
|
|
905
|
+
argumentIndex: { type: "integer", minimum: 1 },
|
|
906
|
+
relatedPromptIndex: { type: "integer", minimum: 1, maximum: 500 },
|
|
907
|
+
relatedArgumentIndex: { type: "integer", minimum: 1 }
|
|
908
|
+
},
|
|
909
|
+
additionalProperties: false
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
additionalProperties: false
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
const promptFileReportDefinition = {
|
|
918
|
+
id: "doctor.prompts.file.json",
|
|
919
|
+
command: "codex-plugin-doctor doctor prompts-file <path> --json",
|
|
920
|
+
required: ["schemaVersion", "scope", "status", "source", "inspection", "coverage", "findings"],
|
|
921
|
+
properties: {
|
|
922
|
+
schemaVersion: { const: 1 },
|
|
923
|
+
scope: { const: "prompt-definitions-file" },
|
|
924
|
+
status: { enum: ["pass", "warn", "fail", "incomplete", "blocked"] },
|
|
925
|
+
source: {
|
|
926
|
+
type: "object", required: ["kind", "format", "bytesRead"],
|
|
927
|
+
properties: {
|
|
928
|
+
kind: { const: "file" }, format: { const: "mcp-prompts-list-response" },
|
|
929
|
+
bytesRead: { type: "integer", minimum: 0, maximum: 1048577 }
|
|
930
|
+
}, additionalProperties: false
|
|
931
|
+
},
|
|
932
|
+
inspection: {
|
|
933
|
+
type: "object", required: ["complete", "promptsChecked", "hasNextCursor", "reason"],
|
|
934
|
+
properties: {
|
|
935
|
+
complete: { type: "boolean" }, promptsChecked: { type: "integer", minimum: 0, maximum: 500 },
|
|
936
|
+
hasNextCursor: { type: ["boolean", "null"] }, reason: { type: ["string", "null"] }
|
|
937
|
+
}, additionalProperties: false
|
|
938
|
+
},
|
|
939
|
+
coverage: {
|
|
940
|
+
type: "object", required: ["definitions", "discovery", "serverCatalog", "promptRetrieval", "fullConformance"],
|
|
941
|
+
properties: {
|
|
942
|
+
definitions: { const: "shape-only" }, discovery: { const: "not-tested" },
|
|
943
|
+
serverCatalog: { const: "not-tested" }, promptRetrieval: { const: "not-tested" },
|
|
944
|
+
fullConformance: { const: "not-tested" }
|
|
945
|
+
}, additionalProperties: false
|
|
946
|
+
},
|
|
947
|
+
findings: {
|
|
948
|
+
type: "array", maxItems: 100,
|
|
949
|
+
items: {
|
|
950
|
+
type: "object", required: ["id", "severity", "message", "impact", "suggestedFix"],
|
|
951
|
+
properties: {
|
|
952
|
+
id: { type: "string", pattern: "^plugin\\.(catalog|prompts_file)\\." },
|
|
953
|
+
severity: { enum: ["warn", "fail"] },
|
|
954
|
+
message: { type: "string" }, impact: { type: "string" }, suggestedFix: { type: "string" },
|
|
955
|
+
location: {
|
|
956
|
+
type: "object", required: ["page", "promptIndex"],
|
|
957
|
+
properties: {
|
|
958
|
+
page: { const: 1 }, promptIndex: { type: "integer", minimum: 1, maximum: 500 },
|
|
959
|
+
argumentIndex: { type: "integer", minimum: 1 },
|
|
960
|
+
relatedPromptIndex: { type: "integer", minimum: 1, maximum: 500 },
|
|
961
|
+
relatedArgumentIndex: { type: "integer", minimum: 1 }
|
|
962
|
+
}, additionalProperties: false
|
|
963
|
+
}
|
|
964
|
+
}, additionalProperties: false
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
const embeddedPromptFileReportSchema = {
|
|
970
|
+
type: "object",
|
|
971
|
+
required: promptFileReportDefinition.required,
|
|
972
|
+
properties: promptFileReportDefinition.properties,
|
|
973
|
+
additionalProperties: true
|
|
974
|
+
};
|
|
975
|
+
publicSchemaDefinitions.push(promptFileReportDefinition, {
|
|
976
|
+
id: "doctor.prompts.diff.json",
|
|
977
|
+
command: "codex-plugin-doctor doctor prompts-diff --before <path> --after <path> --json",
|
|
978
|
+
required: ["schemaVersion", "scope", "status", "before", "after", "comparison", "coverage", "changes"],
|
|
979
|
+
properties: {
|
|
980
|
+
schemaVersion: { const: 1 },
|
|
981
|
+
scope: { const: "prompt-definitions-diff" },
|
|
982
|
+
status: { enum: ["pass", "warn", "incomplete", "blocked"] },
|
|
983
|
+
before: embeddedPromptFileReportSchema,
|
|
984
|
+
after: embeddedPromptFileReportSchema,
|
|
985
|
+
comparison: {
|
|
986
|
+
type: "object", required: ["complete", "reason", "added", "removed", "changed", "unchanged", "breaking", "unclassified"],
|
|
987
|
+
properties: {
|
|
988
|
+
complete: { type: "boolean" },
|
|
989
|
+
reason: { enum: [null, "input-blocked", "input-incomplete", "invalid-definitions", "ambiguous-names"] },
|
|
990
|
+
added: { type: ["integer", "null"], minimum: 0, maximum: 500 },
|
|
991
|
+
removed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
|
|
992
|
+
changed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
|
|
993
|
+
unchanged: { type: ["integer", "null"], minimum: 0, maximum: 500 },
|
|
994
|
+
breaking: { type: ["integer", "null"], minimum: 0, maximum: 1000 },
|
|
995
|
+
unclassified: { type: ["integer", "null"], minimum: 0, maximum: 1000 }
|
|
996
|
+
}, additionalProperties: false
|
|
997
|
+
},
|
|
998
|
+
coverage: {
|
|
999
|
+
type: "object", required: ["comparison", "definitions", "compatibility", "serverCatalog", "promptRetrieval", "impactClassification"],
|
|
1000
|
+
properties: {
|
|
1001
|
+
comparison: { const: "structural-only" }, definitions: { const: "shape-only" },
|
|
1002
|
+
compatibility: { const: "not-tested" }, serverCatalog: { const: "not-tested" },
|
|
1003
|
+
promptRetrieval: { const: "not-tested" }, impactClassification: { const: "heuristic" }
|
|
1004
|
+
}, additionalProperties: false
|
|
1005
|
+
},
|
|
1006
|
+
changes: {
|
|
1007
|
+
type: "array", maxItems: 1000,
|
|
1008
|
+
items: {
|
|
1009
|
+
type: "object", required: ["kind", "beforePromptIndex", "afterPromptIndex", "fields", "impact", "reasons"],
|
|
1010
|
+
properties: {
|
|
1011
|
+
kind: { enum: ["added", "removed", "changed"] },
|
|
1012
|
+
beforePromptIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
|
|
1013
|
+
afterPromptIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
|
|
1014
|
+
fields: { type: "array", maxItems: 4, uniqueItems: true, items: { enum: ["arguments", "description", "title", "other"] } },
|
|
1015
|
+
impact: { enum: ["breaking", "compatible", "unclassified"] },
|
|
1016
|
+
reasons: { type: "array", maxItems: PROMPT_CHANGE_REASONS.length, uniqueItems: true, items: { enum: [...PROMPT_CHANGE_REASONS] } },
|
|
1017
|
+
// Only with --show-names.
|
|
1018
|
+
name: { type: "string" },
|
|
1019
|
+
locations: {
|
|
1020
|
+
type: "array", maxItems: MAX_PROMPT_CHANGE_LOCATIONS,
|
|
1021
|
+
items: {
|
|
1022
|
+
type: "object", required: ["reason", "argument"],
|
|
1023
|
+
properties: { reason: { enum: [...PROMPT_CHANGE_REASONS] }, argument: { type: ["string", "null"] } },
|
|
1024
|
+
additionalProperties: false
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}, additionalProperties: false
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
855
1032
|
function sha256(value) {
|
|
856
1033
|
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
857
1034
|
}
|
|
@@ -5,3 +5,16 @@ const UNSAFE_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
|
5
5
|
export function quoteServerText(text) {
|
|
6
6
|
return JSON.stringify(text).replace(UNSAFE_TEXT, (character) => Array.from({ length: character.length }, (_, index) => `\\u${character.charCodeAt(index).toString(16).padStart(4, "0")}`).join(""));
|
|
7
7
|
}
|
|
8
|
+
// Names and pointers come from the server, so they are quoted and put in a code span, where Markdown,
|
|
9
|
+
// HTML, mentions, and links stay inert; the fence outgrows any backtick run, and pipes are escaped for the table.
|
|
10
|
+
export function markdownCodeSpan(text) {
|
|
11
|
+
const quoted = quoteServerText(text);
|
|
12
|
+
let run = 0;
|
|
13
|
+
let longest = 0;
|
|
14
|
+
for (const character of quoted) {
|
|
15
|
+
run = character === "`" ? run + 1 : 0;
|
|
16
|
+
longest = Math.max(longest, run);
|
|
17
|
+
}
|
|
18
|
+
const fence = "`".repeat(longest + 1);
|
|
19
|
+
return `${fence}${quoted.replace(/\|/gu, "\\|")}${fence}`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describePromptLocation } from "./render-mcp-prompt-file-report.js";
|
|
2
|
+
export function renderMcpPromptCatalogReport(report) {
|
|
3
|
+
return [
|
|
4
|
+
"MCP Prompt Catalog",
|
|
5
|
+
"==================",
|
|
6
|
+
`Status: ${report.status}`,
|
|
7
|
+
`Scope: ${report.scope}`,
|
|
8
|
+
...(report.transport === "stdio"
|
|
9
|
+
? [
|
|
10
|
+
"Transport: stdio",
|
|
11
|
+
`Protocol: ${report.protocol?.era ?? "not established"}${report.protocol?.version ? ` (${report.protocol.version})` : ""}`,
|
|
12
|
+
`Execution: ${report.execution?.backend ?? "not started"}`
|
|
13
|
+
]
|
|
14
|
+
: []),
|
|
15
|
+
`Discovery: ${report.discovery.status}`,
|
|
16
|
+
`Catalog complete: ${report.catalog.complete}`,
|
|
17
|
+
`Pages read: ${report.catalog.pagesRead}`,
|
|
18
|
+
`Prompts checked: ${report.catalog.promptsChecked}`,
|
|
19
|
+
...(report.catalog.reason === null ? [] : [`Reason: ${report.catalog.reason}`]),
|
|
20
|
+
`Enumeration: ${report.coverage.catalogEnumeration}`,
|
|
21
|
+
`Definition coverage: ${report.coverage.definitions}`,
|
|
22
|
+
`Prompt retrieval: ${report.coverage.promptRetrieval}`,
|
|
23
|
+
`Full conformance: ${report.coverage.fullConformance}`,
|
|
24
|
+
"Counts describe inspected content, not a guaranteed server-wide inventory.",
|
|
25
|
+
...report.discovery.findings.map((finding) => `${finding.severity.toUpperCase()} ${finding.id}: ${finding.message}\n ${finding.suggestedFix}`),
|
|
26
|
+
...report.findings.map((finding) => {
|
|
27
|
+
const locator = finding.location ? ` [page ${finding.location.page}, ${describePromptLocation(finding.location)}]` : "";
|
|
28
|
+
return `${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
|
|
29
|
+
})
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { markdownCodeSpan } from "./quote-server-text.js";
|
|
2
|
+
import { describePromptLocation } from "./render-mcp-prompt-file-report.js";
|
|
3
|
+
function count(value) {
|
|
4
|
+
return value === null || value === undefined ? "not compared" : String(value);
|
|
5
|
+
}
|
|
6
|
+
function promptIndex(value) {
|
|
7
|
+
return value === null ? "-" : String(value);
|
|
8
|
+
}
|
|
9
|
+
export function renderMcpPromptDiffMarkdown(report) {
|
|
10
|
+
const comparison = report.comparison;
|
|
11
|
+
const named = report.changes.some((change) => change.name !== undefined);
|
|
12
|
+
const lines = [
|
|
13
|
+
"## MCP Prompt Definition Diff",
|
|
14
|
+
"",
|
|
15
|
+
`**Status:** \`${report.status}\` · **Comparison complete:** ${comparison.complete ? "yes" : "no"}${comparison.reason === null ? "" : ` · **Reason:** \`${comparison.reason}\``}`,
|
|
16
|
+
"",
|
|
17
|
+
"| Added | Removed | Changed | Unchanged | Breaking | Unclassified |",
|
|
18
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
19
|
+
`| ${count(comparison.added)} | ${count(comparison.removed)} | ${count(comparison.changed)} | ${count(comparison.unchanged)} | ${count(comparison.breaking)} | ${count(comparison.unclassified)} |`
|
|
20
|
+
];
|
|
21
|
+
if (report.changes.length > 0) {
|
|
22
|
+
lines.push("", "### Changes", "", named ? "| Change | Prompt | Before prompt | After prompt | Fields | Impact | Reasons |" : "| Change | Before prompt | After prompt | Fields | Impact | Reasons |", named ? "| --- | --- | --- | --- | --- | --- | --- |" : "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind}${named ? ` | ${change.name === undefined ? "-" : markdownCodeSpan(change.name)}` : ""} | ${promptIndex(change.beforePromptIndex)} | ${promptIndex(change.afterPromptIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
|
|
23
|
+
}
|
|
24
|
+
const located = report.changes.flatMap((change) => (change.locations ?? []).map((location) => `| ${change.name === undefined ? "-" : markdownCodeSpan(change.name)} | \`${location.reason}\` | ${location.argument === null ? "-" : markdownCodeSpan(location.argument)} |`));
|
|
25
|
+
if (located.length > 0) {
|
|
26
|
+
lines.push("", "### Locations", "", "| Prompt | Reason | Argument |", "| --- | --- | --- |", ...located);
|
|
27
|
+
}
|
|
28
|
+
const findings = ["before", "after"].flatMap((side) => report[side].findings.map((finding) => {
|
|
29
|
+
const locator = finding.location ? `${side} ${describePromptLocation(finding.location)}` : side;
|
|
30
|
+
return `| ${finding.severity} | \`${finding.id}\` | ${locator} | ${finding.message} |`;
|
|
31
|
+
}));
|
|
32
|
+
if (findings.length > 0) {
|
|
33
|
+
lines.push("", "### Input Findings", "", "| Severity | Finding | Location | Message |", "| --- | --- | --- | --- |", ...findings);
|
|
34
|
+
}
|
|
35
|
+
lines.push("", named
|
|
36
|
+
? `Prompt and argument names are shown because --show-names was used; descriptions and other values are not reported. Impact labels are ${report.coverage.impactClassification} argument checks, not proof of compatibility.`
|
|
37
|
+
: `Prompt numbers are one-based positions in the before and after files; names and descriptions are not reported. Impact labels are ${report.coverage.impactClassification} argument checks, not proof of compatibility.`);
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|