Skip to content

Commit 29d2570

Browse files
sanchitrkclaude
andcommitted
fix(harness): page MCP resources with the SDK and pass cursors through
- Listing every server's resources or templates uses the SDK's own pagination and its limits, so a server repeating a cursor cannot keep a listing running. - A cursor goes back to the server exactly as it was given; only server names and URIs are trimmed. - Tests remove the files they save, and read saved paths between the message's own delimiters. - Docs: which servers a message waits for, exposure changes keep a server running, and where server logs go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 01dedb6 commit 29d2570

8 files changed

Lines changed: 88 additions & 62 deletions

File tree

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

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export interface Connection {
6363
readonly resources: (cursor?: string) => Effect.Effect<ListResourcesResult, Failed>;
6464
/** One page of resource templates; empty when the server does not implement the method. */
6565
readonly resourceTemplates: (cursor?: string) => Effect.Effect<ListResourceTemplatesResult, Failed>;
66+
/** Every resource, following pagination with the SDK's limits. */
67+
readonly allResources: () => Effect.Effect<ListResourcesResult["resources"], Failed>;
68+
/** Every resource template, following pagination; empty when the server does not implement the method. */
69+
readonly allResourceTemplates: () => Effect.Effect<ListResourceTemplatesResult["resourceTemplates"], Failed>;
6670
readonly readResource: (uri: string) => Effect.Effect<ReadResourceResult, Failed>;
6771
readonly callTool: (input: {
6872
readonly name: string;
@@ -395,6 +399,21 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI
395399
onprogress: onProgress ?? (() => {}),
396400
});
397401
const capabilities = client.getServerCapabilities();
402+
// Servers that do not implement templates have none.
403+
const templates = (run: (signal: AbortSignal) => Promise<ListResourceTemplatesResult>) =>
404+
capabilities?.resources === undefined
405+
? Effect.succeed<ListResourceTemplatesResult>({ resourceTemplates: [] })
406+
: settle(
407+
"resources/templates/list",
408+
retrying("resources/templates/list", attempt(run)).pipe(
409+
Effect.catchIf(
410+
(failure) =>
411+
failure.error instanceof ProtocolError &&
412+
failure.error.code === ProtocolErrorCode.MethodNotFound,
413+
() => Effect.succeed<ListResourceTemplatesResult>({ resourceTemplates: [] }),
414+
),
415+
),
416+
);
398417

399418
const connection: Connection = {
400419
serverInfo: client.getServerVersion(),
@@ -417,28 +436,22 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI
417436
),
418437
),
419438
resourceTemplates: (cursor) =>
439+
templates((signal) =>
440+
client.request(
441+
{ method: "resources/templates/list", params: cursor === undefined ? {} : { cursor } },
442+
options(signal),
443+
),
444+
),
445+
allResources: () =>
420446
capabilities?.resources === undefined
421-
? Effect.succeed({ resourceTemplates: [] })
422-
: settle(
423-
"resources/templates/list",
424-
retrying(
425-
"resources/templates/list",
426-
attempt((signal) =>
427-
client.request(
428-
{ method: "resources/templates/list", params: cursor === undefined ? {} : { cursor } },
429-
options(signal),
430-
),
431-
),
432-
).pipe(
433-
// Servers that do not implement templates have none.
434-
Effect.catchIf(
435-
(failure) =>
436-
failure.error instanceof ProtocolError &&
437-
failure.error.code === ProtocolErrorCode.MethodNotFound,
438-
() => Effect.succeed({ resourceTemplates: [] }),
439-
),
440-
),
447+
? Effect.succeed([])
448+
: read("resources/list", (signal) => client.listResources(undefined, options(signal))).pipe(
449+
Effect.map((result) => result.resources),
441450
),
451+
allResourceTemplates: () =>
452+
templates((signal) => client.listResourceTemplates(undefined, options(signal))).pipe(
453+
Effect.map((result) => result.resourceTemplates),
454+
),
442455
readResource: (uri) => read("resources/read", (signal) => client.readResource({ uri }, options(signal))),
443456
callTool: (call) =>
444457
request("tools/call", (signal) =>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,8 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string
382382
name,
383383
resources: (cursor) => reach((connection) => connection.resources(cursor)),
384384
templates: (cursor) => reach((connection) => connection.resourceTemplates(cursor)),
385+
allResources: () => reach((connection) => connection.allResources()),
386+
allTemplates: () => reach((connection) => connection.allResourceTemplates()),
385387
read: (uri) => reach((connection) => connection.readResource(uri)),
386388
};
387389
};

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

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export interface Server {
3232
readonly name: string;
3333
readonly resources: (cursor?: string) => Effect.Effect<ListResourcesResult, string>;
3434
readonly templates: (cursor?: string) => Effect.Effect<ListResourceTemplatesResult, string>;
35+
/** Every page, with the SDK's own pagination limits. */
36+
readonly allResources: () => Effect.Effect<ReadonlyArray<Resource>, string>;
37+
readonly allTemplates: () => Effect.Effect<ReadonlyArray<ResourceTemplate>, string>;
3538
readonly read: (uri: string) => Effect.Effect<ReadResourceResult, string>;
3639
}
3740

