Skip to content

Commit 403cdd6

Browse files
sanchitrkclaude
andcommitted
fix(harness): harden MCP config values and send servers clean arguments
- Refuse env and header values with a line break or NUL without quoting them. - Run a !command with stdin closed, and reuse its output for the life of the process. - Report auth/oauth entries as unsupported instead of ignoring the keys. - Drop the nulls strict sampling writes for omitted optional arguments before the call. - Validate JSON Schema patterns instead of skipping the whole schema. - Refresh a changed tool list without holding the server lock. - Retry a server whose connect failed at the next exchange, in the background. - Stop removed or changed servers without making the prompt wait. - Keep hashed tool names within 64 characters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent de5fc62 commit 403cdd6

9 files changed

Lines changed: 124 additions & 35 deletions

File tree

‎packages/harness/src/mcp/config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export const validate = (name: string, value: unknown): ServerConfig | string =>
5555
return `server "${name}": description must be a string`;
5656
if (timeout !== undefined && !(Predicate.isNumber(timeout) && timeout > 0))
5757
return `server "${name}": timeout must be a positive number of seconds`;
58+
// Until sign-in exists, an entry relying on it could only fail; say so instead of ignoring the key.
59+
for (const key of ["auth", "oauth"])
60+
if (value[key] !== undefined) return `server "${name}": "${key}" is not supported yet`;
5861
if (type === "sse") return `server "${name}": legacy SSE transport is not supported; use the streamable HTTP URL`;
5962

