cuonztech-agent-harness 0.5.2 → 0.7.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/dist/doctor.js ADDED
@@ -0,0 +1,241 @@
1
+ // `doctor` — prüft vor einem Testlauf, ob der Weg funktioniert, und nennt für
2
+ // jeden Fehler die Lösung. Ruft nur `initialize` und `tools/list` auf, führt nie
3
+ // ein Tool aus und schreibt nichts (der Report-Pfad wird nur geprüft).
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+ import { access, stat } from "node:fs/promises";
7
+ import { constants, existsSync } from "node:fs";
8
+ import { basename, dirname, resolve } from "node:path";
9
+ import { classifyUpstreamTool } from "./proxy/interceptor.js";
10
+ import { auditToolSchemas, shown } from "./schema-audit.js";
11
+ import { isReplaceableReportFile, isReservedFileName, notReplaceableMessage } from "./report-file-guard.js";
12
+ const START_TIMEOUT_MS = 15_000;
13
+ function splitList(raw) {
14
+ return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
15
+ }
16
+ function parseDoctorArgs(argv) {
17
+ const out = { upstreamArgs: [], writeTools: [], readTools: [], unknown: [], empty: [] };
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const token = argv[i];
20
+ const eq = token.indexOf("=");
21
+ const flag = eq === -1 ? token : token.slice(0, eq);
22
+ const inline = eq === -1 ? undefined : token.slice(eq + 1);
23
+ const takeValue = () => {
24
+ const value = inline !== undefined ? inline : ((i += 1), argv[i] ?? "");
25
+ // An empty --upstream-arg is a legitimate argument for the upstream.
26
+ if (value.trim() === "" && flag !== "--upstream-arg")
27
+ out.empty.push(flag);
28
+ return value;
29
+ };
30
+ switch (flag) {
31
+ case "--upstream-command":
32
+ out.command = takeValue();
33
+ break;
34
+ case "--upstream-arg":
35
+ out.upstreamArgs.push(takeValue());
36
+ break;
37
+ case "--upstream-cwd":
38
+ out.cwd = takeValue();
39
+ break;
40
+ case "--report-file":
41
+ out.reportFile = takeValue();
42
+ break;
43
+ case "--write-tools":
44
+ out.writeTools = splitList(takeValue());
45
+ break;
46
+ case "--read-tools":
47
+ out.readTools = splitList(takeValue());
48
+ break;
49
+ default:
50
+ out.unknown.push(token);
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ function checkNode() {
56
+ const major = Number(process.versions.node.split(".")[0]);
57
+ return {
58
+ ok: major >= 20,
59
+ label: `Node.js ${process.versions.node} (mindestens 20 nötig)`,
60
+ fix: "Node 20 oder neuer von https://nodejs.org installieren. Achtung: `apt install nodejs` liefert auf Ubuntu 24.04 nur Node 18.",
61
+ };
62
+ }
63
+ function checkUpstreamScript(command, args, cwd) {
64
+ const scriptArg = args.find((a) => !a.startsWith("-"));
65
+ const inlineScript = args.some((a) => ["-e", "--eval", "-p", "--print"].includes(a));
66
+ const isNode = /^node(\.exe)?$/i.test(basename(command));
67
+ if (isNode && !inlineScript && scriptArg === undefined) {
68
+ return {
69
+ ok: false,
70
+ label: "Start-Datei angegeben (node braucht eine Datei)",
71
+ fix: "Den Pfad zur Start-Datei deines MCP-Servers angeben, z. B. --upstream-arg <pfad\\zu\\server.js>.",
72
+ };
73
+ }
74
+ if (!isNode || scriptArg === undefined || inlineScript) {
75
+ return undefined;
76
+ }
77
+ const scriptPath = resolve(cwd, scriptArg);
78
+ return {
79
+ ok: existsSync(scriptPath),
80
+ label: `Upstream-Datei vorhanden: ${scriptPath}`,
81
+ fix: "Den Platzhalter durch den Pfad zur Start-Datei deines MCP-Servers ersetzen (oder --upstream-cwd setzen).",
82
+ };
83
+ }
84
+ async function checkReportFile(path) {
85
+ const target = resolve(path);
86
+ if (isReservedFileName(path)) {
87
+ return { ok: false, label: `Report-Datei: ${path}`, detail: "Der Name enthält einen reservierten Gerätenamen oder ein \":\" und ist keine nutzbare Datei.", fix: "Anderen Dateinamen für --report-file wählen." };
88
+ }
89
+ if (existsSync(target)) {
90
+ const info = await stat(target);
91
+ if (!info.isFile()) {
92
+ return { ok: false, label: `Report-Datei: ${target}`, detail: "Ziel ist keine normale Datei.", fix: "Anderen Dateinamen für --report-file wählen." };
93
+ }
94
+ if (!isReplaceableReportFile(target)) {
95
+ return { ok: false, label: `Report-Datei: ${target}`, detail: "Die Datei existiert und ist kein früherer Harness-Report; der Proxy würde sie nicht überschreiben.", fix: notReplaceableMessage("--report-file", target) };
96
+ }
97
+ const writable = await access(target, constants.W_OK).then(() => true, () => false);
98
+ return { ok: writable, label: `Report-Datei schreibbar: ${target}`, fix: "Datei schliessen oder Schreibrecht setzen." };
99
+ }
100
+ const dir = dirname(target);
101
+ const writable = await access(dir, constants.W_OK).then(() => true, () => false);
102
+ return { ok: writable, label: `Report-Verzeichnis schreibbar: ${dir}`, fix: "Verzeichnis anlegen oder Schreibrecht darauf geben." };
103
+ }
104
+ async function checkUpstreamProtocol(command, args, cwd, patterns) {
105
+ const transport = new StdioClientTransport({ command, args, cwd, stderr: "pipe" });
106
+ let stderrText = "";
107
+ transport.stderr?.on("data", (chunk) => {
108
+ stderrText += chunk.toString();
109
+ });
110
+ const client = new Client({ name: "cuonztech-doctor", version: "0" });
111
+ let timer;
112
+ const timeout = new Promise((_, reject) => {
113
+ timer = setTimeout(() => reject(new Error(`keine Antwort innerhalb von ${START_TIMEOUT_MS / 1000} s`)), START_TIMEOUT_MS);
114
+ });
115
+ try {
116
+ await Promise.race([client.connect(transport), timeout]);
117
+ const { tools } = await Promise.race([client.listTools(), timeout]);
118
+ const classified = tools.map((t) => classifyUpstreamTool(t, patterns));
119
+ const unclassified = classified.filter((c) => c.class === "unclassified");
120
+ return {
121
+ checks: [
122
+ { ok: true, label: "Upstream-Server startet und antwortet (MCP initialize)" },
123
+ {
124
+ ok: tools.length > 0,
125
+ label: `Tools gefunden: ${tools.length}`,
126
+ fix: "Der Server meldet keine Tools. Prüfen, ob der richtige Server gestartet wird.",
127
+ },
128
+ {
129
+ ok: unclassified.length === 0,
130
+ label: unclassified.length === 0
131
+ ? "Alle Tools eindeutig als Lese- oder Schreib-Tool eingeordnet"
132
+ : `${unclassified.length} Tool(s) nicht eindeutig einordenbar: ${unclassified.map((c) => c.name).join(", ")}`,
133
+ fix: "Mit --write-tools bzw. --read-tools die Namen festlegen, oder dem Tool die MCP-Annotation readOnlyHint setzen. Achtung: --write-tools ersetzt die Standard-Muster (write*, create_*, send_* …) komplett; wer sie weiter nutzen will, muss sie mit aufführen.",
134
+ },
135
+ ],
136
+ toolLines: classified.map((c) => ` ${c.class.padEnd(12)} ${c.name} (${c.source})`),
137
+ schemaAudit: auditToolSchemas(tools, patterns),
138
+ };
139
+ }
140
+ catch (err) {
141
+ const tail = stderrText.trim().split("\n").slice(-8).map((l) => ` ${l}`).join("\n");
142
+ const detail = `${err.message}${tail ? `\n Upstream-Fehlerausgabe:\n${tail}` : ""}`;
143
+ return {
144
+ checks: [
145
+ {
146
+ ok: false,
147
+ label: "Upstream-Server startet und antwortet (MCP initialize)",
148
+ detail,
149
+ fix: "Startbefehl und Argumente prüfen. Die Ausgabe oben zeigt, was der Server selbst gemeldet hat.",
150
+ },
151
+ ],
152
+ toolLines: [],
153
+ };
154
+ }
155
+ finally {
156
+ if (timer)
157
+ clearTimeout(timer);
158
+ await client.close().catch(() => undefined);
159
+ }
160
+ }
161
+ function printCheck(check) {
162
+ process.stdout.write(`${check.ok ? "OK " : "FEHLER "} ${check.label}\n`);
163
+ if (check.detail)
164
+ process.stdout.write(` ${check.detail}\n`);
165
+ if (!check.ok && check.fix)
166
+ process.stdout.write(` → ${check.fix}\n`);
167
+ }
168
+ // Alle Prüfungen als Daten, damit Kommandozeile und Oberfläche dasselbe nutzen.
169
+ export async function collectDoctor(input) {
170
+ const checks = [checkNode()];
171
+ let toolLines = [];
172
+ let schemaAudit;
173
+ if (input.command === undefined || input.command.trim() === "") {
174
+ checks.push({
175
+ ok: false,
176
+ label: "--upstream-command angegeben",
177
+ fix: "Beispiel: doctor --upstream-command node --upstream-arg <pfad\\zu\\server.js>",
178
+ });
179
+ }
180
+ else {
181
+ const cwd = resolve(input.cwd ?? process.cwd());
182
+ checks.push({ ok: true, label: `--upstream-command: ${input.command}` });
183
+ if (input.cwd !== undefined) {
184
+ const isDir = await stat(cwd).then((st) => st.isDirectory(), () => false);
185
+ checks.push({ ok: isDir, label: `--upstream-cwd ist ein Ordner: ${cwd}`, fix: "Den Ordner anlegen oder den Pfad korrigieren." });
186
+ }
187
+ const scriptCheck = checkUpstreamScript(input.command, input.upstreamArgs, cwd);
188
+ if (scriptCheck)
189
+ checks.push(scriptCheck);
190
+ if (scriptCheck?.ok !== false) {
191
+ const protocol = await checkUpstreamProtocol(input.command, input.upstreamArgs, cwd, {
192
+ writeToolPatterns: input.writeTools,
193
+ readToolPatterns: input.readTools,
194
+ });
195
+ checks.push(...protocol.checks);
196
+ toolLines = protocol.toolLines;
197
+ schemaAudit = protocol.schemaAudit;
198
+ }
199
+ }
200
+ if (input.reportFile !== undefined) {
201
+ checks.push(await checkReportFile(input.reportFile));
202
+ }
203
+ return { checks, toolLines, ok: checks.every((c) => c.ok), ...(schemaAudit ? { schemaAudit } : {}) };
204
+ }
205
+ export async function runDoctor(argv) {
206
+ process.stdout.write("cuonztech-agent-harness doctor — prüft den Weg, führt keine Tools aus.\n\n");
207
+ if (argv.includes("--help") || argv.includes("-h")) {
208
+ process.stdout.write("Nutzung: doctor --upstream-command <cmd> [--upstream-arg <arg> ...] [--upstream-cwd <dir>] [--write-tools a,b] [--read-tools a,b] [--report-file <datei>]\n\n" +
209
+ "Startet deinen MCP-Server, fragt nur initialize und tools/list ab und prüft Node-Version, Start-Datei, Tool-Einordnung und Report-Pfad.\n" +
210
+ "Es wird nie ein Tool ausgeführt. Optionen wie beim Proxy.\n");
211
+ return 0;
212
+ }
213
+ const parsed = parseDoctorArgs(argv);
214
+ if (parsed.empty.length > 0) {
215
+ process.stdout.write(`Option(en) ohne Wert: ${[...new Set(parsed.empty)].join(" ")}\n`);
216
+ return 1;
217
+ }
218
+ if (parsed.unknown.length > 0) {
219
+ process.stdout.write(`Unbekannte Option(en): ${parsed.unknown.join(" ")}\n`);
220
+ process.stdout.write("Nutzung: doctor --upstream-command <cmd> [--upstream-arg <arg> ...] [--upstream-cwd <dir>] [--write-tools a,b] [--read-tools a,b] [--report-file <datei>]\n");
221
+ return 1;
222
+ }
223
+ const { checks, toolLines, schemaAudit } = await collectDoctor(parsed);
224
+ checks.forEach(printCheck);
225
+ if (toolLines.length > 0) {
226
+ process.stdout.write("\nErkannte Tools (nur Einordnung, nichts wurde ausgeführt):\n");
227
+ toolLines.forEach((line) => process.stdout.write(`${line}\n`));
228
+ }
229
+ if (schemaAudit && schemaAudit.totalTools > 0) {
230
+ process.stdout.write("\nSchnittstellen-Prüfung (nur Schema, nichts wurde aufgerufen):\n");
231
+ process.stdout.write(` ${schemaAudit.summaryDe}\n`);
232
+ for (const t of schemaAudit.tools.filter((e) => e.riskLevel === "HIGH")) {
233
+ process.stdout.write(` HOCH: ${shown(t.name)}\n`);
234
+ }
235
+ }
236
+ const failed = checks.filter((c) => !c.ok).length;
237
+ process.stdout.write(failed === 0
238
+ ? "\nAlles bereit. Der Proxy-Befehl kann gestartet werden.\n"
239
+ : `\n${failed} Punkt(e) fehlen. Die Lösungen stehen jeweils darunter.\n`);
240
+ return failed === 0 ? 0 : 1;
241
+ }