@@ -70,14 +73,18 @@ const READ_PARAMETERS = {
7073
additionalProperties: false,
7174
};
7275

73-
/** A string argument, trimmed; absent when missing or blank. */
74-
const argument = (args: Record<string, unknown>, key: string): Effect.Effect<string | undefined, string> => {
76+
/** A string argument; absent when missing. */
77+
const raw = (args: Record<string, unknown>, key: string): Effect.Effect<string | undefined, string> => {
7578
const value = args[key];
7679
if (value === undefined || value === null) return Effect.undefined;
7780
if (!Predicate.isString(value)) return Effect.fail(`${key} must be a string`);
78-
return Effect.succeed(value.trim() || undefined);
81+
return Effect.succeed(value);
7982
};
8083

84+
/** A name the model typed: trimmed, and absent when blank. */
85+
const argument = (args: Record<string, unknown>, key: string) =>
86+
raw(args, key).pipe(Effect.map((value) => value?.trim() || undefined));
87+
8188
type Item = Resource | ResourceTemplate;
8289
type Page = { readonly items: ReadonlyArray<Item>; readonly nextCursor?: string | undefined };
8390

@@ -106,10 +113,12 @@ export const make = (input: {
106113
const list = (
107114
key: "resources" | "resourceTemplates",
108115
page: (server: Server, cursor?: string) => Effect.Effect<Page, string>,
116+
all: (server: Server) => Effect.Effect<ReadonlyArray<Item>, string>,
109117
) =>
110118
Effect.fnUntraced(function* (args: Record<string, unknown>) {
111119
const name = yield* argument(args, "server");
112-
const cursor = yield* argument(args, "cursor");
120+
// A cursor is the server's opaque token, handed back exactly as it was given.
121+
const cursor = yield* raw(args, "cursor");
113122
const visible = (item: Item) => !isMcpApp(item);
114123
if (name !== undefined) {
115124
const server = yield* find(name);
@@ -122,18 +131,7 @@ export const make = (input: {
122131
}
123132
if (cursor !== undefined) return yield* Effect.fail("cursor can only be used when a server is specified");
124133
const servers = [...input.servers()].sort((a, b) => a.name.localeCompare(b.name));
125-
const every = (server: Server) =>
126-
Effect.gen(function* () {
127-
const items: Item[] = [];
128-
let next: string | undefined;
129-
do {
130-
const result = yield* page(server, next);
131-
items.push(...result.items);
132-
next = result.nextCursor;
133-
} while (next !== undefined);
134-
return items;
135-
});
136-
const results = yield* Effect.forEach(servers, (server) => Effect.result(every(server)), {
134+
const results = yield* Effect.forEach(servers, (server) => Effect.result(all(server)), {
137135
concurrency: "unbounded",
138136
});
139137
const items: Record<string, unknown>[] = [];
@@ -178,10 +176,13 @@ export const make = (input: {
178176
server: "",
179177
tool: LIST_RESOURCES_TOOL,
180178
run: ({ args }) =>
181-
list("resources", (server, cursor) =>
182-
server
183-
.resources(cursor)
184-
.pipe(Effect.map((result) => ({ items: result.resources, nextCursor: result.nextCursor }))),
179+
list(
180+
"resources",
181+
(server, cursor) =>
182+
server
183+
.resources(cursor)
184+
.pipe(Effect.map((result) => ({ items: result.resources, nextCursor: result.nextCursor }))),
185+
(server) => server.allResources(),
185186
)(args),
186187
}),
187188
McpTool.define({
@@ -193,10 +194,15 @@ export const make = (input: {
193194
server: "",
194195
tool: LIST_TEMPLATES_TOOL,
195196
run: ({ args }) =>
196-
list("resourceTemplates", (server, cursor) =>
197-
server
198-
.templates(cursor)
199-
.pipe(Effect.map((result) => ({ items: result.resourceTemplates, nextCursor: result.nextCursor }))),
197+
list(
198+
"resourceTemplates",
199+
(server, cursor) =>
200+
server
201+
.templates(cursor)
202+
.pipe(
203+
Effect.map((result) => ({ items: result.resourceTemplates, nextCursor: result.nextCursor })),
204+
),
205+
(server) => server.allTemplates(),
200206
)(args),
201207
}),
202208
McpTool.define({

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@
156156
"status": "completed",
157157
"isError": false,
158158
"content": [
159-
"{\"server\":\"e2e\",\"resources\":[{\"server\":\"e2e\",\"name\":\"notes\",\"uri\":\"fixture://notes.txt\",\"mimeType\":\"text/plain\"}],\"nextCursor\":\"page-2\"}"
159+
"{\"server\":\"e2e\",\"resources\":[{\"server\":\"e2e\",\"name\":\"notes\",\"uri\":\"fixture://notes.txt\",\"mimeType\":\"text/plain\"}],\"nextCursor\":\" page-2 \"}"
160160
]
161161
},
162162
"resources-next": {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ const resources = {
3434
{ uri: "fixture://notes.txt", name: "notes", mimeType: "text/plain" },
3535
{ uri: "ui://widget", name: "widget" },
3636
],
37-
nextCursor: "page-2",
37+
nextCursor: " page-2 ",
3838
},
39-
"page-2": { resources: [{ uri: "fixture://data.bin", name: "data", mimeType: "application/octet-stream" }] },
39+
" page-2 ": { resources: [{ uri: "fixture://data.bin", name: "data", mimeType: "application/octet-stream" }] },
4040
};
4141
const contents = {
4242
"fixture://notes.txt": [{ uri: "fixture://notes.txt", mimeType: "text/plain", text: "the notes" }],

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { join } from "node:path";
77
import { fileURLToPath } from "node:url";
88
import type { LoggingMessageNotificationParams, OAuthClientProvider } from "@modelcontextprotocol/client";
99
import { Deferred, Effect, Exit, Fiber, Option, Ref, Scope } from "effect";
10-
import { describe, expect, vi } from "vite-plus/test";
10+
import { describe, expect, onTestFinished, vi } from "vite-plus/test";
1111
import { McpClient } from "../src/mcp/client.ts";
1212
import { McpContent } from "../src/mcp/content.ts";
1313
import { McpLog } from "../src/mcp/log.ts";
@@ -72,7 +72,6 @@ const scenarios = [
7272

7373
const normalise = (message: string) =>
7474
message
75-
.replace(/\S+codework-mcp-[0-9a-f]+(\.\w+)/g, "<output>$1")
7675
.replaceAll(fixtures, "<fixtures>/")
7776
.replaceAll(process.execPath, "<node>")
7877
.replace(/127\.0\.0\.1:\d+/g, "127.0.0.1:<port>")
@@ -145,16 +144,17 @@ describe("mcp client", () => {
145144
tools: tools.map((tool) => tool.name),
146145
echo: converted.echo,
147146
mixed: converted.mixed.map((block) =>
148-
block.type === "text" ? { ...block, text: normalise(block.text) } : block,
147+
block.type === "text"
148+
? { ...block, text: block.text.replace(/saved to .+\]$/, "saved to <output>.bin]") }
149+
: block,
149150
),
150151
structured: converted.structured,
151152
};
152153
expect(connection.instructions).toBe("Use the echo tool.");
153154
expect(converted.echo).toEqual([{ type: "text", text: "hi" }]);
154-
const saved = /saved to (\S+codework-mcp-[0-9a-f]+\.bin)\]$/.exec(
155-
(converted.mixed[4] as { readonly text: string }).text,
156-
)?.[1];
155+
const saved = /saved to (.+)\]$/.exec((converted.mixed[4] as { readonly text: string }).text)?.[1];
157156
expect(saved).toBeDefined();
157+
onTestFinished(() => rmSync(saved!, { force: true }));
158158
expect(yield* Effect.promise(() => readFile(saved!))).toEqual(Buffer.from([0, 1, 2]));
159159
expect(converted.mixed).toEqual([
160160
{ type: "text", text: "hello" },

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Effect } from "effect";
44
import fs from "node:fs/promises";
55
import { join } from "node:path";
66
import { fileURLToPath } from "node:url";
7-
import { expect, it } from "vite-plus/test";
7+
import { expect, it, onTestFinished } from "vite-plus/test";
88
import { ContextCodec } from "../src/context/codec.ts";
99
import { Harness } from "../src/effect/harness.ts";
1010
import { Session } from "../src/effect/session.ts";
@@ -111,7 +111,8 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th
111111
pendingCall("mcp__e2e__big", {}, "big"),
112112
pendingCall("list_mcp_resources", {}, "resources-all"),
113113
pendingCall("list_mcp_resources", { server: "e2e" }, "resources-page"),
114-
pendingCall("list_mcp_resources", { server: "e2e", cursor: "page-2" }, "resources-next"),
114+
// The server's cursor goes back exactly as it was given, spaces and all.
115+
pendingCall("list_mcp_resources", { server: "e2e", cursor: " page-2 " }, "resources-next"),
115116
pendingCall("list_mcp_resource_templates", {}, "templates"),
116117
pendingCall("read_mcp_resource", { server: "e2e", uri: "fixture://notes.txt" }, "read-text"),
117118
pendingCall("read_mcp_resource", { server: "e2e", uri: "fixture://data.bin" }, "read-binary"),
@@ -186,10 +187,12 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th
186187
expect(outcome("cwd")?.content).toEqual([await fs.realpath(join(work, "srv"))]);
187188

188189
// What the model is not shown in full is kept on the host, in files only the user can read.
189-
const savedPath = (id: string) =>
190-
/(\/\S+codework-mcp-[0-9a-f]+\.\w+)/.exec(outcome(id)?.content.join("\n") ?? "")?.[1];
191-
const bigFile = savedPath("big");
192-
const binaryFile = savedPath("read-binary");
190+
// Paths are read between the message's own delimiters, so a temp directory with spaces still parses.
191+
const bigFile = /\[Full output: (.+) \(read it with offset\/limit\)\]/.exec(outcome("big")?.content[0] ?? "")?.[1];
192+
const binaryFile = /saved to (.+)\]$/.exec(outcome("read-binary")?.content[0] ?? "")?.[1];
193+
onTestFinished(async () => {
194+
for (const file of [bigFile, binaryFile]) if (file) await fs.rm(file, { force: true });
195+
});
193196
expect(await fs.readFile(bigFile!, "utf8")).toBe(`start ${"x".repeat(30 * 1024)} end`);
194197
expect([...(await fs.readFile(binaryFile!))]).toEqual([0, 1, 2, 3]);
195198
expect((await fs.stat(binaryFile!)).mode & 0o777).toBe(0o600);
@@ -201,7 +204,8 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th
201204
const normalise = (value: unknown) =>
202205
JSON.parse(
203206
JSON.stringify(value)
204-
.replace(/\/\S+?codework-mcp-[0-9a-f]+(\.\w+)/g, "<output>$1")
207+
.replaceAll(bigFile!, "<output>.txt")
208+
.replaceAll(binaryFile!, "<output>.bin")
205209
.replaceAll(real, "<tmp>")
206210
.replaceAll(dir.path, "<tmp>")
207211
.replaceAll(process.execPath, "<node>")

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ Servers with resources also add `list_mcp_resources`, `list_mcp_resource_templat
6565

6666
## Connections
6767

68-
- Servers connect in the background. Your first message waits up to 10 seconds for servers whose tools the model sees.
68+
- 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.
6969
- If a server stops, the next call starts it again.
70-
- Edits to `mcp.json` apply from your next message.
70+
- Edits to `mcp.json` apply from your next message. Changing only `exposure` or `toolExposure` keeps the server running.
71+
- Server log messages go to `~/.codework/mcp.log`.

0 commit comments

Comments
 (0)