codex-plugin-doctor 1.76.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 +18 -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 +203 -140
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -172,6 +172,22 @@ Doctor tries `server/discover` (`2026-07-28`) first and falls back to the
|
|
|
172
172
|
container by name.
|
|
173
173
|
See [MCP Tool Catalog](docs/guides/mcp-tool-catalog.md).
|
|
174
174
|
|
|
175
|
+
### MCP Prompt Catalog and Diff
|
|
176
|
+
|
|
177
|
+
Record a server's prompt list and catch prompt changes that break clients:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
codex-plugin-doctor doctor prompts https://mcp.example.com/mcp --allow-network --save-response ./mcp/prompts-baseline.json
|
|
181
|
+
codex-plugin-doctor doctor prompts-diff --before ./mcp/prompts-baseline.json --after ./prompts-current.json --fail-on breaking
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`doctor prompts` reads `prompts/list` from an HTTP server, or from a local stdio
|
|
185
|
+
server with `--server <name> --runtime`, without retrieving any prompt.
|
|
186
|
+
`doctor prompts-file` inspects a saved list offline, and `doctor prompts-diff`
|
|
187
|
+
labels a removed prompt, a removed argument, or a newly required argument as
|
|
188
|
+
breaking. Like the tool diff, reports omit names unless `--show-names` is given.
|
|
189
|
+
See [MCP Prompt Catalog and Diff](docs/guides/mcp-prompts.md).
|
|
190
|
+
|
|
175
191
|
### Remote MCP Readiness
|
|
176
192
|
|
|
177
193
|
Remote MCP runtime probing is disabled until you explicitly pass `--runtime --allow-network`. Loopback endpoints also require `--allow-local-network`:
|
|
@@ -590,9 +606,9 @@ jobs:
|
|
|
590
606
|
runs-on: ubuntu-latest
|
|
591
607
|
steps:
|
|
592
608
|
- uses: actions/checkout@v5
|
|
593
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
609
|
+
- uses: Esquetta/CodexPluginDoctor@v1.77.0
|
|
594
610
|
with:
|
|
595
|
-
version: "1.
|
|
611
|
+
version: "1.77.0"
|
|
596
612
|
path: .
|
|
597
613
|
runtime: "true"
|
|
598
614
|
policy: codex-publish
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RuntimeExecutionEvidence } from "../domain/types.js";
|
|
2
|
+
import type { McpDiscoveryReport } from "./mcp-discovery.js";
|
|
3
|
+
import { type PromptCatalogFinding } from "./mcp-prompt-definition.js";
|
|
4
|
+
import { type McpStdioServer, type McpStdioToolCatalogOptions } from "./mcp-stdio-tool-catalog.js";
|
|
5
|
+
import { type CatalogStatus, type EnumerationCoverage, type McpCatalogList, type McpToolCatalogCache, type McpToolCatalogOptions, type McpToolCatalogProtocol } from "./mcp-tool-catalog.js";
|
|
6
|
+
export declare const MCP_PROMPTS_LIST: McpCatalogList<PromptCatalogFinding>;
|
|
7
|
+
export interface McpPromptCatalogReport {
|
|
8
|
+
schemaVersion: 1;
|
|
9
|
+
scope: "prompt-catalog-structure";
|
|
10
|
+
transport?: "stdio";
|
|
11
|
+
status: CatalogStatus;
|
|
12
|
+
protocol?: McpToolCatalogProtocol;
|
|
13
|
+
execution?: RuntimeExecutionEvidence | null;
|
|
14
|
+
discovery: McpDiscoveryReport;
|
|
15
|
+
catalog: {
|
|
16
|
+
complete: boolean;
|
|
17
|
+
pagesRead: number;
|
|
18
|
+
promptsChecked: number;
|
|
19
|
+
reason: string | null;
|
|
20
|
+
};
|
|
21
|
+
coverage: {
|
|
22
|
+
discovery: "pass" | "fail" | "skipped";
|
|
23
|
+
catalogEnumeration: EnumerationCoverage;
|
|
24
|
+
definitions: "shape-only";
|
|
25
|
+
promptRetrieval: "not-tested";
|
|
26
|
+
fullConformance: "not-tested";
|
|
27
|
+
};
|
|
28
|
+
findings: PromptCatalogFinding[];
|
|
29
|
+
}
|
|
30
|
+
export interface McpPromptCatalogSnapshot {
|
|
31
|
+
report: McpPromptCatalogReport;
|
|
32
|
+
prompts: unknown[] | null;
|
|
33
|
+
cache: McpToolCatalogCache | null;
|
|
34
|
+
}
|
|
35
|
+
export declare function captureMcpPromptCatalog(rawUrl: string, options?: McpToolCatalogOptions): Promise<McpPromptCatalogSnapshot>;
|
|
36
|
+
export declare function captureStdioMcpPromptCatalog(server: McpStdioServer, options?: McpStdioToolCatalogOptions): Promise<McpPromptCatalogSnapshot>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createMcpPromptCatalogInspector } from "./mcp-prompt-definition.js";
|
|
2
|
+
import { captureStdioMcpCatalog } from "./mcp-stdio-tool-catalog.js";
|
|
3
|
+
import { captureMcpCatalog } from "./mcp-tool-catalog.js";
|
|
4
|
+
export const MCP_PROMPTS_LIST = {
|
|
5
|
+
method: "prompts/list",
|
|
6
|
+
capability: "prompts",
|
|
7
|
+
noun: "prompt",
|
|
8
|
+
createInspector() {
|
|
9
|
+
const inspector = createMcpPromptCatalogInspector();
|
|
10
|
+
return {
|
|
11
|
+
findings: inspector.findings,
|
|
12
|
+
items: inspector.prompts,
|
|
13
|
+
get checked() {
|
|
14
|
+
return inspector.promptsChecked;
|
|
15
|
+
},
|
|
16
|
+
addFindings: inspector.addFindings,
|
|
17
|
+
inspectPage: inspector.inspectPage
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function promptSnapshot(capture, stdio) {
|
|
22
|
+
const { complete, pagesRead, checked, reason } = capture.catalog;
|
|
23
|
+
return {
|
|
24
|
+
report: {
|
|
25
|
+
schemaVersion: 1,
|
|
26
|
+
scope: "prompt-catalog-structure",
|
|
27
|
+
...(stdio === undefined ? {} : { transport: "stdio" }),
|
|
28
|
+
status: capture.status,
|
|
29
|
+
...(stdio === undefined ? {} : { protocol: stdio.protocol, execution: stdio.execution }),
|
|
30
|
+
discovery: capture.discovery,
|
|
31
|
+
catalog: { complete, pagesRead, promptsChecked: checked, reason },
|
|
32
|
+
coverage: {
|
|
33
|
+
discovery: capture.discovery.coverage.discovery,
|
|
34
|
+
catalogEnumeration: capture.enumeration,
|
|
35
|
+
definitions: "shape-only",
|
|
36
|
+
promptRetrieval: "not-tested",
|
|
37
|
+
fullConformance: "not-tested"
|
|
38
|
+
},
|
|
39
|
+
findings: capture.findings
|
|
40
|
+
},
|
|
41
|
+
prompts: capture.items,
|
|
42
|
+
cache: capture.cache
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// Prompts and cache metadata are returned only for a complete enumeration; prompts are never retrieved.
|
|
46
|
+
export async function captureMcpPromptCatalog(rawUrl, options = {}) {
|
|
47
|
+
return promptSnapshot(await captureMcpCatalog(rawUrl, MCP_PROMPTS_LIST, options));
|
|
48
|
+
}
|
|
49
|
+
// Starts one configured stdio server, reads its prompt list without retrieving prompts, and stops it.
|
|
50
|
+
export async function captureStdioMcpPromptCatalog(server, options = {}) {
|
|
51
|
+
const { capture, protocol, execution } = await captureStdioMcpCatalog(server, MCP_PROMPTS_LIST, options);
|
|
52
|
+
return promptSnapshot(capture, { protocol, execution });
|
|
53
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Finding } from "../domain/types.js";
|
|
2
|
+
export interface PromptCatalogLocation {
|
|
3
|
+
page: number;
|
|
4
|
+
promptIndex: number;
|
|
5
|
+
argumentIndex?: number;
|
|
6
|
+
relatedPromptIndex?: number;
|
|
7
|
+
relatedArgumentIndex?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface PromptCatalogFinding extends Finding {
|
|
10
|
+
location?: PromptCatalogLocation;
|
|
11
|
+
}
|
|
12
|
+
export declare const MAX_PROMPTS = 500;
|
|
13
|
+
export declare function inspectMcpPromptDefinition(value: unknown, location: {
|
|
14
|
+
page: number;
|
|
15
|
+
promptIndex: number;
|
|
16
|
+
}): PromptCatalogFinding[];
|
|
17
|
+
export interface McpPromptCatalogInspector {
|
|
18
|
+
readonly findings: PromptCatalogFinding[];
|
|
19
|
+
readonly prompts: unknown[];
|
|
20
|
+
readonly promptsChecked: number;
|
|
21
|
+
addFindings(items: PromptCatalogFinding[]): boolean;
|
|
22
|
+
inspectPage(pagePrompts: unknown[], page: number, timeExpired: () => boolean): "prompt-limit" | "finding-limit" | "total-time-limit" | null;
|
|
23
|
+
}
|
|
24
|
+
export declare function createMcpPromptCatalogInspector(): McpPromptCatalogInspector;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const MAX_PROMPTS = 500;
|
|
2
|
+
const MAX_FINDINGS = 100;
|
|
3
|
+
const DUPLICATE_PROMPT_NAME = "plugin.catalog.prompt.name.duplicate";
|
|
4
|
+
function isJsonObject(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function finding(id, message, impact, suggestedFix, location) {
|
|
8
|
+
return { id, severity: id === DUPLICATE_PROMPT_NAME ? "warn" : "fail", message, impact, suggestedFix, location };
|
|
9
|
+
}
|
|
10
|
+
function inspectArguments(value, location, findings) {
|
|
11
|
+
if (!Array.isArray(value)) {
|
|
12
|
+
findings.push(finding("plugin.catalog.prompt.arguments.invalid", "A catalog prompt arguments field must be an array when provided.", "Clients cannot determine which arguments the prompt accepts.", "Provide arguments as an array of argument objects or omit the field.", location));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const names = new Map();
|
|
16
|
+
value.forEach((argument, offset) => {
|
|
17
|
+
const argumentLocation = { ...location, argumentIndex: offset + 1 };
|
|
18
|
+
if (!isJsonObject(argument)) {
|
|
19
|
+
findings.push(finding("plugin.catalog.prompt.argument.invalid_object", "A catalog prompt argument must be an object.", "Clients cannot read an argument definition that is not an object.", "Return each prompt argument as an object.", argumentLocation));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (typeof argument.name !== "string" || argument.name === "") {
|
|
23
|
+
findings.push(finding("plugin.catalog.prompt.argument.name.invalid", "A catalog prompt argument name must be a non-empty string.", "Clients cannot supply an argument without a name.", "Provide a non-empty string name for the argument.", argumentLocation));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
const previous = names.get(argument.name);
|
|
27
|
+
if (previous === undefined) {
|
|
28
|
+
names.set(argument.name, offset + 1);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
findings.push(finding("plugin.catalog.prompt.argument.name.duplicate", "Two arguments of a catalog prompt use the same name.", "Clients cannot tell which definition applies to the argument they supply.", "Use a unique name for each prompt argument.", { ...argumentLocation, relatedArgumentIndex: previous }));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (Object.hasOwn(argument, "required") && typeof argument.required !== "boolean") {
|
|
35
|
+
findings.push(finding("plugin.catalog.prompt.argument.required.invalid", "A catalog prompt argument required field must be a boolean when provided.", "Clients cannot tell whether the argument must be supplied.", "Set required to true or false, or omit it.", argumentLocation));
|
|
36
|
+
}
|
|
37
|
+
for (const field of ["title", "description"]) {
|
|
38
|
+
if (Object.hasOwn(argument, field) && typeof argument[field] !== "string") {
|
|
39
|
+
findings.push(finding(`plugin.catalog.prompt.argument.${field}.invalid`, `A catalog prompt argument ${field} must be a string when provided.`, `Clients cannot reliably display a non-string argument ${field}.`, `Provide a string ${field} or omit the field.`, argumentLocation));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export function inspectMcpPromptDefinition(value, location) {
|
|
45
|
+
const findings = [];
|
|
46
|
+
const baseLocation = { page: location.page, promptIndex: location.promptIndex };
|
|
47
|
+
if (!isJsonObject(value)) {
|
|
48
|
+
findings.push(finding("plugin.catalog.prompt.invalid_object", "A catalog prompt entry must be an object.", "The validator cannot inspect a prompt definition that is not an object.", "Return each prompts/list entry as an object.", baseLocation));
|
|
49
|
+
return findings;
|
|
50
|
+
}
|
|
51
|
+
if (typeof value.name !== "string" || value.name === "") {
|
|
52
|
+
findings.push(finding("plugin.catalog.prompt.name.invalid", "A catalog prompt name must be a non-empty string.", "Clients cannot identify or retrieve a prompt without a name.", "Provide a non-empty string name for the prompt.", baseLocation));
|
|
53
|
+
}
|
|
54
|
+
for (const field of ["title", "description"]) {
|
|
55
|
+
if (Object.hasOwn(value, field) && typeof value[field] !== "string") {
|
|
56
|
+
findings.push(finding(`plugin.catalog.prompt.${field}.invalid`, `A catalog prompt ${field} must be a string when provided.`, `Clients cannot reliably display a non-string prompt ${field}.`, `Provide a string ${field} or omit the field.`, baseLocation));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (Object.hasOwn(value, "arguments"))
|
|
60
|
+
inspectArguments(value.arguments, baseLocation, findings);
|
|
61
|
+
return findings;
|
|
62
|
+
}
|
|
63
|
+
// Shared by saved-file inspection and the HTTP and stdio catalogs so all apply the same prompt, finding, and duplicate-name rules.
|
|
64
|
+
export function createMcpPromptCatalogInspector() {
|
|
65
|
+
const findings = [];
|
|
66
|
+
const prompts = [];
|
|
67
|
+
const names = new Map();
|
|
68
|
+
let promptsChecked = 0;
|
|
69
|
+
const addFindings = (items) => {
|
|
70
|
+
if (findings.length + items.length > MAX_FINDINGS) {
|
|
71
|
+
findings.push(...items.slice(0, MAX_FINDINGS - findings.length));
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
findings.push(...items);
|
|
75
|
+
return true;
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
findings,
|
|
79
|
+
prompts,
|
|
80
|
+
get promptsChecked() {
|
|
81
|
+
return promptsChecked;
|
|
82
|
+
},
|
|
83
|
+
addFindings,
|
|
84
|
+
inspectPage(pagePrompts, page, timeExpired) {
|
|
85
|
+
for (const prompt of pagePrompts) {
|
|
86
|
+
if (promptsChecked >= MAX_PROMPTS)
|
|
87
|
+
return "prompt-limit";
|
|
88
|
+
promptsChecked += 1;
|
|
89
|
+
prompts.push(prompt);
|
|
90
|
+
const location = { page, promptIndex: promptsChecked };
|
|
91
|
+
if (!addFindings(inspectMcpPromptDefinition(prompt, location)))
|
|
92
|
+
return "finding-limit";
|
|
93
|
+
if (isJsonObject(prompt) && typeof prompt.name === "string" && prompt.name !== "") {
|
|
94
|
+
const previous = names.get(prompt.name);
|
|
95
|
+
if (previous !== undefined && !addFindings([finding(DUPLICATE_PROMPT_NAME, "Two enumerated prompts use the same name.", "Duplicate prompt names can make client selection ambiguous.", "Use a unique MCP prompt name for each definition.", { ...location, relatedPromptIndex: previous })])) {
|
|
96
|
+
return "finding-limit";
|
|
97
|
+
}
|
|
98
|
+
if (previous === undefined)
|
|
99
|
+
names.set(prompt.name, promptsChecked);
|
|
100
|
+
}
|
|
101
|
+
if (timeExpired())
|
|
102
|
+
return "total-time-limit";
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type McpPromptFileReport } from "./mcp-prompt-file.js";
|
|
2
|
+
import { type PromptChangeImpact, type PromptChangeLocation, type PromptChangeReason } from "./mcp-prompt-impact.js";
|
|
3
|
+
type DiffField = "arguments" | "description" | "title" | "other";
|
|
4
|
+
export interface McpPromptDiffReport {
|
|
5
|
+
schemaVersion: 1;
|
|
6
|
+
scope: "prompt-definitions-diff";
|
|
7
|
+
status: "pass" | "warn" | "incomplete" | "blocked";
|
|
8
|
+
before: McpPromptFileReport;
|
|
9
|
+
after: McpPromptFileReport;
|
|
10
|
+
comparison: {
|
|
11
|
+
complete: boolean;
|
|
12
|
+
reason: "input-blocked" | "input-incomplete" | "invalid-definitions" | "ambiguous-names" | null;
|
|
13
|
+
added: number | null;
|
|
14
|
+
removed: number | null;
|
|
15
|
+
changed: number | null;
|
|
16
|
+
unchanged: number | null;
|
|
17
|
+
breaking: number | null;
|
|
18
|
+
unclassified: number | null;
|
|
19
|
+
};
|
|
20
|
+
coverage: {
|
|
21
|
+
comparison: "structural-only";
|
|
22
|
+
definitions: "shape-only";
|
|
23
|
+
compatibility: "not-tested";
|
|
24
|
+
serverCatalog: "not-tested";
|
|
25
|
+
promptRetrieval: "not-tested";
|
|
26
|
+
impactClassification: "heuristic";
|
|
27
|
+
};
|
|
28
|
+
changes: Array<{
|
|
29
|
+
kind: "added" | "removed" | "changed";
|
|
30
|
+
beforePromptIndex: number | null;
|
|
31
|
+
afterPromptIndex: number | null;
|
|
32
|
+
fields: DiffField[];
|
|
33
|
+
impact: PromptChangeImpact;
|
|
34
|
+
reasons: PromptChangeReason[];
|
|
35
|
+
name?: string;
|
|
36
|
+
locations?: PromptChangeLocation[];
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
export interface McpPromptDiffOptions {
|
|
40
|
+
showNames?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function compareMcpPromptFiles(beforePath: string, afterPath: string, options?: McpPromptDiffOptions): Promise<McpPromptDiffReport>;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { loadMcpPromptFileSnapshot } from "./mcp-prompt-file.js";
|
|
2
|
+
import { classifyAddedPrompt, classifyChangedPrompt, classifyRemovedPrompt } from "./mcp-prompt-impact.js";
|
|
3
|
+
import { structurallyEqual } from "./mcp-tool-diff.js";
|
|
4
|
+
const FIXED_FIELDS = ["arguments", "description", "title"];
|
|
5
|
+
const EXCLUDED_OTHER_FIELDS = new Set(["name", ...FIXED_FIELDS]);
|
|
6
|
+
const COVERAGE = {
|
|
7
|
+
comparison: "structural-only",
|
|
8
|
+
definitions: "shape-only",
|
|
9
|
+
compatibility: "not-tested",
|
|
10
|
+
serverCatalog: "not-tested",
|
|
11
|
+
promptRetrieval: "not-tested",
|
|
12
|
+
impactClassification: "heuristic"
|
|
13
|
+
};
|
|
14
|
+
function isJsonObject(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function filteredOther(definition) {
|
|
18
|
+
const other = Object.create(null);
|
|
19
|
+
for (const key of Object.keys(definition)) {
|
|
20
|
+
if (!EXCLUDED_OTHER_FIELDS.has(key))
|
|
21
|
+
other[key] = definition[key];
|
|
22
|
+
}
|
|
23
|
+
return other;
|
|
24
|
+
}
|
|
25
|
+
function changedFields(before, after) {
|
|
26
|
+
const fields = [];
|
|
27
|
+
for (const field of FIXED_FIELDS) {
|
|
28
|
+
if (!structurallyEqual(before[field], after[field]))
|
|
29
|
+
fields.push(field);
|
|
30
|
+
}
|
|
31
|
+
if (!structurallyEqual(filteredOther(before), filteredOther(after)))
|
|
32
|
+
fields.push("other");
|
|
33
|
+
return fields;
|
|
34
|
+
}
|
|
35
|
+
function promptDefinitions(prompts) {
|
|
36
|
+
const definitions = new Map();
|
|
37
|
+
for (let offset = 0; offset < prompts.length; offset += 1) {
|
|
38
|
+
const value = prompts[offset];
|
|
39
|
+
if (!isJsonObject(value) || typeof value.name !== "string" || definitions.has(value.name))
|
|
40
|
+
return null;
|
|
41
|
+
definitions.set(value.name, { index: offset + 1, value });
|
|
42
|
+
}
|
|
43
|
+
return definitions;
|
|
44
|
+
}
|
|
45
|
+
function incompleteReport(before, after, status, reason) {
|
|
46
|
+
return {
|
|
47
|
+
schemaVersion: 1,
|
|
48
|
+
scope: "prompt-definitions-diff",
|
|
49
|
+
status,
|
|
50
|
+
before,
|
|
51
|
+
after,
|
|
52
|
+
comparison: { complete: false, reason, added: null, removed: null, changed: null, unchanged: null, breaking: null, unclassified: null },
|
|
53
|
+
coverage: { ...COVERAGE },
|
|
54
|
+
changes: []
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export async function compareMcpPromptFiles(beforePath, afterPath, options = {}) {
|
|
58
|
+
const [beforeSnapshot, afterSnapshot] = await Promise.all([
|
|
59
|
+
loadMcpPromptFileSnapshot(beforePath),
|
|
60
|
+
loadMcpPromptFileSnapshot(afterPath)
|
|
61
|
+
]);
|
|
62
|
+
const { report: before } = beforeSnapshot;
|
|
63
|
+
const { report: after } = afterSnapshot;
|
|
64
|
+
if (before.status === "blocked" || after.status === "blocked") {
|
|
65
|
+
return incompleteReport(before, after, "blocked", "input-blocked");
|
|
66
|
+
}
|
|
67
|
+
if (!before.inspection.complete || !after.inspection.complete) {
|
|
68
|
+
return incompleteReport(before, after, "incomplete", "input-incomplete");
|
|
69
|
+
}
|
|
70
|
+
if (before.status === "fail" || after.status === "fail" || beforeSnapshot.prompts === null || afterSnapshot.prompts === null) {
|
|
71
|
+
return incompleteReport(before, after, "incomplete", "invalid-definitions");
|
|
72
|
+
}
|
|
73
|
+
const beforeDefinitions = promptDefinitions(beforeSnapshot.prompts);
|
|
74
|
+
const afterDefinitions = promptDefinitions(afterSnapshot.prompts);
|
|
75
|
+
if (beforeDefinitions === null || afterDefinitions === null) {
|
|
76
|
+
return incompleteReport(before, after, "incomplete", "ambiguous-names");
|
|
77
|
+
}
|
|
78
|
+
const named = (name) => (options.showNames === true ? { name } : {});
|
|
79
|
+
const changes = [];
|
|
80
|
+
let added = 0;
|
|
81
|
+
let removed = 0;
|
|
82
|
+
let changed = 0;
|
|
83
|
+
let unchanged = 0;
|
|
84
|
+
for (const [name, beforeDefinition] of beforeDefinitions) {
|
|
85
|
+
const afterDefinition = afterDefinitions.get(name);
|
|
86
|
+
if (afterDefinition === undefined) {
|
|
87
|
+
removed += 1;
|
|
88
|
+
changes.push({ kind: "removed", ...named(name), beforePromptIndex: beforeDefinition.index, afterPromptIndex: null, fields: [], ...classifyRemovedPrompt() });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const fields = changedFields(beforeDefinition.value, afterDefinition.value);
|
|
92
|
+
if (fields.length === 0) {
|
|
93
|
+
unchanged += 1;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
changed += 1;
|
|
97
|
+
changes.push({
|
|
98
|
+
kind: "changed",
|
|
99
|
+
...named(name),
|
|
100
|
+
beforePromptIndex: beforeDefinition.index,
|
|
101
|
+
afterPromptIndex: afterDefinition.index,
|
|
102
|
+
fields,
|
|
103
|
+
...classifyChangedPrompt(beforeDefinition.value, afterDefinition.value, fields, structurallyEqual, { locate: options.showNames === true })
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const [name, afterDefinition] of afterDefinitions) {
|
|
108
|
+
if (!beforeDefinitions.has(name)) {
|
|
109
|
+
added += 1;
|
|
110
|
+
changes.push({ kind: "added", ...named(name), beforePromptIndex: null, afterPromptIndex: afterDefinition.index, fields: [], ...classifyAddedPrompt() });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
scope: "prompt-definitions-diff",
|
|
116
|
+
status: changes.length === 0 && before.status === "pass" && after.status === "pass" ? "pass" : "warn",
|
|
117
|
+
before,
|
|
118
|
+
after,
|
|
119
|
+
comparison: {
|
|
120
|
+
complete: true,
|
|
121
|
+
reason: null,
|
|
122
|
+
added,
|
|
123
|
+
removed,
|
|
124
|
+
changed,
|
|
125
|
+
unchanged,
|
|
126
|
+
breaking: changes.filter((change) => change.impact === "breaking").length,
|
|
127
|
+
unclassified: changes.filter((change) => change.impact === "unclassified").length
|
|
128
|
+
},
|
|
129
|
+
coverage: { ...COVERAGE },
|
|
130
|
+
changes
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type PromptCatalogFinding } from "./mcp-prompt-definition.js";
|
|
2
|
+
export interface McpPromptFileReport {
|
|
3
|
+
schemaVersion: 1;
|
|
4
|
+
scope: "prompt-definitions-file";
|
|
5
|
+
status: "pass" | "warn" | "fail" | "incomplete" | "blocked";
|
|
6
|
+
source: {
|
|
7
|
+
kind: "file";
|
|
8
|
+
format: "mcp-prompts-list-response";
|
|
9
|
+
bytesRead: number;
|
|
10
|
+
};
|
|
11
|
+
inspection: {
|
|
12
|
+
complete: boolean;
|
|
13
|
+
promptsChecked: number;
|
|
14
|
+
hasNextCursor: boolean | null;
|
|
15
|
+
reason: string | null;
|
|
16
|
+
};
|
|
17
|
+
coverage: {
|
|
18
|
+
definitions: "shape-only";
|
|
19
|
+
discovery: "not-tested";
|
|
20
|
+
serverCatalog: "not-tested";
|
|
21
|
+
promptRetrieval: "not-tested";
|
|
22
|
+
fullConformance: "not-tested";
|
|
23
|
+
};
|
|
24
|
+
findings: PromptCatalogFinding[];
|
|
25
|
+
}
|
|
26
|
+
export interface McpPromptFileSnapshot {
|
|
27
|
+
report: McpPromptFileReport;
|
|
28
|
+
prompts: unknown[] | null;
|
|
29
|
+
}
|
|
30
|
+
export declare function loadMcpPromptFileSnapshot(filePath: string): Promise<McpPromptFileSnapshot>;
|
|
31
|
+
export declare function inspectMcpPromptFile(filePath: string): Promise<McpPromptFileReport>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createMcpPromptCatalogInspector } from "./mcp-prompt-definition.js";
|
|
2
|
+
import { isBlockedOfflinePath, readBoundedRegularFile } from "./mcp-tool-file.js";
|
|
3
|
+
function snapshot(report, prompts = null) {
|
|
4
|
+
return { report, prompts };
|
|
5
|
+
}
|
|
6
|
+
function isJsonObject(value) {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function report(bytesRead, status, inspection, findings) {
|
|
10
|
+
return {
|
|
11
|
+
schemaVersion: 1,
|
|
12
|
+
scope: "prompt-definitions-file",
|
|
13
|
+
status,
|
|
14
|
+
source: { kind: "file", format: "mcp-prompts-list-response", bytesRead },
|
|
15
|
+
inspection,
|
|
16
|
+
coverage: {
|
|
17
|
+
definitions: "shape-only",
|
|
18
|
+
discovery: "not-tested",
|
|
19
|
+
serverCatalog: "not-tested",
|
|
20
|
+
promptRetrieval: "not-tested",
|
|
21
|
+
fullConformance: "not-tested"
|
|
22
|
+
},
|
|
23
|
+
findings
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function fileFinding(id, message, impact, suggestedFix) {
|
|
27
|
+
return { id, severity: "fail", message, impact, suggestedFix };
|
|
28
|
+
}
|
|
29
|
+
function incompleteFileReport(bytesRead, reason, finding) {
|
|
30
|
+
return report(bytesRead, "incomplete", { complete: false, promptsChecked: 0, hasNextCursor: null, reason }, [finding]);
|
|
31
|
+
}
|
|
32
|
+
function blockedFileReport(reason, finding) {
|
|
33
|
+
return report(0, "blocked", { complete: false, promptsChecked: 0, hasNextCursor: null, reason }, [finding]);
|
|
34
|
+
}
|
|
35
|
+
function finalStatus(findings) {
|
|
36
|
+
if (findings.some((finding) => finding.severity === "fail"))
|
|
37
|
+
return "fail";
|
|
38
|
+
return findings.some((finding) => finding.severity === "warn") ? "warn" : "pass";
|
|
39
|
+
}
|
|
40
|
+
function parsePromptsResult(value) {
|
|
41
|
+
if (!isJsonObject(value)
|
|
42
|
+
|| value.jsonrpc !== "2.0"
|
|
43
|
+
|| !Object.hasOwn(value, "id")
|
|
44
|
+
|| !(typeof value.id === "string" || (typeof value.id === "number" && Number.isFinite(value.id)))
|
|
45
|
+
|| !Object.hasOwn(value, "result")
|
|
46
|
+
|| Object.hasOwn(value, "error")
|
|
47
|
+
|| !isJsonObject(value.result)
|
|
48
|
+
|| value.result.resultType !== "complete"
|
|
49
|
+
|| !Array.isArray(value.result.prompts)
|
|
50
|
+
|| typeof value.result.ttlMs !== "number"
|
|
51
|
+
|| !Number.isFinite(value.result.ttlMs)
|
|
52
|
+
|| value.result.ttlMs < 0
|
|
53
|
+
|| (value.result.cacheScope !== "public" && value.result.cacheScope !== "private")) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const hasNextCursor = Object.hasOwn(value.result, "nextCursor");
|
|
57
|
+
if (hasNextCursor && typeof value.result.nextCursor !== "string")
|
|
58
|
+
return null;
|
|
59
|
+
return { prompts: value.result.prompts, hasNextCursor };
|
|
60
|
+
}
|
|
61
|
+
export async function loadMcpPromptFileSnapshot(filePath) {
|
|
62
|
+
if (isBlockedOfflinePath(filePath)) {
|
|
63
|
+
return snapshot(blockedFileReport("offline-path-blocked", fileFinding("plugin.prompts_file.path.blocked", "The local prompt definition path is not permitted for offline inspection.", "The offline prompt definition inspection does not access remote or device paths.", "Provide a local regular file path.")));
|
|
64
|
+
}
|
|
65
|
+
const file = await readBoundedRegularFile(filePath);
|
|
66
|
+
if (file.kind === "unavailable") {
|
|
67
|
+
return snapshot(incompleteFileReport(file.bytesRead, "file-unavailable", fileFinding("plugin.prompts_file.file.unavailable", "The local prompt definition file could not be read.", "The offline prompt definition inspection could not start.", "Provide a readable local regular file.")));
|
|
68
|
+
}
|
|
69
|
+
if (file.kind === "too-large") {
|
|
70
|
+
return snapshot(incompleteFileReport(file.bytesRead, "file-size-limit", fileFinding("plugin.prompts_file.file.too_large", "The local prompt definition file exceeds the one MiB inspection limit.", "The offline prompt definition inspection cannot safely read this file within its byte limit.", "Provide a prompt definition file no larger than one MiB.")));
|
|
71
|
+
}
|
|
72
|
+
if (file.kind === "changed") {
|
|
73
|
+
return snapshot(incompleteFileReport(file.bytesRead, "file-changed", fileFinding("plugin.prompts_file.file.changed", "The local prompt definition file changed during inspection.", "The offline prompt definition inspection cannot rely on an inconsistent file snapshot.", "Retry after the file is stable.")));
|
|
74
|
+
}
|
|
75
|
+
if (file.kind === "symlink") {
|
|
76
|
+
return snapshot(blockedFileReport("final-symlink", fileFinding("plugin.prompts_file.file.symlink", "The local prompt definition target is a symbolic link.", "The offline prompt definition inspection does not follow final symbolic links.", "Provide a regular file directly.")));
|
|
77
|
+
}
|
|
78
|
+
if (file.kind === "not-regular") {
|
|
79
|
+
return snapshot(blockedFileReport("non-regular-file", fileFinding("plugin.prompts_file.file.not_regular", "The local prompt definition target is not a regular file.", "The offline prompt definition inspection reads only bounded regular files.", "Provide a regular file directly.")));
|
|
80
|
+
}
|
|
81
|
+
let decoded;
|
|
82
|
+
try {
|
|
83
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(file.content);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return snapshot(incompleteFileReport(file.bytesRead, "invalid-utf8", fileFinding("plugin.prompts_file.encoding.invalid", "The local prompt definition file is not valid UTF-8.", "The offline prompt definition inspection cannot parse the file safely.", "Save the prompts/list response as UTF-8 JSON.")));
|
|
87
|
+
}
|
|
88
|
+
let value;
|
|
89
|
+
try {
|
|
90
|
+
value = JSON.parse(decoded);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return snapshot(incompleteFileReport(file.bytesRead, "invalid-json", fileFinding("plugin.prompts_file.json.invalid", "The local prompt definition file is not valid JSON.", "The offline prompt definition inspection cannot parse the file safely.", "Provide one UTF-8 JSON-RPC prompts/list response.")));
|
|
94
|
+
}
|
|
95
|
+
const parsed = parsePromptsResult(value);
|
|
96
|
+
if (parsed === null) {
|
|
97
|
+
return snapshot(incompleteFileReport(file.bytesRead, "response-invalid", fileFinding("plugin.prompts_file.response.invalid", "The local JSON value is not a supported complete prompts/list response.", "The offline prompt definition inspection cannot identify a valid prompt list.", "Provide one JSON-RPC 2.0 complete prompts/list result with valid cache metadata.")));
|
|
98
|
+
}
|
|
99
|
+
const inspector = createMcpPromptCatalogInspector();
|
|
100
|
+
const reason = inspector.inspectPage(parsed.prompts, 1, () => false) ?? (parsed.hasNextCursor ? "more-pages-present" : null);
|
|
101
|
+
if (reason !== null) {
|
|
102
|
+
return snapshot(report(file.bytesRead, "incomplete", {
|
|
103
|
+
complete: false,
|
|
104
|
+
promptsChecked: inspector.promptsChecked,
|
|
105
|
+
hasNextCursor: parsed.hasNextCursor,
|
|
106
|
+
reason
|
|
107
|
+
}, inspector.findings), parsed.prompts);
|
|
108
|
+
}
|
|
109
|
+
return snapshot(report(file.bytesRead, finalStatus(inspector.findings), {
|
|
110
|
+
complete: true,
|
|
111
|
+
promptsChecked: inspector.promptsChecked,
|
|
112
|
+
hasNextCursor: false,
|
|
113
|
+
reason: null
|
|
114
|
+
}, inspector.findings), parsed.prompts);
|
|
115
|
+
}
|
|
116
|
+
export async function inspectMcpPromptFile(filePath) {
|
|
117
|
+
return (await loadMcpPromptFileSnapshot(filePath)).report;
|
|
118
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type JsonObject = Record<string, unknown>;
|
|
2
|
+
type Equal = (left: unknown, right: unknown) => boolean;
|
|
3
|
+
export type PromptChangeImpact = "breaking" | "compatible" | "unclassified";
|
|
4
|
+
export declare const PROMPT_CHANGE_REASONS: readonly ["prompt-removed", "prompt-argument-removed", "prompt-required-argument-added", "prompt-argument-now-required", "prompt-argument-fields-unclassified", "other-fields-unclassified"];
|
|
5
|
+
export type PromptChangeReason = (typeof PROMPT_CHANGE_REASONS)[number];
|
|
6
|
+
export interface PromptChangeLocation {
|
|
7
|
+
reason: PromptChangeReason;
|
|
8
|
+
argument: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface PromptChangeClassification {
|
|
11
|
+
impact: PromptChangeImpact;
|
|
12
|
+
reasons: PromptChangeReason[];
|
|
13
|
+
locations?: PromptChangeLocation[];
|
|
14
|
+
}
|
|
15
|
+
export declare const MAX_PROMPT_CHANGE_LOCATIONS = 100;
|
|
16
|
+
export declare function classifyRemovedPrompt(): PromptChangeClassification;
|
|
17
|
+
export declare function classifyAddedPrompt(): PromptChangeClassification;
|
|
18
|
+
export declare function classifyChangedPrompt(before: JsonObject, after: JsonObject, fields: readonly string[], equal: Equal, options?: {
|
|
19
|
+
locate?: boolean;
|
|
20
|
+
}): PromptChangeClassification;
|
|
21
|
+
export {};
|