cuonztech-agent-harness 0.5.0 → 0.5.2

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.
@@ -6,6 +6,7 @@
6
6
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
7
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
8
8
  import { VERSION } from "../version.js";
9
+ import { readFileSync, readdirSync } from "node:fs";
9
10
  import { createSession, incrementCall, addRecord, classifyTool, extractKey, isKeyField, isReadTool, matchesAnyGlob, releaseScenarioFire, DEFAULT_WRITE_TOOL_PATTERNS, } from "../engine/state-machine.js";
10
11
  import { decideChaosAction, handleGhostWrite, buildCallRecord, } from "./ghost-write.js";
11
12
  function sleep(ms) {
@@ -36,7 +37,12 @@ export function classifyUpstreamTool(tool, config) {
36
37
  return { name, class: "write", source: "annotation destructiveHint=true" };
37
38
  }
38
39
  // Explicit --write-tools replaces the default pattern list (documented).
39
- if (explicitWrite.length === 0 && matchesAnyGlob(name, DEFAULT_WRITE_TOOL_PATTERNS)) {
40
+ // The defaults are snake_case ("create_*"); a camelCase name is matched
41
+ // through its snake_case form, so "createAppointment" is a write too.
42
+ const snake = name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
43
+ if (explicitWrite.length === 0 &&
44
+ (matchesAnyGlob(name, DEFAULT_WRITE_TOOL_PATTERNS) ||
45
+ matchesAnyGlob(snake, DEFAULT_WRITE_TOOL_PATTERNS))) {
40
46
  return { name, class: "write", source: "default write pattern" };
41
47
  }
42
48
  if (isReadTool(name)) {
@@ -46,6 +52,61 @@ export function classifyUpstreamTool(tool, config) {
46
52
  }
47
53
  const STDERR_TAIL_CHARS = 2000;
48
54
  const MAX_TOOL_LIST_PAGES = 1000;
55
+ // Linux only (/proc). [ppid, starttime] of a process, or null if it is gone.
56
+ function procStat(pid) {
57
+ try {
58
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
59
+ // The command name (field 2) may contain spaces and parentheses.
60
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
61
+ return { ppid: Number(fields[1]), startTime: fields[19] };
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function listDescendants(root) {
68
+ if (root === null || process.platform !== "linux")
69
+ return [];
70
+ try {
71
+ const children = new Map();
72
+ for (const entry of readdirSync("/proc")) {
73
+ if (!/^\d+$/.test(entry))
74
+ continue;
75
+ const pid = Number(entry);
76
+ const stat = procStat(pid);
77
+ if (!stat)
78
+ continue;
79
+ const list = children.get(stat.ppid) ?? [];
80
+ list.push({ pid, startTime: stat.startTime });
81
+ children.set(stat.ppid, list);
82
+ }
83
+ const found = [];
84
+ const queue = [root];
85
+ while (queue.length > 0 && found.length < 1000) {
86
+ for (const child of children.get(queue.shift()) ?? []) {
87
+ found.push(child);
88
+ queue.push(child.pid);
89
+ }
90
+ }
91
+ return found;
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ }
97
+ function killSurvivors(processes) {
98
+ for (const ref of processes) {
99
+ // Same pid AND same start time: never a process that reused the number.
100
+ if (procStat(ref.pid)?.startTime !== ref.startTime)
101
+ continue;
102
+ try {
103
+ process.kill(ref.pid, "SIGKILL");
104
+ }
105
+ catch {
106
+ // already gone
107
+ }
108
+ }
109
+ }
49
110
  export class ProxyInterceptor {
50
111
  upstreamClient = null;
51
112
  upstreamTransport = null;
@@ -56,7 +117,9 @@ export class ProxyInterceptor {
56
117
  upstreamAlive = false;
57
118
  closing = false;
58
119
  stderrTail = "";
59
- // Called after every intercepted call has been recorded (--report-file).
120
+ // Called when a call is forwarded upstream and again once it has been
121
+ // recorded (--report-file). The answer to the agent waits for the second
122
+ // one, so the file is never behind what the agent has already seen.
60
123
  onCallRecorded = null;
61
124
  // Called once if the upstream connection drops while the proxy is running.
62
125
  onUpstreamLost = null;
@@ -99,7 +162,27 @@ export class ProxyInterceptor {
99
162
  this.onUpstreamLost?.(this.stderrTail);
100
163
  }
101
164
  };
102
- await this.upstreamClient.connect(this.upstreamTransport);
165
+ // connect() spawns the process before its first await.
166
+ const connecting = this.upstreamClient.connect(this.upstreamTransport);
167
+ const pid = this.upstreamTransport.pid;
168
+ try {
169
+ await connecting;
170
+ }
171
+ catch (err) {
172
+ // The SDK starts closing the transport on its own but does not wait
173
+ // for it; this process is about to exit, which left an upstream that
174
+ // never answered (a hanging start) running. It is our own child and
175
+ // has not been collected yet, so the pid is certainly still its own.
176
+ if (pid !== null) {
177
+ try {
178
+ process.kill(pid, "SIGKILL");
179
+ }
180
+ catch {
181
+ // already gone
182
+ }
183
+ }
184
+ throw err;
185
+ }
103
186
  this.upstreamAlive = true;
104
187
  // Discover upstream tools (full definitions, including inputSchema, so
105
188
  // the gateway can advertise them to its own client unchanged). The list
@@ -117,6 +200,7 @@ export class ProxyInterceptor {
117
200
  this.upstreamTools = discovered;
118
201
  this.session.knownToolsOnly = true;
119
202
  this.session.kind = "proxy";
203
+ this.session.pending = new Map();
120
204
  this.toolClassification = this.upstreamTools.map((tool) => classifyUpstreamTool(tool, this.config));
121
205
  for (const entry of this.toolClassification) {
122
206
  this.session.toolClassOverrides.set(entry.name, entry.class);
@@ -130,10 +214,18 @@ export class ProxyInterceptor {
130
214
  }
131
215
  async disconnectUpstream() {
132
216
  this.closing = true;
217
+ // A wrapper command ("sh -c ...", "npx ...") ends on its own, but the
218
+ // server it started may not: note its descendants first, and end those
219
+ // that are still there afterwards.
220
+ const descendants = listDescendants(this.upstreamTransport?.pid ?? null);
133
221
  if (this.upstreamClient) {
134
- await this.upstreamClient.close();
222
+ await this.upstreamClient.close().catch(() => undefined);
135
223
  this.upstreamClient = null;
136
224
  }
225
+ // After a failed start the client never took over the transport.
226
+ await this.upstreamTransport?.close().catch(() => undefined);
227
+ this.upstreamTransport = null;
228
+ killSurvivors(descendants);
137
229
  }
138
230
  getUpstreamTools() {
139
231
  return this.upstreamTools;
@@ -156,6 +248,16 @@ export class ProxyInterceptor {
156
248
  return [...this.session.keyCapableTools];
157
249
  }
158
250
  async interceptToolCall(toolName, args) {
251
+ const result = await this.handleToolCall(toolName, args);
252
+ try {
253
+ await this.onCallRecorded?.();
254
+ }
255
+ catch {
256
+ // Reporting must never change what the agent gets back.
257
+ }
258
+ return result;
259
+ }
260
+ async handleToolCall(toolName, args) {
159
261
  // Everything up to the first await runs synchronously, so two calls
160
262
  // dispatched in parallel still get distinct, ordered write positions —
161
263
  // only one of them can be "the first write" a scenario fires on.
@@ -163,16 +265,10 @@ export class ProxyInterceptor {
163
265
  const callNumber = incrementCall(this.session.sessionId);
164
266
  const mergedArgs = { ...args };
165
267
  const key = extractKey(mergedArgs, this.session.keyFields);
166
- // A tool the upstream executes but did not list: an explicit
167
- // --write-tools/--read-tools pattern still applies to it.
168
- if (!this.session.toolClassOverrides.has(toolName)) {
169
- if (matchesAnyGlob(toolName, this.config.writeToolPatterns ?? [])) {
170
- this.session.toolClassOverrides.set(toolName, "write");
171
- }
172
- else if (matchesAnyGlob(toolName, this.config.readToolPatterns ?? [])) {
173
- this.session.toolClassOverrides.set(toolName, "read");
174
- }
175
- }
268
+ // A tool the upstream did not list is unclassified whatever its name
269
+ // and whatever --write-tools says: F2/F3/F5 answer without asking the
270
+ // upstream, so a call to a tool that does not exist would otherwise use
271
+ // up the scenario and the real write would never see a failure.
176
272
  const toolClass = classifyTool(this.session, toolName);
177
273
  // A dead upstream is reported as exactly that. No scenario is applied:
178
274
  // an injected "timeout" on top would hide the real problem.
@@ -197,6 +293,29 @@ export class ProxyInterceptor {
197
293
  let upstreamExecuted = false;
198
294
  let upstreamFailure = null;
199
295
  if (decision.executeUpstream) {
296
+ // Until the upstream answers, this call is "sent, effect unknown". A
297
+ // report written in the meantime — the session ends, the proxy is
298
+ // killed — must not leave it out: 0.5.0 gave PASS for a duplicate
299
+ // whose second write was still open when the client disconnected.
300
+ this.session.pending?.set(callNumber, {
301
+ ...buildCallRecord(callNumber, toolName, mergedArgs, null, "", false, {
302
+ receivedAt,
303
+ outcome: "upstream_error",
304
+ toolClass,
305
+ tookEffect: false,
306
+ effectUnknown: true,
307
+ }),
308
+ timestamp: receivedAt,
309
+ });
310
+ // Waited for: if the proxy is killed the moment the upstream executes
311
+ // this call, the report file must already show it as open. (Not
312
+ // waiting left a 1-2 ms window in which the file still read PASS.)
313
+ try {
314
+ await this.onCallRecorded?.();
315
+ }
316
+ catch {
317
+ // see interceptToolCall
318
+ }
200
319
  try {
201
320
  const result = (await this.upstreamClient.callTool({
202
321
  name: toolName,
@@ -218,7 +337,9 @@ export class ProxyInterceptor {
218
337
  }
219
338
  }
220
339
  catch (err) {
221
- upstreamFailure = err instanceof Error ? err.message : String(err);
340
+ // An upstream that throws an McpError gets its "MCP error <code>: "
341
+ // prefix added a second time by the SDK client.
342
+ upstreamFailure = (err instanceof Error ? err.message : String(err)).replace(/^(MCP error -?\d+: )\1+/, "$1");
222
343
  }
223
344
  }
224
345
  // The call to the real upstream itself failed without an answer
@@ -305,7 +426,10 @@ export class ProxyInterceptor {
305
426
  tookEffect: toolClass === "write" && !upstreamIsError,
306
427
  scenarioTriggered: decision.scenarioTriggered && !ghostRejected,
307
428
  upstreamResponse: upstreamText ?? "",
308
- }), { ...upstreamResult, content, isError: upstreamIsError });
429
+ }),
430
+ // isError is passed on only if the upstream sent one: adding
431
+ // "isError: false" to a success would change the upstream's answer.
432
+ { ...upstreamResult, content });
309
433
  }
310
434
  // No scenario triggered and no upstream call was attempted — cannot
311
435
  // happen with the current decision logic; answered as an error rather
@@ -322,11 +446,11 @@ export class ProxyInterceptor {
322
446
  }), { content: [{ type: "text", text }], isError: true });
323
447
  }
324
448
  finish(record, result, ghostUpstreamResponse = null) {
449
+ this.session.pending?.delete(record.callNumber);
325
450
  addRecord(this.session.sessionId, record);
326
451
  if (ghostUpstreamResponse) {
327
452
  handleGhostWrite(this.session, record.args, ghostUpstreamResponse);
328
453
  }
329
- this.onCallRecorded?.();
330
454
  return result;
331
455
  }
332
456
  }
@@ -59,7 +59,7 @@ export const F3_MALFORMED_JSON = {
59
59
  // F4: Duplicate Dispatch
60
60
  export const F4_DUPLICATE_DISPATCH = {
61
61
  id: "F4",
62
- name: "Duplicate Dispatch (Ghost-Write)",
62
+ name: "Duplicate Dispatch (no injected failure)",
63
63
  description: "Es wird nichts injiziert: die ersten beiden Schreibversuche gehen unverändert durch. Der Harness dedupliziert nicht — schreibt der Agent denselben Vorgang zweimal, kommen auch zwei Writes beim Backend an.",
64
64
  triggerCondition: ({ priorWriteCalls, isWriteCall }) => priorWriteCalls <= 1 && isWriteCall,
65
65
  // Only used by the non-proxy session/benchmark simulation (tools/handlers.ts),
@@ -1,53 +1,106 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
3
+ import { z } from "zod";
2
4
  import { VERSION } from "../version.js";
3
5
  import { StartSessionSchema, ExecuteCallSchema, GetReportSchema, GetScoreSchema, ListScenariosSchema, ResetSessionSchema, DeleteSessionSchema, } from "../tools/schemas.js";
4
6
  import { handleStartSession, handleExecuteCall, handleGetReport, handleGetScore, handleListScenarios, handleResetSession, handleDeleteSession, } from "../tools/handlers.js";
7
+ // Keywords that strict tool-calling stacks (vLLM/XGrammar and others) reject
8
+ // or cannot compile. They add nothing here: the arguments are validated by
9
+ // the Zod schemas when the tool is called.
10
+ const DROPPED_SCHEMA_KEYS = new Set(["$schema", "pattern", "format", "propertyNames"]);
11
+ function plainSchema(value) {
12
+ if (Array.isArray(value))
13
+ return value.map(plainSchema);
14
+ if (value === null || typeof value !== "object")
15
+ return value;
16
+ const out = {};
17
+ for (const [key, child] of Object.entries(value)) {
18
+ if (DROPPED_SCHEMA_KEYS.has(key))
19
+ continue;
20
+ // "properties" maps argument names to schemas: an argument may itself
21
+ // be called "format" and must not be dropped.
22
+ if (key === "properties" && child !== null && typeof child === "object") {
23
+ out[key] = Object.fromEntries(Object.entries(child).map(([name, schema]) => [
24
+ name,
25
+ plainSchema(schema),
26
+ ]));
27
+ continue;
28
+ }
29
+ if (key === "additionalProperties" && child !== null && typeof child === "object" &&
30
+ Object.keys(child).length === 0) {
31
+ out[key] = true;
32
+ continue;
33
+ }
34
+ out[key] = plainSchema(child);
35
+ }
36
+ return out;
37
+ }
5
38
  export function createServer() {
39
+ const listed = [];
6
40
  const server = new McpServer({
7
41
  name: "cuonztech-agent-harness",
8
42
  version: VERSION,
9
43
  });
44
+ // Passes a tool's config through unchanged and keeps it for the
45
+ // hand-built tools/list below.
46
+ const keep = (name, config) => {
47
+ listed.push({ name, ...config });
48
+ return config;
49
+ };
10
50
  // Tool: start_session
11
- server.registerTool("start_session", {
51
+ server.registerTool("start_session", keep("start_session", {
12
52
  title: "Start Test Session",
13
53
  description: "Creates an isolated test session with optional scenario (F1–F5) and mode (deterministic/chaos). Returns a sessionId for subsequent calls. Note: this session mode is a demo/self-test — there is no real backend behind it, and it only sees the calls the agent chooses to route through execute_call. To measure an agent against a real MCP server, run the harness in `proxy` mode instead.",
14
54
  inputSchema: StartSessionSchema,
15
- }, async (args) => handleStartSession(args));
55
+ }), async (args) => handleStartSession(args));
16
56
  // Tool: execute_call
17
- server.registerTool("execute_call", {
57
+ server.registerTool("execute_call", keep("execute_call", {
18
58
  title: "Execute Tracked Call",
19
59
  description: "Executes a tool call within an active session against a simulated backend. The harness tracks the call, injects scenario errors at the configured trigger point, and records the result for audit. Reads return a generic ok without data.",
20
60
  inputSchema: ExecuteCallSchema,
21
- }, async (args) => handleExecuteCall(args));
61
+ }), async (args) => handleExecuteCall(args));
22
62
  // Tool: get_report
23
- server.registerTool("get_report", {
63
+ server.registerTool("get_report", keep("get_report", {
24
64
  title: "Get Audit Report",
25
65
  description: "Generates the audit report for a session. Returns markdown (chat-friendly) or JSON. Also persists JSON to .cuonztech/audit-report.json.",
26
66
  inputSchema: GetReportSchema,
27
- }, async (args) => handleGetReport(args));
67
+ }), async (args) => handleGetReport(args));
28
68
  // Tool: get_score
29
- server.registerTool("get_score", {
69
+ server.registerTool("get_score", keep("get_score", {
30
70
  title: "Get Resilience Score",
31
71
  description: "Scores THIS session's own audit report (0-100: Duplicate Safety 60%, Failure Handling 40%; honesty is not measured) and generates copy-pasteable system-prompt hardening patches for whatever violations the session actually produced. Returns n/a instead of a score when nothing was tested. Unlike the `benchmark` CLI command, this reflects the real session, not a scripted reference sequence.",
32
72
  inputSchema: GetScoreSchema,
33
- }, async (args) => handleGetScore(args));
73
+ }), async (args) => handleGetScore(args));
34
74
  // Tool: list_scenarios
35
- server.registerTool("list_scenarios", {
75
+ server.registerTool("list_scenarios", keep("list_scenarios", {
36
76
  title: "List Available Scenarios",
37
77
  description: "Returns all available deterministic failure scenarios (F1–F5) with descriptions and audit metrics.",
38
78
  inputSchema: ListScenariosSchema,
39
- }, async () => handleListScenarios());
79
+ }), async () => handleListScenarios());
40
80
  // Tool: reset_session
41
- server.registerTool("reset_session", {
81
+ server.registerTool("reset_session", keep("reset_session", {
42
82
  title: "Reset Session",
43
83
  description: "Resets a session's call counter and history without deleting it. Useful for re-running the same scenario.",
44
84
  inputSchema: ResetSessionSchema,
45
- }, async (args) => handleResetSession(args));
85
+ }), async (args) => handleResetSession(args));
46
86
  // Tool: delete_session
47
- server.registerTool("delete_session", {
87
+ server.registerTool("delete_session", keep("delete_session", {
48
88
  title: "Delete Session",
49
89
  description: "Permanently deletes a session and its state.",
50
90
  inputSchema: DeleteSessionSchema,
51
- }, async (args) => handleDeleteSession(args));
91
+ }), async (args) => handleDeleteSession(args));
92
+ // The SDK's generated tools/list carries "$schema", a UUID "pattern" and
93
+ // "format" on every session_id, "propertyNames" and an "execution" block.
94
+ // An external test lost 69 of 100 runs to a tool-calling stack that could
95
+ // not compile those. This replaces the listing only; calls still go
96
+ // through the SDK's own handler and Zod validation.
97
+ server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
98
+ tools: listed.map((tool) => ({
99
+ name: tool.name,
100
+ title: tool.title,
101
+ description: tool.description,
102
+ inputSchema: plainSchema(z.toJSONSchema(z.object(tool.inputSchema), { io: "input" })),
103
+ })),
104
+ }));
52
105
  return server;
53
106
  }
@@ -44,7 +44,7 @@ export const StartSessionSchema = {
44
44
  .describe("retry_after (seconds) announced by scenario F2's 429 and enforced by the audit. Default 60."),
45
45
  };
46
46
  export const ExecuteCallSchema = {
47
- session_id: z.string().uuid().describe("Active session ID."),
47
+ session_id: z.string().uuid().describe("Active session ID (the UUID returned by start_session)."),
48
48
  tool_name: z.string().describe("Name of the tool the agent is calling."),
49
49
  arguments: z
50
50
  .record(z.string(), z.unknown())
@@ -55,7 +55,7 @@ export const ExecuteCallSchema = {
55
55
  .describe("Idempotency key for duplicate detection."),
56
56
  };
57
57
  export const GetReportSchema = {
58
- session_id: z.string().uuid().describe("Session ID to audit."),
58
+ session_id: z.string().uuid().describe("Session ID to audit (the UUID returned by start_session)."),
59
59
  format: z
60
60
  .enum(["json", "markdown"])
61
61
  .optional()
@@ -63,7 +63,7 @@ export const GetReportSchema = {
63
63
  .describe("Output format: markdown (default) or json."),
64
64
  };
65
65
  export const GetScoreSchema = {
66
- session_id: z.string().uuid().describe("Session ID to score."),
66
+ session_id: z.string().uuid().describe("Session ID to score (the UUID returned by start_session)."),
67
67
  format: z
68
68
  .enum(["json", "markdown"])
69
69
  .optional()
@@ -72,8 +72,8 @@ export const GetScoreSchema = {
72
72
  };
73
73
  export const ListScenariosSchema = {};
74
74
  export const ResetSessionSchema = {
75
- session_id: z.string().uuid().describe("Session ID to reset."),
75
+ session_id: z.string().uuid().describe("Session ID to reset (the UUID returned by start_session)."),
76
76
  };
77
77
  export const DeleteSessionSchema = {
78
- session_id: z.string().uuid().describe("Session ID to delete."),
78
+ session_id: z.string().uuid().describe("Session ID to delete (the UUID returned by start_session)."),
79
79
  };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "cuonztech-agent-harness",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
+ "mcpName": "io.github.cuonztech/agent-harness",
4
5
  "description": "Chaos-Proxy für MCP-Server: injiziert F1-F5-Fehlerszenarien (Ghost-Write nach Timeout, 429, defektes JSON, Duplicate Dispatch, 500) vor einem echten Upstream-MCP-Server und prüft, ob ein Agent Schreibvorgänge doppelt ausführt. Mit Resilience Score und System-Prompt-Patches für beobachtetes Fehlverhalten.",
5
6
  "type": "module",
6
7
  "main": "dist/index.js",
@@ -45,7 +46,8 @@
45
46
  "files": [
46
47
  "dist/**/*.js",
47
48
  "README.md",
48
- "LICENSE"
49
+ "LICENSE",
50
+ "server.json"
49
51
  ],
50
52
  "dependencies": {
51
53
  "@modelcontextprotocol/sdk": "^1.32.1",
package/server.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.cuonztech/agent-harness",
4
+ "description": "MCP chaos-testing toolkit. Default: simulated demo. Configure proxy mode to test a real agent.",
5
+ "repository": {
6
+ "url": "https://github.com/cuonztech/agent-harness",
7
+ "source": "github"
8
+ },
9
+ "version": "0.5.2",
10
+ "packages": [
11
+ {
12
+ "registryType": "npm",
13
+ "identifier": "cuonztech-agent-harness",
14
+ "version": "0.5.2",
15
+ "transport": {
16
+ "type": "stdio"
17
+ }
18
+ }
19
+ ]
20
+ }