Skip to content

Commit 386dd54

Browse files
sanchitrkclaude
andcommitted
fix(harness): treat a blank MCP cursor as none and wait for connecting servers
- A blank cursor lists the first page; any other cursor goes back exactly as given. - Resource tools wait for servers still connecting, so none is left out of a listing. - Resource failures carry the server's own message; listings name the server beside it. - Docs: where stdio servers start off a local sandbox, $NAME values, command caching, sign-in, and retries after a failed connect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8e4f384 commit 386dd54

5 files changed

Lines changed: 83 additions & 50 deletions

File tree

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

Lines changed: 59 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -312,51 +312,54 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string
312312
* runs once more on a new one; nothing else is retried here.
313313
*/
314314
const request =
315-
(session: Session, name: string, what: string) =>
316-
<A>(
317-
run: (connection: McpClient.Connection) => Effect.Effect<A, McpClient.Failed>,
318-
/** Why the server, as it stands, no longer offers what is asked; checked before every attempt. */
319-
unavailable: (server: Server) => string | undefined = () => undefined,
320-
): Effect.Effect<A, string> =>
321-
Effect.gen(function* () {
322-
for (let attempt = 1; ; attempt++) {
323-
const server = session.servers.get(name);
324-
if (server === undefined || server.closed || server.config.enabled === false)
325-
return yield* Effect.fail(`MCP server "${name}" is disabled.`);
326-
const reason = unavailable(server);
327-
if (reason !== undefined) return yield* Effect.fail(reason);
328-
const live = yield* open(server).pipe(
329-
Effect.mapError((error) => `MCP server "${name}" is not connected: ${error}`),
330-
);
331-
live.inflight += 1;
332-
const exit = yield* run(live.connection).pipe(
333-
Effect.exit,
334-
Effect.ensuring(
335-
Effect.suspend(() => {
336-
live.inflight -= 1;
337-
return release(live);
338-
}),
339-
),
340-
);
341-
if (Exit.isSuccess(exit)) return exit.value;
342-
const error = Cause.findErrorOption(exit.cause);
343-
if (error._tag === "None") return yield* Effect.failCause(exit.cause as Cause.Cause<never>);
344-
const failure = error.value;
345-
if (failure._tag === "Mcp.SessionExpiredError" && attempt === 1) {
346-
yield* retire(server, live).pipe(server.lock.withPermits(1));
347-
continue;
348-
}
349-
if (failure._tag === "Mcp.NeedsAuthError") {
350-
yield* Effect.gen(function* () {
351-
server.state = "needs-auth";
352-
server.error = failure.message;
353-
yield* retire(server, live);
354-
}).pipe(server.lock.withPermits(1));
355-
return yield* Effect.fail(`MCP server "${name}" needs sign-in: ${failure.message}`);
315+
/** `what` prefixes a failure, naming what failed; without it the server's message is the failure. */
316+
(session: Session, name: string, what: string | undefined) =>
317+
<A>(
318+
run: (connection: McpClient.Connection) => Effect.Effect<A, McpClient.Failed>,
319+
/** Why the server, as it stands, no longer offers what is asked; checked before every attempt. */
320+
unavailable: (server: Server) => string | undefined = () => undefined,
321+
): Effect.Effect<A, string> =>
322+
Effect.gen(function* () {
323+
for (let attempt = 1; ; attempt++) {
324+
const server = session.servers.get(name);
325+
if (server === undefined || server.closed || server.config.enabled === false)
326+
return yield* Effect.fail(`MCP server "${name}" is disabled.`);
327+
const reason = unavailable(server);
328+
if (reason !== undefined) return yield* Effect.fail(reason);
329+
const live = yield* open(server).pipe(
330+
Effect.mapError((error) => `MCP server "${name}" is not connected: ${error}`),
331+
);
332+
live.inflight += 1;
333+
const exit = yield* run(live.connection).pipe(
334+
Effect.exit,
335+
Effect.ensuring(
336+
Effect.suspend(() => {
337+
live.inflight -= 1;
338+
return release(live);
339+
}),
340+
),
341+
);
342+
if (Exit.isSuccess(exit)) return exit.value;
343+
const error = Cause.findErrorOption(exit.cause);
344+
if (error._tag === "None") return yield* Effect.failCause(exit.cause as Cause.Cause<never>);
345+
const failure = error.value;
346+
if (failure._tag === "Mcp.SessionExpiredError" && attempt === 1) {
347+
yield* retire(server, live).pipe(server.lock.withPermits(1));
348+
continue;
349+
}
350+
if (failure._tag === "Mcp.NeedsAuthError") {
351+
yield* Effect.gen(function* () {
352+
server.state = "needs-auth";
353+
server.error = failure.message;
354+
yield* retire(server, live);
355+
}).pipe(server.lock.withPermits(1));
356+
return yield* Effect.fail(`MCP server "${name}" needs sign-in: ${failure.message}`);
357+
}
358+
return yield* Effect.fail(
359+
what === undefined ? failure.message : `${what} failed: ${failure.message}`,
360+
);
356361
}
357-
return yield* Effect.fail(`${what} failed: ${failure.message}`);
358-
}
359-
});
362+
});
360363

361364
/** One call of one of the server's tools. A tool call is never retried after the server may have run it. */
362365
const call = (session: Session, name: string, tool: string) => (run: McpTool.Run) =>
@@ -378,7 +381,8 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string
378381
server.config.enabled !== false && server.resources && (server.config.exposure ?? "direct") !== "hidden";
379382

380383
const resourceServer = (session: Session, name: string): McpResources.Server => {
381-
const reach = request(session, name, `MCP server "${name}"`);
384+
// Listings name the server beside each failure already, as Pi does.
385+
const reach = request(session, name, undefined);
382386
return {
383387
name,
384388
resources: (cursor) => reach((connection) => connection.resources(cursor)),
@@ -507,6 +511,16 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string
507511
[...session.servers.values()]
508512
.filter(withResources)
509513
.map((server) => resourceServer(session, server.name)),
514+
// A server still connecting may offer resources once it has.
515+
ready: () =>
516+
Effect.forEach(
517+
[...session.servers.values()],
518+
(server) =>
519+
server.config.enabled === false || server.ready === undefined
520+
? Effect.void
521+
: Deferred.await(server.ready),
522+
{ discard: true },
523+
),
510524
content: output,
511525
}),
512526
);

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ const raw = (args: Record<string, unknown>, key: string): Effect.Effect<string |
8181
return Effect.succeed(value);
8282
};
8383