6063
if (Predicate.isString(value.url) && (type === undefined || type === "http" || type === "streamable-http")) {

‎packages/harness/src/mcp/mcp.ts‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -194,21 +194,22 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string
194194
);
195195
// Calls replace an expired session themselves; this covers a background request seeing it first.
196196
live.connection.onSessionExpired(current(retire(server, live)));
197+
// Listed without the server's lock, which calls and closing need: the listing can take a while.
197198
live.connection.onToolsChanged(
198-
current(
199-
live.connection.tools().pipe(
200-
Effect.tap((tools) =>
201-
Effect.sync(() => {
202-
server.tools = tools;
203-
}),
204-
),
205-
Effect.catch((error) =>
206-
Effect.sync(() => {
207-
server.error = `failed to refresh tools: ${error.message}`;
199+
Effect.sync(() => {
200+
fork(
201+
live.connection.tools().pipe(
202+
Effect.match({
203+
onSuccess: (tools) => {
204+
if (server.live === live) server.tools = tools;
205+
},
206+
onFailure: (error) => {
207+
if (server.live === live) server.error = `failed to refresh tools: ${error.message}`;
208+
},
208209
}),
209210
),
210-
),
211-
),
211+
);
212+
}),
212213
);
213214
live.connection.onLog((entry) => log.write(server.name, entry));
214215
};
@@ -392,10 +393,14 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string
392393
// Exposure and description apply to the next exchange without reconnecting.
393394
if (config !== undefined && connectionKey(config, cwd, roots) === server.key) {
394395
server.config = config;
396+
// A server that could not connect declares no tools, so no call would retry it.
397+
// Retry in the background; waiting for it would hold up every prompt while it is down.
398+
if (server.state === "failed" && server.attempt === undefined) start(server);
395399
continue;
396400
}
397401
session.servers.delete(name);
398-
yield* close(server);
402+
// Stopping can take seconds; the prompt does not wait for it.
403+
fork(close(server));
399404
}
400405
for (const [name, config] of wanted) {
401406
if (session.servers.has(name)) continue;

‎packages/harness/src/mcp/tool.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* reaches it.
44
*/
55

6+
import { normalizeOptionalNulls } from "@codeworksh/aikit";
67
import type { Exposure, ServerConfig } from "@codeworksh/plugin/plugin/mcp";
78
import type { CallToolResult, Tool as McpTool } from "@modelcontextprotocol/client";
89
import { Effect, Schema } from "effect";
@@ -26,7 +27,8 @@ export const name = (server: string, tool: string, taken: (candidate: string) =>
2627
if (plain.length <= MAX_NAME_LENGTH && !taken(plain)) return plain;
2728
const hash = Hash.sha256(`${server}\0${tool}`);
2829
let candidate = plain;
29-
for (let length = 8; length <= hash.length; length += 8) {
30+
// The executor refuses two tools of one name, so the hash grows, while `mcp__` still fits before it.
31+
for (let length = 8; length <= MAX_NAME_LENGTH - "mcp__".length - 1; length += 8) {
3032
candidate = `${plain.slice(0, MAX_NAME_LENGTH - length - 1)}_${hash.slice(0, length)}`;
3133
if (!taken(candidate)) return candidate;
3234
}
@@ -99,6 +101,12 @@ const parameters = (schema: McpTool["inputSchema"]): Record<string, unknown> =>
99101
...(schema.properties === undefined ? { properties: {} } : {}),
100102
});
101103

104+
const withoutOptionalNulls = (args: Record<string, unknown>, schema: Record<string, unknown>) => {
105+
const normalized = structuredClone(args);
106+
normalizeOptionalNulls(normalized, schema);
107+
return normalized;
108+
};
109+
102110
export interface Call {
103111
readonly args: Record<string, unknown>;
104112
readonly onProgress: (progress: Progress) => void;
@@ -122,26 +130,28 @@ export const make = (input: {
122130
server,
123131
tool: tool.name,
124132
});
133+
const schema = parameters(tool.inputSchema);
125134
return Tool.register(
126135
Tool.make({
127136
name: input.name,
128137
label: `${server}/${tool.name}`,
129138
description: tool.description?.trim() || title || `MCP tool ${tool.name} from server ${server}`,
130-
parameters: Tool.fromJsonSchema(parameters(tool.inputSchema)),
139+
parameters: Tool.fromJsonSchema(schema),
131140
success: Outcome,
132141
failure: Outcome,
133142
encodeContent: (outcome) => outcome.content as McpContent.Content[],
134143
encodeFailureContent: (outcome) => outcome.content as McpContent.Content[],
135144
encodeDetails: ({ content: _content, ...details }) => details,
136-
// The arguments as the model wrote them: validation only checks them, and the server, not
137-
// a translation of its schema, decides what they mean.
145+
// The arguments as the model wrote them, minus the `null`s strict sampling writes for omitted
146+
// optional ones: validation only checks them, and the server, not a translation of its schema,
147+
// decides what they mean.
138148
handler: (_params, ctx) =>
139149
Effect.gen(function* () {
140150
const progress = yield* ToolProgress;
141151
const report = Effect.runForkWith(yield* Effect.context<never>());
142152
const result = yield* input
143153
.call({
144-
args: ctx.rawArgs,
154+
args: withoutOptionalNulls(ctx.rawArgs, schema),
145155
onProgress: (update) => {
146156
const total = update.total === undefined ? "" : `/${update.total}`;
147157
const text = update.message ?? `Progress ${update.progress}${total}`;

‎packages/harness/src/mcp/value.ts‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
/**
22
* Values of a server's `env` and `headers`, resolved when the server connects:
33
*
4-
* - `!command` runs the rest through `/bin/sh` and uses its trimmed stdout.
4+
* - `!command` runs the rest through `/bin/sh` and uses its trimmed stdout, once per process: later
5+
* connects reuse it. A command that fails runs again next time, since the host outlives sessions.
56
* - Otherwise `$NAME` and `${NAME}` are replaced by host environment variables; `$$` is a literal
67
* `$` and `$!` a literal `!`. Anything else is literal.
8+
*
9+
* A value holding a line break or NUL is refused without being shown: it cannot be sent as a header
10+
* or set as a variable, and the error the runtime would raise quotes the value, often a secret.
711
*/
812

913
import { Config, Duration, Effect, Option, Stream } from "effect";
@@ -12,6 +16,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
1216
const COMMAND_TIMEOUT = Duration.seconds(10);
1317
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
1418
const NAME_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*/;
19+
const UNSAFE = /[\r\n\0]/;
20+
const commands = new Map<string, string>();
1521

1622
type Part = { readonly type: "literal"; readonly value: string } | { readonly type: "env"; readonly name: string };
1723

@@ -66,7 +72,9 @@ const template = (value: string): ReadonlyArray<Part> => {
6672
const run = (command: string) =>
6773
Effect.gen(function* () {
6874
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
69-
const handle = yield* spawner.spawn(ChildProcess.make("/bin/sh", ["-c", command], { stderr: "ignore" }));
75+
const handle = yield* spawner.spawn(
76+
ChildProcess.make("/bin/sh", ["-c", command], { stdin: "ignore", stderr: "ignore" }),
77+
);
7078
const [output, code] = yield* Effect.all([Stream.mkString(Stream.decodeText(handle.stdout)), handle.exitCode], {
7179
concurrency: 2,
7280
});
@@ -79,14 +87,16 @@ const run = (command: string) =>
7987
Effect.orElseSucceed(() => undefined),
8088
);
8189

82-
/** One value. `what` names it in the failure, for example `MCP server "docs" header "Authorization"`. */
83-
export const resolve = Effect.fnUntraced(function* (value: string, what: string) {
84-
if (value.startsWith("!")) {
85-
const output = yield* run(value.slice(1));
86-
if (output === undefined)
87-
return yield* Effect.fail(`Failed to resolve ${what} from shell command: ${value.slice(1)}`);
88-
return output;
89-
}
90+
const command = Effect.fnUntraced(function* (line: string, what: string) {
91+
const cached = commands.get(line);
92+
if (cached !== undefined) return cached;
93+
const output = yield* run(line);
94+
if (output === undefined) return yield* Effect.fail(`Failed to resolve ${what} from shell command: ${line}`);
95+
commands.set(line, output);
96+
return output;
97+
});
98+
99+
const variables = Effect.fnUntraced(function* (value: string, what: string) {
90100
const parts = template(value);
91101
const names = [...new Set(parts.flatMap((part) => (part.type === "env" ? [part.name] : [])))];
92102
// An empty variable counts as unset.
@@ -103,6 +113,13 @@ export const resolve = Effect.fnUntraced(function* (value: string, what: string)
103113
return parts.map((part) => (part.type === "literal" ? part.value : env.get(part.name))).join("");
104114
});
105115

116+
/** One value. `what` names it in the failure, for example `MCP server "docs" header "Authorization"`. */
117+
export const resolve = Effect.fnUntraced(function* (value: string, what: string) {
118+
const resolved = yield* value.startsWith("!") ? command(value.slice(1), what) : variables(value, what);
119+
if (UNSAFE.test(resolved)) return yield* Effect.fail(`${what} contains a line break or NUL character`);
120+
return resolved;
121+
});
122+
106123
/** Every value of a record, failing on the first that does not resolve. */
107124
export const resolveAll = Effect.fnUntraced(function* (values: Readonly<Record<string, string>>, what: string) {
108125
const resolved: Record<string, string> = {};

‎packages/harness/test/__artifacts__/mcp.session.json‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"requests": 6,
33
"tools": [
44
"mcp__e2e__echo",
5+
"mcp__e2e__args",
56
"mcp__e2e__fail",
67
"mcp__e2e__pid",
78
"mcp__e2e__crash",
@@ -38,6 +39,10 @@
3839
[
3940
"my-srv",
4041
false
42+
],
43+
[
44+
"badheader",
45+
true
4146
]
4247
],
4348
[
@@ -52,6 +57,10 @@
5257
[
5358
"my-srv",
5459
false
60+
],
61+
[
62+
"badheader",
63+
true
5564
]
5665
]
5766
],
@@ -82,6 +91,13 @@
8291
"{\n \"version\": 2\n}"
8392
]
8493
},
94+
"nulls": {
95+
"status": "completed",
96+
"isError": false,
97+
"content": [
98+
"{\"word\":\"abc\"}"
99+
]
100+
},
85101
"pid-before": {
86102
"status": "completed",
87103
"isError": false,
@@ -109,13 +125,20 @@
109125
"content": [
110126
"Invalid arguments for mcp__e2e__echo: Missing key\n at [\"text\"]"
111127
]
128+
},
129+
"pattern": {
130+
"status": "error",
131+
"isError": true,
132+
"content": [
133+
"Invalid arguments for mcp__e2e__args: Expected a string matching the RegExp ^[a-z]+$\n at [\"word\"]"
134+
]
112135
}
113136
},
114137
"status": [
115138
{
116139
"server": "e2e",
117140
"state": "connected",
118-
"tools": 7
141+
"tools": 8
119142
},
120143
{
121144
"server": "off",
@@ -126,11 +149,18 @@
126149
"server": "my-srv",
127150
"state": "disabled",
128151
"tools": 0
152+
},
153+
{
154+
"server": "badheader",
155+
"state": "failed",
156+
"error": "MCP server \"badheader\" header \"Authorization\" contains a line break or NUL character",
157+
"tools": 0
129158
}
130159
],
131160
"configErrors": [
132161
"<tmp>/home/mcp.json: server \"my_srv\" conflicts with \"my-srv\"",
133162
"<tmp>/home/mcp.json: server \"broken\": url must be an http or https URL",
163+
"<tmp>/home/mcp.json: server \"signin\": \"oauth\" is not supported yet",
134164
"<tmp>/project/.codework/mcp.json: server \"ghost\" needs \"command\" or \"url\", or a user server to override"
135165
]
136166
}

‎packages/harness/test/__artifacts__/mcp.session.lifecycle.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"declared": [
5959
[
6060
"mcp__e2e__echo",
61+
"mcp__e2e__args",
6162
"mcp__e2e__fail",
6263
"mcp__e2e__pid",
6364
"mcp__e2e__crash",
@@ -68,6 +69,7 @@
6869
],
6970
[
7071
"mcp__e2e__echo",
72+
"mcp__e2e__args",
7173
"mcp__e2e__fail",
7274
"mcp__e2e__pid",
7375
"mcp__e2e__crash",
@@ -78,6 +80,7 @@
7880
],
7981
[
8082
"mcp__e2e__echo",
83+
"mcp__e2e__args",
8184
"mcp__e2e__fail",
8285
"mcp__e2e__pid",
8386
"mcp__e2e__crash",
@@ -88,6 +91,7 @@
8891
],
8992
[
9093
"mcp__e2e__echo",
94+
"mcp__e2e__args",
9195
"mcp__e2e__fail",
9296
"mcp__e2e__pid",
9397
"mcp__e2e__crash",
@@ -98,6 +102,7 @@
98102
[
99103
"mcp__remote__echo",
100104
"mcp__e2e__echo",
105+
"mcp__e2e__args",
101106
"mcp__e2e__fail",
102107
"mcp__e2e__pid",
103108
"mcp__e2e__crash",
@@ -130,7 +135,7 @@
130135
{
131136
"server": "e2e",
132137
"state": "connected",
133-
"tools": 7
138+
"tools": 8
134139
}
135140
]
136141
}

‎packages/harness/test/fixtures/mcp/session.mjs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ server.setRequestHandler("tools/list", () => ({
1313
description: "Echo text back, prefixed with the configured greeting.",
1414
inputSchema: object({ text: { type: "string", description: "What to echo." } }, ["text"]),
1515
},
16+
{
17+
name: "args",
18+
description: "The arguments as received.",
19+
inputSchema: object({ word: { type: "string", pattern: "^[a-z]+$" }, limit: { type: "integer" } }, ["word"]),
20+
},
1621
{ name: "fail", description: "Answer with an error result.", inputSchema: object() },
1722
{ name: "pid", description: "The server's process id.", inputSchema: object() },
1823
{ name: "crash", description: "Exit while the call is running.", inputSchema: object() },
@@ -26,6 +31,8 @@ server.setRequestHandler("tools/call", (request) => {
2631
switch (request.params.name) {
2732
case "echo":
2833
return text(`${process.env.E2E_GREETING}: ${String(args.text)}`);
34+
case "args":
35+
return text(JSON.stringify(args));
2936
case "fail":
3037
return { isError: true, content: [{ type: "text", text: "the fixture refused" }] };
3138
case "pid":

0 commit comments

Comments
 (0)