84+
/** A cursor the model passed back: exactly as the server gave it, and absent when blank. */
85+
const cursorArgument = (args: Record<string, unknown>) =>
86+
raw(args, "cursor").pipe(Effect.map((value) => (value?.trim() === "" ? undefined : value)));
87+
8488
/** A name the model typed: trimmed, and absent when blank. */
8589
const argument = (args: Record<string, unknown>, key: string) =>
8690
raw(args, key).pipe(Effect.map((value) => value?.trim() || undefined));
@@ -91,6 +95,8 @@ type Page = { readonly items: ReadonlyArray<Item>; readonly nextCursor?: string
9195
/** The three resource tools. `servers` answers, when a tool runs, which servers it reaches. */
9296
export const make = (input: {
9397
readonly servers: () => ReadonlyArray<Server>;
98+
/** Settles once no server these tools may reach is still connecting. */
99+
readonly ready: () => Effect.Effect<void>;
94100
readonly content: Omit<McpContent.Options, "readable">;
95101
}): ReadonlyArray<RegisteredTool> => {
96102
const options = { ...input.content, readable: true };
@@ -117,8 +123,8 @@ export const make = (input: {
117123
) =>
118124
Effect.fnUntraced(function* (args: Record<string, unknown>) {
119125
const name = yield* argument(args, "server");
120-
// A cursor is the server's opaque token, handed back exactly as it was given.
121-
const cursor = yield* raw(args, "cursor");
126+
const cursor = yield* cursorArgument(args);
127+
yield* input.ready();
122128
const visible = (item: Item) => !isMcpApp(item);
123129
if (name !== undefined) {
124130
const server = yield* find(name);
@@ -150,6 +156,7 @@ export const make = (input: {
150156
const uri = yield* argument(args, "uri");
151157
if (name === undefined) return yield* Effect.fail("server must be provided");
152158
if (uri === undefined) return yield* Effect.fail("uri must be provided");
159+
yield* input.ready();
153160
const server = yield* find(name);
154161
const result = yield* server.read(uri);
155162
// Several contents (a directory, say) are labeled with their URIs.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,13 @@
193193
"{\"server\":\"e2e\",\"resources\":[{\"server\":\"e2e\",\"name\":\"data\",\"uri\":\"fixture://data.bin\",\"mimeType\":\"application/octet-stream\"}]}"
194194
]
195195
},
196+
"resources-blank": {
197+
"status": "completed",
198+
"isError": false,
199+
"content": [
200+
"{\"server\":\"e2e\",\"resources\":[{\"server\":\"e2e\",\"name\":\"notes\",\"uri\":\"fixture://notes.txt\",\"mimeType\":\"text/plain\"}],\"nextCursor\":\" page-2 \"}"
201+
]
202+
},
196203
"templates": {
197204
"status": "completed",
198205
"isError": false,

‎packages/harness/test/mcp.session.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th
119119
pendingCall("list_mcp_resources", { server: "e2e" }, "resources-page"),
120120
// The server's cursor goes back exactly as it was given, spaces and all.
121121
pendingCall("list_mcp_resources", { server: "e2e", cursor: " page-2 " }, "resources-next"),
122+
// Models fill optional strings with "": a blank cursor is no cursor.
123+
pendingCall("list_mcp_resources", { server: "e2e", cursor: "" }, "resources-blank"),
122124
pendingCall("list_mcp_resource_templates", {}, "templates"),
123125
pendingCall("read_mcp_resource", { server: "e2e", uri: "fixture://notes.txt" }, "read-text"),
124126
pendingCall("read_mcp_resource", { server: "e2e", uri: "fixture://data.bin" }, "read-binary"),
@@ -239,6 +241,7 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th
239241
"resources-all",
240242
"resources-page",
241243
"resources-next",
244+
"resources-blank",
242245
"templates",
243246
"read-text",
244247
"read-binary",

‎sites/www/content/docs/customize/mcp.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ CodeWork connects to [MCP](https://modelcontextprotocol.io) servers over stdio o
1010

1111
MCP servers always run on your machine, the one running CodeWork, whatever sandbox the session uses.
1212

13-
- A stdio server starts in the session's directory when the session runs in the local sandbox, otherwise in the project directory.
13+
- A stdio server starts in the session's directory when the session runs in the local sandbox, otherwise in the session's folder on your machine.
1414
- HTTP servers are called from your machine.
1515
- Output too large to show, and binary files, are saved to your OS temp directory.
1616

@@ -41,7 +41,9 @@ User servers go in `~/.codework/mcp.json`, project servers in `.codework/mcp.jso
4141
| `url`, `headers` | HTTP | A streamable HTTP endpoint. SSE is not supported. |
4242
| `enabled`, `timeout`, `exposure`, `toolExposure` | both | Off switch, per-request timeout in seconds (default 60), and which tools the model sees. |
4343

44-
`env` and `headers` values can use `${NAME}` for an environment variable, or `!command` to use a command's output.
44+
`env` and `headers` values can use `${NAME}` or `$NAME` for an environment variable (`$$` for a literal `$`), or `!command` to use a command's output. A command runs once, and its output is reused until CodeWork restarts.
45+
46+
Servers that need you to sign in are not supported yet.
4547

4648
A broken file or entry is reported and skipped. The other servers still connect.
4749

@@ -66,6 +68,6 @@ Servers with resources also add `list_mcp_resources`, `list_mcp_resource_templat
6668
## Connections
6769

6870
- Servers connect in the background. A message waits up to 10 seconds for servers it starts whose tools the model sees: all of them at first, then new or changed ones.
69-
- If a server stops, the next call starts it again.
71+
- If a server stops, the next call starts it again. If it fails to connect, your next message tries again.
7072
- Edits to `mcp.json` apply from your next message. Changing only `exposure` or `toolExposure` keeps the server running.
7173
- Server log messages go to `~/.codework/mcp.log`.

0 commit comments

Comments
 (0)