diff --git a/packages/harness/src/mcp/client.ts b/packages/harness/src/mcp/client.ts index 6d8cf1b6..fd93f154 100644 --- a/packages/harness/src/mcp/client.ts +++ b/packages/harness/src/mcp/client.ts @@ -49,7 +49,7 @@ export interface Progress { readonly message?: string | undefined; } -type Failed = RequestError | SessionExpiredError | NeedsAuthError; +export type Failed = RequestError | SessionExpiredError | NeedsAuthError; /** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of the harness. */ export interface Connection { @@ -62,6 +62,10 @@ export interface Connection { readonly resources: (cursor?: string) => Effect.Effect; /** One page of resource templates; empty when the server does not implement the method. */ readonly resourceTemplates: (cursor?: string) => Effect.Effect; + /** Every resource, following pagination with the SDK's limits. */ + readonly allResources: () => Effect.Effect; + /** Every resource template, following pagination; empty when the server does not implement the method. */ + readonly allResourceTemplates: () => Effect.Effect; readonly readResource: (uri: string) => Effect.Effect; readonly callTool: (input: { readonly name: string; @@ -421,6 +425,21 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI ? { signal, timeout: requestTimeout } : { signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: onProgress }; const capabilities = client.getServerCapabilities(); + // Servers that do not implement templates have none. + const templates = (run: (signal: AbortSignal) => Promise) => + capabilities?.resources === undefined + ? Effect.succeed({ resourceTemplates: [] }) + : settle( + "resources/templates/list", + retryingRead("resources/templates/list", attempt(run)).pipe( + Effect.catchIf( + (failure) => + failure.error instanceof ProtocolError && + failure.error.code === ProtocolErrorCode.MethodNotFound, + () => Effect.succeed({ resourceTemplates: [] }), + ), + ), + ); const connection: Connection = { serverInfo: client.getServerVersion(), @@ -443,28 +462,22 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI ), ), resourceTemplates: (cursor) => + templates((signal) => + client.request( + { method: "resources/templates/list", params: cursor === undefined ? {} : { cursor } }, + options(signal), + ), + ), + allResources: () => capabilities?.resources === undefined - ? Effect.succeed({ resourceTemplates: [] }) - : settle( - "resources/templates/list", - retryingRead( - "resources/templates/list", - attempt((signal) => - client.request( - { method: "resources/templates/list", params: cursor === undefined ? {} : { cursor } }, - options(signal), - ), - ), - ).pipe( - // Servers that do not implement templates have none. - Effect.catchIf( - (failure) => - failure.error instanceof ProtocolError && - failure.error.code === ProtocolErrorCode.MethodNotFound, - () => Effect.succeed({ resourceTemplates: [] }), - ), - ), + ? Effect.succeed([]) + : read("resources/list", (signal) => client.listResources(undefined, options(signal))).pipe( + Effect.map((result) => result.resources), ), + allResourceTemplates: () => + templates((signal) => client.listResourceTemplates(undefined, options(signal))).pipe( + Effect.map((result) => result.resourceTemplates), + ), readResource: (uri) => read("resources/read", (signal) => client.readResource({ uri }, options(signal))), callTool: (call) => request("tools/call", (signal) => diff --git a/packages/harness/src/mcp/content.ts b/packages/harness/src/mcp/content.ts index b3f2be69..d223e002 100644 --- a/packages/harness/src/mcp/content.ts +++ b/packages/harness/src/mcp/content.ts @@ -1,44 +1,150 @@ import type { Message } from "@codeworksh/aikit"; import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/client"; +import { Effect } from "effect"; +import { Image } from "../tool/image/index.ts"; +import { formatSize, truncateMiddle } from "../tool/truncate.ts"; export type Content = Message.TextContent | Message.ImageContent; -const fromBlock = (block: ContentBlock): Content => { +/** Model-facing text of an MCP result beyond this is cut in the middle, and saved in full. */ +export const MAX_BYTES = 20 * 1024; + +/** The tool that reads what a resource link names. */ +export const READ_RESOURCE_TOOL = "read_mcp_resource"; + +/** Keeps data the model is not shown in full in a host file; `extension` includes the dot. */ +export type Save = (data: string | Uint8Array, extension: string) => Effect.Effect; + +export interface Options { + readonly save: Save; + /** Whether `read_mcp_resource` can read this server's resources, which resource links then name. */ + readonly readable: boolean; +} + +/** Blobs of these types are shown as text. */ +const isText = (mimeType: string | undefined) => { + if (!mimeType) return false; + const type = mimeType.split(";", 1)[0]!.trim().toLowerCase(); + return type.startsWith("text/") || type === "application/json" || type.endsWith("+json") || type.endsWith("+xml"); +}; + +/** The extension a saved binary resource gets: the one its URI ends in, else `.bin`. */ +const extensionOf = (uri: string) => { + const path = URL.canParse(uri) ? new URL(uri).pathname : uri; + return /\.[A-Za-z0-9]{1,8}$/.exec(path)?.[0] ?? ".bin"; +}; + +/** + * An image as the model can take it inline, as the read tool does: in a supported format and within + * the provider's limits, or a note saying why it was left out. + */ +const image = (data: string, mimeType: string) => + Image.inline(Buffer.from(data, "base64"), mimeType).pipe( + Effect.map((inline): ReadonlyArray => + inline.ok + ? [ + { type: "image", data: inline.data, mimeType: inline.mimeType }, + ...inline.hints.map((text): Content => ({ type: "text", text })), + ] + : [{ type: "text", text: inline.message }], + ), + ); + +/** Blocks that need neither a file nor the server's name. */ +const plain = (block: ContentBlock): Effect.Effect> => { switch (block.type) { case "text": - return { type: "text", text: block.text }; + return Effect.succeed([{ type: "text", text: block.text }]); case "image": - return { type: "image", data: block.data, mimeType: block.mimeType }; + return image(block.data, block.mimeType); case "audio": - return { type: "text", text: `[audio ${block.mimeType} omitted]` }; - case "resource_link": - return { type: "text", text: `${block.name}: ${block.uri}` }; + return Effect.succeed([{ type: "text", text: `[audio ${block.mimeType} omitted]` }]); case "resource": { const resource = block.resource; - if ("text" in resource) return { type: "text", text: resource.text }; - if (resource.mimeType?.startsWith("image/")) { - return { type: "image", data: resource.blob, mimeType: resource.mimeType }; - } - return { - type: "text", - text: `[binary resource ${resource.uri} (${resource.mimeType ?? "unknown type"}) omitted]`, - }; + return "text" in resource + ? Effect.succeed([{ type: "text", text: resource.text }]) + : image(resource.blob, resource.mimeType ?? "image/png"); } default: - return { type: "text", text: `[unsupported MCP content ${(block as { type: string }).type}]` }; + return Effect.succeed([{ type: "text", text: `[unsupported MCP content ${block.type}]` }]); } }; +/** One block of `server`'s output as model content. */ +const fromBlock = Effect.fnUntraced(function* ( + server: string, + block: ContentBlock, + options: Options, +): Effect.fn.Return> { + if (block.type === "resource_link") { + const details = [block.mimeType, block.size === undefined ? undefined : formatSize(block.size)].filter(Boolean); + const read = options.readable ? `. Read it with ${READ_RESOURCE_TOOL} (server "${server}")` : ""; + const description = block.description ? `: ${block.description}` : ""; + return [ + { + type: "text", + text: `[Resource ${block.uri} "${block.title ?? block.name}"${details.length > 0 ? ` (${details.join(", ")})` : ""}${description}${read}]`, + }, + ]; + } + if (block.type === "resource" && "blob" in block.resource && !block.resource.mimeType?.startsWith("image/")) { + const { uri, mimeType, blob } = block.resource; + const data = Buffer.from(blob, "base64"); + if (isText(mimeType)) return [{ type: "text", text: data.toString("utf8") }]; + const kind = `${mimeType ?? "unknown type"}, ${formatSize(data.length)}`; + return yield* options.save(data, extensionOf(uri)).pipe( + Effect.match({ + onSuccess: (path): ReadonlyArray => [ + { type: "text", text: `[Binary resource ${uri} (${kind}) saved to ${path}]` }, + ], + onFailure: (reason): ReadonlyArray => [ + { type: "text", text: `[Binary resource ${uri} (${kind}) could not be saved: ${reason}]` }, + ], + }), + ); + } + return yield* plain(block); +}); + +/** + * Keep model-facing text within {@link MAX_BYTES}. Longer text becomes one block with its start and + * end, followed by the path of a file holding all of it; images follow it. + */ +export const limit = Effect.fnUntraced(function* (content: ReadonlyArray, options: Options) { + const combined = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n"); + const cut = truncateMiddle(combined, MAX_BYTES); + if (!cut.truncated) return [...content]; + const where = yield* options.save(combined, ".txt").pipe( + Effect.match({ + onSuccess: (path) => `[Full output: ${path} (read it with offset/limit)]`, + onFailure: (reason) => `[Could not save the full output: ${reason}]`, + }), + ); + const tokens = Math.ceil(cut.totalBytes / 4); + const text = `Warning: truncated output (original token count: ${tokens})\nTotal output lines: ${cut.totalLines}\n\n${cut.content}\n\n${where}`; + return [{ type: "text", text } satisfies Content, ...content.filter((block) => block.type === "image")]; +}); + +/** Blocks of `server`'s output as model content, before {@link limit}. */ +export const fromBlocks = (server: string, blocks: ReadonlyArray, options: Options) => + Effect.forEach(blocks, (block) => fromBlock(server, block, options)).pipe(Effect.map((all) => all.flat())); + /** * A tool result as model content. A result without content blocks but with `structuredContent` * becomes its JSON, since servers should, but do not always, mirror structured results as text. */ -export const fromResult = (result: Pick): Content[] => { - const content = (result.content ?? []).map(fromBlock); +export const fromResult = Effect.fnUntraced(function* ( + server: string, + result: Pick, + options: Options, +) { + const content = yield* fromBlocks(server, result.content ?? [], options); if (content.length === 0 && result.structuredContent !== undefined) { + // Pretty-printed JSON for the model, which Schema's encoders do not offer. + // @effect-diagnostics-next-line preferSchemaOverJson:off content.push({ type: "text", text: JSON.stringify(result.structuredContent, null, 2) }); } - return content; -}; + return yield* limit(content, options); +}); export * as McpContent from "./content.ts"; diff --git a/packages/harness/src/mcp/mcp.ts b/packages/harness/src/mcp/mcp.ts index 364e071a..6d88f71b 100644 --- a/packages/harness/src/mcp/mcp.ts +++ b/packages/harness/src/mcp/mcp.ts @@ -22,7 +22,7 @@ */ import type { Exposure, ServerConfig } from "@codeworksh/plugin/plugin/mcp"; -import type { CallToolResult, Tool } from "@modelcontextprotocol/client"; +import type { Tool } from "@modelcontextprotocol/client"; import { Cause, Context, @@ -48,6 +48,8 @@ import type { RegisteredTool } from "../tool/tool.ts"; import { McpClient } from "./client.ts"; import { namespace } from "./config.ts"; import { McpLog } from "./log.ts"; +import { McpOutput } from "./output.ts"; +import { McpResources } from "./resources.ts"; import { McpTool } from "./tool.ts"; import { McpValue } from "./value.ts"; import { hostPath } from "../host.ts"; @@ -119,6 +121,8 @@ interface Server { state: State; error: string | undefined; tools: ReadonlyArray; + /** Whether the server offered resources when it last connected. */ + resources: boolean; live: Live | undefined; /** The connect attempt in progress. Callers share it; closing the server interrupts it. */ attempt: Fiber.Fiber | undefined; @@ -354,6 +358,7 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string }; server.live = live; server.tools = exit.value.tools; + server.resources = exit.value.connection.capabilities?.resources !== undefined; yield* setState(server, "connected"); watch(server, live); yield* Effect.logInfo("mcp connected", { server: server.name, tools: server.tools.length }); @@ -417,28 +422,32 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string return stop(server); }); - /** One call of one of the server's tools, on whichever connection is current when it runs. */ - const call = - (thread: Thread, name: string, tool: string) => - (input: McpTool.Call): Effect.Effect => - Effect.gen(function* () { - for (let attempt = 1; ; attempt++) { - const server = thread.servers.get(name); - if (server === undefined || server.config.enabled === false) - return yield* Effect.fail(`MCP server "${name}" is disabled.`); - if (server.closed) return yield* Effect.fail(`MCP server "${name}" is shut down.`); - if ( - McpTool.exposure(server.config, tool) === "hidden" || - (server.live !== undefined && !server.tools.some((offered) => offered.name === tool)) - ) - return yield* Effect.fail(`MCP tool "${name}/${tool}" is no longer available.`); - const live = yield* open(server).pipe( - Effect.mapError((error) => `MCP server "${name}" is not connected: ${error}`), - ); - live.inflight += 1; - const exit = yield* live.connection - .callTool({ name: tool, args: input.args, onProgress: input.onProgress }) - .pipe( + /** + * One request on whichever connection of the server is current when it runs. `what` names + * it in a failure. A request the server never saw, because it had forgotten the session, + * runs once more on a new one; nothing else is retried here. + */ + const request = + /** `what` prefixes a failure, naming what failed; without it the server's message is the failure. */ + (thread: Thread, name: string, what: string | undefined) => + ( + run: (connection: McpClient.Connection) => Effect.Effect, + /** Why the server, as it stands, no longer offers what is asked; checked before every attempt. */ + unavailable: (server: Server) => string | undefined = () => undefined, + ): Effect.Effect => + Effect.gen(function* () { + for (let attempt = 1; ; attempt++) { + const server = thread.servers.get(name); + if (server === undefined || server.config.enabled === false) + return yield* Effect.fail(`MCP server "${name}" is disabled.`); + if (server.closed) return yield* Effect.fail(`MCP server "${name}" is shut down.`); + const reason = unavailable(server); + if (reason !== undefined) return yield* Effect.fail(reason); + const live = yield* open(server).pipe( + Effect.mapError((error) => `MCP server "${name}" is not connected: ${error}`), + ); + live.inflight += 1; + const exit = yield* run(live.connection).pipe( Effect.exit, Effect.ensuring( Effect.suspend(() => { @@ -447,25 +456,61 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string }), ), ); - if (Exit.isSuccess(exit)) return exit.value; - const error = Cause.findErrorOption(exit.cause); - if (error._tag === "None") return yield* Effect.failCause(exit.cause as Cause.Cause); - const failure = error.value; - // The server no longer knows the session, so it did not run the call. - if (failure._tag === "Mcp.SessionExpiredError" && attempt === 1) { - yield* retire(server, live).pipe(server.lock.withPermits(1)); - continue; - } - if (failure._tag === "Mcp.NeedsAuthError") { - yield* Effect.gen(function* () { - yield* setState(server, "authenticate", failure.message); - yield* retire(server, live); - }).pipe(server.lock.withPermits(1)); - return yield* Effect.fail(`MCP server "${name}" needs sign-in: ${failure.message}`); + if (Exit.isSuccess(exit)) return exit.value; + const error = Cause.findErrorOption(exit.cause); + if (error._tag === "None") return yield* Effect.failCause(exit.cause as Cause.Cause); + const failure = error.value; + if (failure._tag === "Mcp.SessionExpiredError" && attempt === 1) { + yield* retire(server, live).pipe(server.lock.withPermits(1)); + continue; + } + if (failure._tag === "Mcp.NeedsAuthError") { + yield* Effect.gen(function* () { + yield* setState(server, "authenticate", failure.message); + yield* retire(server, live); + }).pipe(server.lock.withPermits(1)); + return yield* Effect.fail(`MCP server "${name}" needs sign-in: ${failure.message}`); + } + return yield* Effect.fail( + what === undefined ? failure.message : `${what} failed: ${failure.message}`, + ); } - return yield* Effect.fail(`MCP tool "${name}/${tool}" failed: ${failure.message}`); - } - }); + }); + + /** One call of one of the server's tools. A tool call is never retried after the server may have run it. */ + const call = (thread: Thread, name: string, tool: string) => (run: McpTool.Run) => + request( + thread, + name, + `MCP tool "${name}/${tool}"`, + )( + (connection) => connection.callTool({ name: tool, args: run.args, onProgress: run.onProgress }), + (server) => + McpTool.exposure(server.config, tool) === "hidden" || + (server.live !== undefined && !server.tools.some((offered) => offered.name === tool)) + ? `MCP tool "${name}/${tool}" is no longer available.` + : undefined, + ); + + /** Servers the resource tools reach: enabled, connectable, offering resources, and not hidden. */ + const withResources = (server: Server) => + server.config.enabled !== false && + !server.stopped && + server.resources && + (server.config.exposure ?? "direct") !== "hidden"; + + const resourceServer = (thread: Thread, name: string): McpResources.Server => { + // Listings name the server beside each failure already. + const reach = request(thread, name, undefined); + return { + name, + resources: (cursor) => reach((connection) => connection.resources(cursor)), + templates: (cursor) => reach((connection) => connection.resourceTemplates(cursor)), + allResources: () => reach((connection) => connection.allResources()), + allTemplates: () => reach((connection) => connection.allResourceTemplates()), + read: (uri) => reach((connection) => connection.readResource(uri)), + }; + }; /** Connect in the background; `ready` settles when the attempt does. */ const start = (server: Server) => @@ -535,6 +580,7 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string state: "connecting", error: undefined, tools: [], + resources: false, live: undefined, attempt: undefined, ready: undefined, @@ -571,6 +617,8 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string // Every tool is registered with its exposure; the registry decides which ones are offered. const tools: RegisteredTool[] = []; + const output = { save: McpOutput.save }; + const content = (server: Server) => ({ ...output, readable: withResources(server) }); for (const server of thread.servers.values()) { if (server.config.enabled === false || server.stopped || server.tools.length === 0) continue; const names = thread.names( @@ -585,10 +633,31 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string name: names.get(tool.name)!, exposure: McpTool.exposure(server.config, tool.name), call: call(thread, server.name, tool.name), + content: content(server), }), ); } } + if ([...thread.servers.values()].some(withResources)) + tools.push( + ...McpResources.make({ + servers: () => + [...thread.servers.values()] + .filter(withResources) + .map((server) => resourceServer(thread, server.name)), + // A server still connecting may offer resources once it has. + ready: () => + Effect.forEach( + [...thread.servers.values()], + (server) => + server.config.enabled === false || server.ready === undefined + ? Effect.void + : Deferred.await(server.ready), + { discard: true }, + ), + content: output, + }), + ); return tools; }); diff --git a/packages/harness/src/mcp/output.ts b/packages/harness/src/mcp/output.ts new file mode 100644 index 00000000..3db5f59c --- /dev/null +++ b/packages/harness/src/mcp/output.ts @@ -0,0 +1,24 @@ +/** + * Files for MCP output the model is not shown in full: the whole text of a truncated result, and + * binary resources. They live on the host, like MCP itself, and never in a session's sandbox. + */ + +import { tmpdir } from "node:os"; +import { Effect } from "effect"; +import { Hex } from "effect/encoding"; +import { crypto, fileSystem, hostPath } from "../host.ts"; + +/** Output can carry private data, so only the user may read the files. */ +const MODE = 0o600; + +/** Write `data` to a new file and return its path. `extension` includes the dot. */ +export const save = Effect.fn("Mcp.saveOutput")(function* (data: string | Uint8Array, extension: string) { + const random = yield* crypto.randomBytes(8).pipe(Effect.orDie); + const path = hostPath.join(tmpdir(), `codework-mcp-${Hex.encode(random)}${extension}`); + const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data; + // `wx` never follows a link someone else placed at the path. + yield* fileSystem.writeFile(path, bytes, { flag: "wx", mode: MODE }).pipe(Effect.mapError((error) => error.message)); + return path; +}); + +export * as McpOutput from "./output.ts"; diff --git a/packages/harness/src/mcp/resources.ts b/packages/harness/src/mcp/resources.ts new file mode 100644 index 00000000..03ab8e42 --- /dev/null +++ b/packages/harness/src/mcp/resources.ts @@ -0,0 +1,230 @@ +/** + * MCP resources, through the tools other agents use for them: `list_mcp_resources`, + * `list_mcp_resource_templates` and `read_mcp_resource`. They take a `server` argument and reach + * every server with resources, so models trained on those tools use them unchanged. + * + * Listings are JSON: `{ server?, resources: [{ server, ...resource }], nextCursor? }`. With a + * `server`, one page is listed and `cursor` continues it; without, every page of every server. MCP + * App resources (`ui://` URIs and `profile=mcp-app` HTML) are left out, since they are interfaces + * for hosts that render them, and so are icons. A read resource becomes text and images; a binary + * one is saved to a file. + */ + +import type { + ContentBlock, + ListResourcesResult, + ListResourceTemplatesResult, + ReadResourceResult, + Resource, + ResourceTemplateType as ResourceTemplate, +} from "@modelcontextprotocol/client"; +import { Effect, Predicate } from "effect"; +import type { RegisteredTool } from "../tool/tool.ts"; +import { McpContent } from "./content.ts"; +import { McpTool } from "./tool.ts"; + +export const LIST_RESOURCES_TOOL = "list_mcp_resources"; +export const LIST_TEMPLATES_TOOL = "list_mcp_resource_templates"; +export const READ_RESOURCE_TOOL = McpContent.READ_RESOURCE_TOOL; + +/** A server that offers resources, reached through its current connection. */ +export interface Server { + readonly name: string; + readonly resources: (cursor?: string) => Effect.Effect; + readonly templates: (cursor?: string) => Effect.Effect; + /** Every page, with the SDK's own pagination limits. */ + readonly allResources: () => Effect.Effect, string>; + readonly allTemplates: () => Effect.Effect, string>; + readonly read: (uri: string) => Effect.Effect; +} + +const isMcpApp = (item: { + readonly uri?: string | undefined; + readonly uriTemplate?: string | undefined; + readonly mimeType?: string | undefined; +}) => + (item.uri ?? item.uriTemplate ?? "").startsWith("ui://") || + /;\s*profile\s*=\s*"?mcp-app"?/i.test(item.mimeType ?? ""); + +/** A listed resource or template without `_meta` and icons, tagged with its server. */ +const listed = (server: string, item: Resource | ResourceTemplate): Record => { + const { _meta: _ignored, icons: _icons, ...rest } = item; + return { server, ...rest }; +}; + +const property = (description: string) => ({ type: "string", description }); +const LIST_PARAMETERS = { + type: "object", + properties: { + server: property("MCP server name. Omit to list every server with resources."), + cursor: property("Opaque cursor from a previous call with the same server; omit for the first page."), + }, + additionalProperties: false, +}; +const READ_PARAMETERS = { + type: "object", + properties: { + server: property( + "MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources.", + ), + uri: property("Resource URI to read. Must be one of the URIs returned by list_mcp_resources."), + }, + required: ["server", "uri"], + additionalProperties: false, +}; + +/** A string argument; absent when missing. */ +const raw = (args: Record, key: string): Effect.Effect => { + const value = args[key]; + if (value === undefined || value === null) return Effect.undefined; + if (!Predicate.isString(value)) return Effect.fail(`${key} must be a string`); + return Effect.succeed(value); +}; + +/** A cursor the model passed back: exactly as the server gave it, and absent when blank. */ +const cursorArgument = (args: Record) => + raw(args, "cursor").pipe(Effect.map((value) => (value?.trim() === "" ? undefined : value))); + +/** A name the model typed: trimmed, and absent when blank. */ +const argument = (args: Record, key: string) => + raw(args, key).pipe(Effect.map((value) => value?.trim() || undefined)); + +type Item = Resource | ResourceTemplate; +type Page = { readonly items: ReadonlyArray; readonly nextCursor?: string | undefined }; + +/** The three resource tools. `servers` answers, when a tool runs, which servers it reaches. */ +export const make = (input: { + readonly servers: () => ReadonlyArray; + /** Settles once no server these tools may reach is still connecting. */ + readonly ready: () => Effect.Effect; + readonly content: Omit; +}): ReadonlyArray => { + const options = { ...input.content, readable: true }; + const find = (name: string) => { + const servers = input.servers(); + const server = servers.find((candidate) => candidate.name === name); + if (server !== undefined) return Effect.succeed(server); + const available = servers.map((candidate) => candidate.name).join(", "); + return Effect.fail( + `MCP server "${name}" has no resources${available ? `. Servers with resources: ${available}` : ""}`, + ); + }; + + const json = (payload: Record) => + McpContent.limit([{ type: "text", text: JSON.stringify(payload) }], options).pipe( + Effect.map((content) => ({ content, structuredContent: payload })), + ); + + /** One page of one server, or every page of every server. */ + const list = ( + key: "resources" | "resourceTemplates", + page: (server: Server, cursor?: string) => Effect.Effect, + all: (server: Server) => Effect.Effect, string>, + ) => + Effect.fnUntraced(function* (args: Record) { + const name = yield* argument(args, "server"); + const cursor = yield* cursorArgument(args); + yield* input.ready(); + const visible = (item: Item) => !isMcpApp(item); + if (name !== undefined) { + const server = yield* find(name); + const result = yield* page(server, cursor); + return yield* json({ + server: server.name, + [key]: result.items.filter(visible).map((item) => listed(server.name, item)), + ...(result.nextCursor === undefined ? {} : { nextCursor: result.nextCursor }), + }); + } + if (cursor !== undefined) return yield* Effect.fail("cursor can only be used when a server is specified"); + const servers = [...input.servers()].sort((a, b) => a.name.localeCompare(b.name)); + const results = yield* Effect.forEach(servers, (server) => Effect.result(all(server)), { + concurrency: "unbounded", + }); + const items: Record[] = []; + const errors: { server: string; error: string }[] = []; + results.forEach((result, index) => { + const server = servers[index]!.name; + if (result._tag === "Success") + items.push(...result.success.filter(visible).map((item) => listed(server, item))); + else errors.push({ server, error: result.failure }); + }); + return yield* json({ [key]: items, ...(errors.length > 0 ? { errors } : {}) }); + }); + + const read = Effect.fnUntraced(function* (args: Record) { + const name = yield* argument(args, "server"); + const uri = yield* argument(args, "uri"); + if (name === undefined) return yield* Effect.fail("server must be provided"); + if (uri === undefined) return yield* Effect.fail("uri must be provided"); + yield* input.ready(); + const server = yield* find(name); + const result = yield* server.read(uri); + // Several contents (a directory, say) are labeled with their URIs. + const blocks: ContentBlock[] = result.contents.flatMap((contents) => [ + ...(result.contents.length > 1 ? [{ type: "text" as const, text: `${contents.uri}:` }] : []), + { type: "resource" as const, resource: contents }, + ]); + const converted = yield* McpContent.fromBlocks(server.name, blocks, options); + const content = yield* McpContent.limit( + converted.length > 0 ? converted : [{ type: "text", text: `Resource ${uri} is empty.` }], + options, + ); + const contents = result.contents.map(({ _meta: _ignored, ...rest }) => rest); + return { content, structuredContent: { server: server.name, uri, contents } }; + }); + + return [ + McpTool.define({ + name: LIST_RESOURCES_TOOL, + label: LIST_RESOURCES_TOOL, + description: + "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.", + parameters: LIST_PARAMETERS, + server: "", + tool: LIST_RESOURCES_TOOL, + exposure: "direct", + run: ({ args }) => + list( + "resources", + (server, cursor) => + server + .resources(cursor) + .pipe(Effect.map((result) => ({ items: result.resources, nextCursor: result.nextCursor }))), + (server) => server.allResources(), + )(args), + }), + McpTool.define({ + name: LIST_TEMPLATES_TOOL, + label: LIST_TEMPLATES_TOOL, + description: + "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.", + parameters: LIST_PARAMETERS, + server: "", + tool: LIST_TEMPLATES_TOOL, + exposure: "direct", + run: ({ args }) => + list( + "resourceTemplates", + (server, cursor) => + server + .templates(cursor) + .pipe( + Effect.map((result) => ({ items: result.resourceTemplates, nextCursor: result.nextCursor })), + ), + (server) => server.allTemplates(), + )(args), + }), + McpTool.define({ + name: READ_RESOURCE_TOOL, + label: READ_RESOURCE_TOOL, + description: "Read a specific resource from an MCP server given the server name and resource URI.", + parameters: READ_PARAMETERS, + server: "", + tool: READ_RESOURCE_TOOL, + exposure: "direct", + run: ({ args }) => read(args), + }), + ]; +}; + +export * as McpResources from "./resources.ts"; diff --git a/packages/harness/src/mcp/tool.ts b/packages/harness/src/mcp/tool.ts index e3e8b119..b421ec82 100644 --- a/packages/harness/src/mcp/tool.ts +++ b/packages/harness/src/mcp/tool.ts @@ -94,12 +94,21 @@ const Outcome = Schema.Struct({ }); type Outcome = typeof Outcome.Type; -/** Tool input schemas must be objects; servers may omit `type`, and some providers want `properties`. */ -const parameters = (schema: McpTool["inputSchema"]): Record => ({ - ...schema, - type: schema.type ?? "object", - ...(schema.properties === undefined ? { properties: {} } : {}), -}); +/** What running an MCP-backed tool produced. `isError` reaches the model as an error result. */ +export interface Result { + readonly content: ReadonlyArray; + readonly structuredContent?: unknown; + readonly isError?: boolean; +} + +export interface Run { + /** + * The arguments as the model wrote them, minus the `null`s strict sampling writes for omitted + * optional ones: validation only checks them, and the server decides what they mean. + */ + readonly args: Record; + readonly onProgress: (progress: Progress) => void; +} const withoutOptionalNulls = (args: Record, schema: Record) => { const normalized = structuredClone(args); @@ -107,73 +116,101 @@ const withoutOptionalNulls = (args: Record, schema: Record; - readonly onProgress: (progress: Progress) => void; -} - /** - * A server tool as a harness tool. `call` resolves the server's current connection when it runs, so - * the definition can outlive the connection it was listed on. A failed call and a result the server - * flags `isError` both reach the model as an error result. + * A harness tool backed by MCP, for a server tool or a resource tool. A failure and an `isError` + * result both reach the model as an error result, as text it can act on. */ -export const make = (input: { - readonly server: string; - readonly tool: McpTool; +export const define = (input: { readonly name: string; + readonly label: string; + readonly description: string; + readonly parameters: Record; + /** Who answers, for the details kept with the result: a server, or none for a tool reaching several. */ + readonly server: string; + readonly tool: string; readonly exposure: Exposure; - readonly call: (call: Call) => Effect.Effect; + readonly run: (run: Run) => Effect.Effect; }) => { - const { server, tool } = input; - const title = tool.title ?? tool.annotations?.title; - const failed = (message: string): Outcome => ({ - content: [{ type: "text", text: message }], - server, - tool: tool.name, + const outcome = (result: Result): Outcome => ({ + content: result.content, + server: input.server, + tool: input.tool, + ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }), }); - const schema = parameters(tool.inputSchema); return Tool.register( Tool.make({ name: input.name, - label: `${server}/${tool.name}`, + label: input.label, exposure: input.exposure, - description: tool.description?.trim() || title || `MCP tool ${tool.name} from server ${server}`, - parameters: Tool.fromJsonSchema(schema), + description: input.description, + parameters: Tool.fromJsonSchema(input.parameters), success: Outcome, failure: Outcome, - encodeContent: (outcome) => outcome.content as McpContent.Content[], - encodeFailureContent: (outcome) => outcome.content as McpContent.Content[], + encodeContent: (result) => result.content as McpContent.Content[], + encodeFailureContent: (result) => result.content as McpContent.Content[], encodeDetails: ({ content: _content, ...details }) => details, - // The arguments as the model wrote them, minus the `null`s strict sampling writes for omitted - // optional ones: validation only checks them, and the server, not a translation of its schema, - // decides what they mean. handler: (_params, ctx) => Effect.gen(function* () { const progress = yield* ToolProgress; const report = Effect.runForkWith(yield* Effect.context()); const result = yield* input - .call({ - args: withoutOptionalNulls(ctx.rawArgs, schema), + .run({ + args: withoutOptionalNulls(ctx.rawArgs, input.parameters), onProgress: (update) => { const total = update.total === undefined ? "" : `/${update.total}`; const text = update.message ?? `Progress ${update.progress}${total}`; report(progress.report({ content: [{ type: "text", text }] })); }, }) - .pipe(Effect.mapError(failed)); - const content = McpContent.fromResult(result); - if (result.isError && !content.some((block) => block.type === "text" && block.text !== "")) - content.push({ type: "text", text: `MCP tool ${server}/${tool.name} returned an error` }); - const outcome: Outcome = { - content, - server, - tool: tool.name, - ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }), - }; - return result.isError ? yield* Effect.fail(outcome) : outcome; + .pipe(Effect.mapError((message) => outcome({ content: [{ type: "text", text: message }] }))); + return result.isError ? yield* Effect.fail(outcome(result)) : outcome(result); }), }), ); }; +/** Tool input schemas must be objects; servers may omit `type`, and some providers want `properties`. */ +const parameters = (schema: McpTool["inputSchema"]): Record => ({ + ...schema, + type: schema.type ?? "object", + ...(schema.properties === undefined ? { properties: {} } : {}), +}); + +/** + * A server tool as a harness tool. `call` resolves the server's current connection when it runs, so + * the definition can outlive the connection it was listed on. + */ +export const make = (input: { + readonly server: string; + readonly tool: McpTool; + readonly name: string; + readonly exposure: Exposure; + readonly call: (run: Run) => Effect.Effect; + readonly content: McpContent.Options; +}) => { + const { server, tool } = input; + const title = tool.title ?? tool.annotations?.title; + return define({ + name: input.name, + label: `${server}/${tool.name}`, + description: tool.description?.trim() || title || `MCP tool ${tool.name} from server ${server}`, + parameters: parameters(tool.inputSchema), + server, + tool: tool.name, + exposure: input.exposure, + run: (run) => + Effect.gen(function* () { + const result = yield* input.call(run); + const content = yield* McpContent.fromResult(server, result, input.content); + if (result.isError && !content.some((block) => block.type === "text" && block.text !== "")) + content.push({ type: "text", text: `MCP tool ${server}/${tool.name} returned an error` }); + return { + content, + ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }), + ...(result.isError ? { isError: true } : {}), + }; + }), + }); +}; + export * as McpTool from "./tool.ts"; diff --git a/packages/harness/src/tool/truncate.ts b/packages/harness/src/tool/truncate.ts index eeed56f3..70fda5c0 100644 --- a/packages/harness/src/tool/truncate.ts +++ b/packages/harness/src/tool/truncate.ts @@ -258,3 +258,40 @@ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { return buf.subarray(start).toString("utf-8"); } + +export interface MiddleTruncationResult { + content: string; + truncated: boolean; + /** Characters cut from the middle. */ + removedChars: number; + totalBytes: number; + totalLines: number; +} + +/** + * Keep the start and the end of `content` within `maxBytes`, cutting the middle and saying how much + * went: for output whose beginning and end both matter. Cuts fall on character boundaries. + */ +export function truncateMiddle(content: string, maxBytes: number): MiddleTruncationResult { + const buffer = Buffer.from(content, "utf-8"); + const totalLines = splitLinesForCounting(content).length; + if (buffer.length <= maxBytes) { + return { content, truncated: false, removedChars: 0, totalBytes: buffer.length, totalLines }; + } + // Continuation bytes (10xxxxxx) are not character starts. + const isBoundary = (index: number) => index >= buffer.length || (buffer[index]! & 0xc0) !== 0x80; + let headEnd = Math.floor(maxBytes / 2); + while (headEnd > 0 && !isBoundary(headEnd)) headEnd--; + let tailStart = buffer.length - (maxBytes - Math.floor(maxBytes / 2)); + while (tailStart < buffer.length && !isBoundary(tailStart)) tailStart++; + const head = buffer.subarray(0, headEnd).toString("utf-8"); + const tail = buffer.subarray(tailStart).toString("utf-8"); + const removedChars = Array.from(buffer.subarray(headEnd, tailStart).toString("utf-8")).length; + return { + content: `${head}…${removedChars} chars truncated…${tail}`, + truncated: true, + removedChars, + totalBytes: buffer.length, + totalLines, + }; +} diff --git a/packages/harness/test/__artifacts__/mcp.client.json b/packages/harness/test/__artifacts__/mcp.client.json index 6563329e..e03546bd 100644 --- a/packages/harness/test/__artifacts__/mcp.client.json +++ b/packages/harness/test/__artifacts__/mcp.client.json @@ -300,7 +300,7 @@ second line", "type": "text", }, { - "data": "iVBORw0KGgo=", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "mimeType": "image/png", "type": "image", }, @@ -309,12 +309,12 @@ second line", "type": "text", }, { - "data": "iVBORw0KGgo=", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "mimeType": "image/png", "type": "image", }, { - "text": "[binary resource file:///data.bin (unknown type) omitted]", + "text": "[Binary resource file:///data.bin (unknown type, 3B) saved to .bin]", "type": "text", }, { @@ -322,7 +322,11 @@ second line", "type": "text", }, { - "text": "readme: file:///README.md", + "text": "[Resource file:///README.md "readme"]", + "type": "text", + }, + { + "text": "[Image omitted: could not be converted to a supported inline image format.]", "type": "text", }, ], diff --git a/packages/harness/test/__artifacts__/mcp.session.json b/packages/harness/test/__artifacts__/mcp.session.json index cab967bc..6a6df06d 100644 --- a/packages/harness/test/__artifacts__/mcp.session.json +++ b/packages/harness/test/__artifacts__/mcp.session.json @@ -1,5 +1,5 @@ { - "requests": 6, + "requests": 8, "tools": [ "mcp__e2e__echo", "mcp__e2e__args", @@ -7,7 +7,13 @@ "mcp__e2e__pid", "mcp__e2e__crash", "mcp__e2e__my_tool_v2", - "mcp__e2e__bye" + "mcp__e2e__bye", + "mcp__e2e__cwd", + "mcp__e2e__link", + "mcp__e2e__big", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource" ], "echoTool": { "name": "mcp__e2e__echo", @@ -117,6 +123,42 @@ true ] ], + [ + [ + "e2e", + true + ], + [ + "off", + false + ], + [ + "my-srv", + false + ], + [ + "badheader", + true + ] + ], + [ + [ + "e2e", + true + ], + [ + "off", + false + ], + [ + "my-srv", + false + ], + [ + "badheader", + true + ] + ], [ [ "e2e", @@ -142,6 +184,8 @@ "mcp registry is closed: servers are settled before the tool domain runs", "mcp registry is closed: servers are settled before the tool domain runs", "mcp registry is closed: servers are settled before the tool domain runs", + "mcp registry is closed: servers are settled before the tool domain runs", + "mcp registry is closed: servers are settled before the tool domain runs", "mcp registry is closed: servers are settled before the tool domain runs" ] }, @@ -215,8 +259,85 @@ "content": [ "Invalid Arguments for mcp__e2e__args: Expected no excess property\n at [\"extra\"]" ] + }, + "cwd": { + "status": "completed", + "isError": false, + "content": [ + "/work/srv" + ] + }, + "link": { + "status": "completed", + "isError": false, + "content": [ + "[Resource fixture://notes.txt \"notes\" (text/plain, 9B). Read it with read_mcp_resource (server \"e2e\")]" + ] + }, + "resources-all": { + "status": "completed", + "isError": false, + "content": [ + "{\"resources\":[{\"server\":\"e2e\",\"name\":\"notes\",\"uri\":\"fixture://notes.txt\",\"mimeType\":\"text/plain\"},{\"server\":\"e2e\",\"name\":\"data\",\"uri\":\"fixture://data.bin\",\"mimeType\":\"application/octet-stream\"}]}" + ] + }, + "resources-page": { + "status": "completed", + "isError": false, + "content": [ + "{\"server\":\"e2e\",\"resources\":[{\"server\":\"e2e\",\"name\":\"notes\",\"uri\":\"fixture://notes.txt\",\"mimeType\":\"text/plain\"}],\"nextCursor\":\" page-2 \"}" + ] + }, + "resources-next": { + "status": "completed", + "isError": false, + "content": [ + "{\"server\":\"e2e\",\"resources\":[{\"server\":\"e2e\",\"name\":\"data\",\"uri\":\"fixture://data.bin\",\"mimeType\":\"application/octet-stream\"}]}" + ] + }, + "resources-blank": { + "status": "completed", + "isError": false, + "content": [ + "{\"server\":\"e2e\",\"resources\":[{\"server\":\"e2e\",\"name\":\"notes\",\"uri\":\"fixture://notes.txt\",\"mimeType\":\"text/plain\"}],\"nextCursor\":\" page-2 \"}" + ] + }, + "templates": { + "status": "completed", + "isError": false, + "content": [ + "{\"resourceTemplates\":[{\"server\":\"e2e\",\"name\":\"daily notes\",\"uriTemplate\":\"fixture://notes/{day}\"}]}" + ] + }, + "read-text": { + "status": "completed", + "isError": false, + "content": [ + "the notes" + ] + }, + "read-binary": { + "status": "completed", + "isError": false, + "content": [ + "[Binary resource fixture://data.bin (application/octet-stream, 4B) saved to .bin]" + ] + }, + "read-unknown": { + "status": "error", + "isError": true, + "content": [ + "MCP server \"nope\" has no resources. Servers with resources: e2e" + ] } }, + "big": [ + "Warning: truncated output (original token count: 7683)", + "Total output lines: 1", + "", + "", + "[Full output: .txt (read it with offset/limit)]" + ], "status": [ { "server": "e2e", @@ -253,6 +374,18 @@ { "name": "bye", "exposure": "direct" + }, + { + "name": "cwd", + "exposure": "direct" + }, + { + "name": "link", + "exposure": "direct" + }, + { + "name": "big", + "exposure": "direct" } ] }, diff --git a/packages/harness/test/__artifacts__/mcp.session.lifecycle.json b/packages/harness/test/__artifacts__/mcp.session.lifecycle.json index 1fcd7319..bf559d8b 100644 --- a/packages/harness/test/__artifacts__/mcp.session.lifecycle.json +++ b/packages/harness/test/__artifacts__/mcp.session.lifecycle.json @@ -65,7 +65,13 @@ "mcp__e2e__my_tool_v2", "mcp__e2e__secret", "mcp__e2e__bye", - "mcp__remote__echo" + "mcp__e2e__cwd", + "mcp__e2e__link", + "mcp__e2e__big", + "mcp__remote__echo", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource" ], [ "mcp__e2e__echo", @@ -76,7 +82,13 @@ "mcp__e2e__my_tool_v2", "mcp__e2e__secret", "mcp__e2e__bye", - "mcp__remote__echo" + "mcp__e2e__cwd", + "mcp__e2e__link", + "mcp__e2e__big", + "mcp__remote__echo", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource" ], [ "mcp__e2e__echo", @@ -87,7 +99,13 @@ "mcp__e2e__my_tool_v2", "mcp__e2e__secret", "mcp__e2e__bye", - "mcp__remote__echo" + "mcp__e2e__cwd", + "mcp__e2e__link", + "mcp__e2e__big", + "mcp__remote__echo", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource" ], [ "mcp__e2e__echo", @@ -97,7 +115,13 @@ "mcp__e2e__crash", "mcp__e2e__my_tool_v2", "mcp__e2e__secret", - "mcp__remote__echo" + "mcp__e2e__cwd", + "mcp__e2e__link", + "mcp__e2e__big", + "mcp__remote__echo", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource" ], [ "mcp__remote__echo", @@ -107,7 +131,13 @@ "mcp__e2e__pid", "mcp__e2e__crash", "mcp__e2e__my_tool_v2", - "mcp__e2e__secret" + "mcp__e2e__secret", + "mcp__e2e__cwd", + "mcp__e2e__link", + "mcp__e2e__big", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource" ] ], "remote": [ @@ -172,6 +202,18 @@ { "name": "bye", "exposure": "hidden" + }, + { + "name": "cwd", + "exposure": "direct" + }, + { + "name": "link", + "exposure": "direct" + }, + { + "name": "big", + "exposure": "direct" } ] } diff --git a/packages/harness/test/__artifacts__/mcp.thread.json b/packages/harness/test/__artifacts__/mcp.thread.json index 0f406a30..4ad70663 100644 --- a/packages/harness/test/__artifacts__/mcp.thread.json +++ b/packages/harness/test/__artifacts__/mcp.thread.json @@ -5,6 +5,12 @@ "mcp__e2e__crash", "mcp__e2e__my_tool_v2", "mcp__e2e__bye", + "mcp__e2e__cwd", + "mcp__e2e__link", + "mcp__e2e__big", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "toolsearch" ], "registeredMain": [ @@ -39,6 +45,18 @@ { "name": "mcp__e2e__bye", "exposure": "direct" + }, + { + "name": "mcp__e2e__cwd", + "exposure": "direct" + }, + { + "name": "mcp__e2e__link", + "exposure": "direct" + }, + { + "name": "mcp__e2e__big", + "exposure": "direct" } ], "forkRegistered": [ @@ -74,6 +92,18 @@ "name": "mcp__e2e__bye", "exposure": "direct" }, + { + "name": "mcp__e2e__cwd", + "exposure": "direct" + }, + { + "name": "mcp__e2e__link", + "exposure": "direct" + }, + { + "name": "mcp__e2e__big", + "exposure": "direct" + }, { "name": "mcp__extra__echo", "exposure": "deferred" @@ -105,6 +135,18 @@ { "name": "mcp__extra__bye", "exposure": "deferred" + }, + { + "name": "mcp__extra__cwd", + "exposure": "deferred" + }, + { + "name": "mcp__extra__link", + "exposure": "deferred" + }, + { + "name": "mcp__extra__big", + "exposure": "deferred" } ], "mainStatus": { @@ -142,6 +184,18 @@ { "name": "bye", "exposure": "direct" + }, + { + "name": "cwd", + "exposure": "direct" + }, + { + "name": "link", + "exposure": "direct" + }, + { + "name": "big", + "exposure": "direct" } ] }, @@ -190,6 +244,18 @@ { "name": "bye", "exposure": "direct" + }, + { + "name": "cwd", + "exposure": "direct" + }, + { + "name": "link", + "exposure": "direct" + }, + { + "name": "big", + "exposure": "direct" } ] }, @@ -236,6 +302,18 @@ { "name": "bye", "exposure": "deferred" + }, + { + "name": "cwd", + "exposure": "deferred" + }, + { + "name": "link", + "exposure": "deferred" + }, + { + "name": "big", + "exposure": "deferred" } ] } @@ -277,6 +355,18 @@ { "name": "bye", "exposure": "hidden" + }, + { + "name": "cwd", + "exposure": "hidden" + }, + { + "name": "link", + "exposure": "hidden" + }, + { + "name": "big", + "exposure": "hidden" } ] }, @@ -286,7 +376,13 @@ "mcp__e2e__crash", "mcp__e2e__my_tool_v2", "mcp__e2e__bye", + "mcp__e2e__cwd", + "mcp__e2e__link", + "mcp__e2e__big", "mcp__gated__pid", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "toolsearch" ], "disconnected": { @@ -324,6 +420,18 @@ { "name": "bye", "exposure": "hidden" + }, + { + "name": "cwd", + "exposure": "hidden" + }, + { + "name": "link", + "exposure": "hidden" + }, + { + "name": "big", + "exposure": "hidden" } ] }, @@ -333,6 +441,12 @@ "mcp__e2e__crash", "mcp__e2e__my_tool_v2", "mcp__e2e__bye", + "mcp__e2e__cwd", + "mcp__e2e__link", + "mcp__e2e__big", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "toolsearch" ], "stillDisconnected": "disconnected", @@ -410,6 +524,18 @@ { "name": "bye", "exposure": "direct" + }, + { + "name": "cwd", + "exposure": "direct" + }, + { + "name": "link", + "exposure": "direct" + }, + { + "name": "big", + "exposure": "direct" } ] }, @@ -452,7 +578,10 @@ "crash", "my-tool.v2", "secret", - "bye" + "bye", + "cwd", + "link", + "big" ] }, { @@ -475,7 +604,10 @@ "crash", "my-tool.v2", "secret", - "bye" + "bye", + "cwd", + "link", + "big" ] }, { @@ -493,7 +625,10 @@ "crash", "my-tool.v2", "secret", - "bye" + "bye", + "cwd", + "link", + "big" ] } ], @@ -522,7 +657,10 @@ "crash", "my-tool.v2", "secret", - "bye" + "bye", + "cwd", + "link", + "big" ] }, { @@ -544,7 +682,10 @@ "crash", "my-tool.v2", "secret", - "bye" + "bye", + "cwd", + "link", + "big" ] }, { @@ -566,7 +707,10 @@ "crash", "my-tool.v2", "secret", - "bye" + "bye", + "cwd", + "link", + "big" ] } ], @@ -594,7 +738,10 @@ "crash", "my-tool.v2", "secret", - "bye" + "bye", + "cwd", + "link", + "big" ] } ], @@ -631,7 +778,10 @@ "crash", "my-tool.v2", "secret", - "bye" + "bye", + "cwd", + "link", + "big" ] } ] diff --git a/packages/harness/test/__artifacts__/mcp.thread.loaded.json b/packages/harness/test/__artifacts__/mcp.thread.loaded.json index bc01304b..1e118ef6 100644 --- a/packages/harness/test/__artifacts__/mcp.thread.loaded.json +++ b/packages/harness/test/__artifacts__/mcp.thread.loaded.json @@ -3,7 +3,8 @@ "awaitedChanges": [ { "added": [ - "mcp__slow__echo" + "mcp__slow__echo", + "mcp__slow__big" ], "removed": [] } @@ -11,44 +12,50 @@ "whileAway": [ { "added": [ - "mcp__slow__echo" + "mcp__slow__echo", + "mcp__slow__big" ], "removed": [] }, { "added": [], "removed": [ - "mcp__slow__echo" + "mcp__slow__echo", + "mcp__slow__big" ] } ], "loadedWhileAway": { "add": [ - "mcp__slow__echo" + "mcp__slow__echo", + "mcp__slow__big" ] }, "back": [ { "added": [ - "mcp__slow__echo" + "mcp__slow__echo", + "mcp__slow__big" ], "removed": [] }, { "added": [], "removed": [ - "mcp__slow__echo" + "mcp__slow__echo", + "mcp__slow__big" ] }, { "added": [ - "mcp__slow__echo" + "mcp__slow__echo", + "mcp__slow__big" ], "removed": [] } ], "calls": { - "search": "Loaded 1 tool. They are available from your next call:\n- mcp__slow__echo: Echo text back, prefixed with the configured greeting.", + "search": "Loaded 2 tools. They are available from your next call:\n- mcp__slow__echo: Echo text back, prefixed with the configured greeting.\n- mcp__slow__big: More text than the model is shown.", "awaited": "hello: awaited" } } diff --git a/packages/harness/test/fixtures/mcp/session.mjs b/packages/harness/test/fixtures/mcp/session.mjs index 158b1095..1625e73e 100644 --- a/packages/harness/test/fixtures/mcp/session.mjs +++ b/packages/harness/test/fixtures/mcp/session.mjs @@ -18,7 +18,10 @@ if (process.env.E2E_DELAY) await new Promise((resolve) => setTimeout(resolve, Nu const object = (properties = {}, required = []) => ({ type: "object", properties, required }); const text = (value) => ({ content: [{ type: "text", text: String(value) }] }); -const server = new Server({ name: "session-fixture", version: "1.0.0" }, { capabilities: { tools: {} } }); +const server = new Server( + { name: "session-fixture", version: "1.0.0" }, + { capabilities: { tools: {}, resources: {} } }, +); server.setRequestHandler("tools/list", () => ({ tools: [ { @@ -40,8 +43,31 @@ server.setRequestHandler("tools/list", () => ({ { name: "my-tool.v2", inputSchema: { type: "object" } }, { name: "secret", description: "Never declared.", inputSchema: object() }, { name: "bye", description: "Answer, then exit while idle.", inputSchema: object() }, + { name: "cwd", description: "The server's working directory.", inputSchema: object() }, + { name: "link", description: "Point at a resource.", inputSchema: object() }, + { name: "big", description: "More text than the model is shown.", inputSchema: object() }, ], })); + +const resources = { + "": { + resources: [ + { uri: "fixture://notes.txt", name: "notes", mimeType: "text/plain" }, + { uri: "ui://widget", name: "widget" }, + ], + nextCursor: " page-2 ", + }, + " page-2 ": { resources: [{ uri: "fixture://data.bin", name: "data", mimeType: "application/octet-stream" }] }, +}; +const contents = { + "fixture://notes.txt": [{ uri: "fixture://notes.txt", mimeType: "text/plain", text: "the notes" }], + "fixture://data.bin": [{ uri: "fixture://data.bin", mimeType: "application/octet-stream", blob: "AAECAw==" }], +}; +server.setRequestHandler("resources/list", (request) => resources[request.params?.cursor ?? ""]); +server.setRequestHandler("resources/templates/list", () => ({ + resourceTemplates: [{ uriTemplate: "fixture://notes/{day}", name: "daily notes" }], +})); +server.setRequestHandler("resources/read", (request) => ({ contents: contents[request.params.uri] ?? [] })); server.setRequestHandler("tools/call", (request) => { const args = request.params.arguments ?? {}; switch (request.params.name) { @@ -60,6 +86,16 @@ server.setRequestHandler("tools/call", (request) => { case "bye": setTimeout(() => process.exit(0), 20); return text("bye"); + case "cwd": + return text(process.cwd()); + case "link": + return { + content: [ + { type: "resource_link", uri: "fixture://notes.txt", name: "notes", mimeType: "text/plain", size: 9 }, + ], + }; + case "big": + return text(`start ${"x".repeat(30 * 1024)} end`); case "my-tool.v2": return { content: [], structuredContent: { version: 2 } }; default: diff --git a/packages/harness/test/fixtures/mcp/tools.mjs b/packages/harness/test/fixtures/mcp/tools.mjs index 2a404216..60945b84 100644 --- a/packages/harness/test/fixtures/mcp/tools.mjs +++ b/packages/harness/test/fixtures/mcp/tools.mjs @@ -1,7 +1,8 @@ // Tools shared by the stdio fixture and the in-process streamable HTTP fixture. import { spawn } from "node:child_process"; -const PNG = "iVBORw0KGgo="; +// One transparent pixel. +const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; const sleep = (ms, signal) => new Promise((resolve) => { const timer = setTimeout(resolve, ms); @@ -71,6 +72,8 @@ export const install = (server) => { { type: "resource", resource: { uri: "file:///data.bin", blob: "AAEC" } }, { type: "audio", data: "AAAA", mimeType: "audio/wav" }, { type: "resource_link", name: "readme", uri: "file:///README.md" }, + // No provider takes SVG inline. + { type: "image", data: Buffer.from("").toString("base64"), mimeType: "image/svg+xml" }, ], }; case "structured": diff --git a/packages/harness/test/mcp.client.test.ts b/packages/harness/test/mcp.client.test.ts index 613bdd34..fc089cd3 100644 --- a/packages/harness/test/mcp.client.test.ts +++ b/packages/harness/test/mcp.client.test.ts @@ -1,4 +1,5 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; @@ -6,10 +7,11 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import type { LoggingMessageNotificationParams, OAuthClientProvider } from "@modelcontextprotocol/client"; import { Deferred, Effect, Exit, Fiber, Option, Ref, Scope } from "effect"; -import { describe, expect, vi } from "vite-plus/test"; +import { describe, expect, onTestFinished, vi } from "vite-plus/test"; import { McpClient } from "../src/mcp/client.ts"; import { McpContent } from "../src/mcp/content.ts"; import { McpLog } from "../src/mcp/log.ts"; +import { McpOutput } from "../src/mcp/output.ts"; import { Process } from "../src/sandbox/utils/process.ts"; import { listen, raw, sdk } from "./fixtures/mcp/http.ts"; import { testEffect } from "./utils/effect.ts"; @@ -24,6 +26,8 @@ const stdio = (fixture: string, env: Record = {}): McpClient.Std env, cwd: fixtures, }); +/** Saved output stays on the host; the fixture servers offer no resources to link to. */ +const content: McpContent.Options = { save: McpOutput.save, readable: false }; const http = (url: URL): McpClient.Http => ({ _tag: "Http", url, headers: {} }); /** Each scenario's observable outcome, written to the artifact by the last test. */ @@ -130,26 +134,40 @@ describe("mcp client", () => { const echo = yield* connection.callTool({ name: "echo", args: { text: "hi" } }); const mixed = yield* connection.callTool({ name: "mixed" }); const structured = yield* connection.callTool({ name: "structured" }); + const converted = { + echo: yield* McpContent.fromResult("fixture", echo, content), + mixed: yield* McpContent.fromResult("fixture", mixed, content), + structured: yield* McpContent.fromResult("fixture", structured, content), + }; summary.stdioHappy = { serverInfo: connection.serverInfo, instructions: connection.instructions, tools: tools.map((tool) => tool.name), - echo: McpContent.fromResult(echo), - mixed: McpContent.fromResult(mixed), - structured: McpContent.fromResult(structured), + echo: converted.echo, + mixed: converted.mixed.map((block) => + block.type === "text" + ? { ...block, text: block.text.replace(/saved to .+\]$/, "saved to .bin]") } + : block, + ), + structured: converted.structured, }; expect(connection.instructions).toBe("Use the echo tool."); - expect(McpContent.fromResult(echo)).toEqual([{ type: "text", text: "hi" }]); - expect(McpContent.fromResult(mixed)).toEqual([ + expect(converted.echo).toEqual([{ type: "text", text: "hi" }]); + const saved = /saved to (.+)\]$/.exec((converted.mixed[4] as { readonly text: string }).text)?.[1]; + expect(saved).toBeDefined(); + onTestFinished(() => rmSync(saved!, { force: true })); + expect(yield* Effect.promise(() => readFile(saved!))).toEqual(Buffer.from([0, 1, 2])); + expect(converted.mixed).toEqual([ { type: "text", text: "hello" }, - { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }, + { type: "image", data: expect.any(String), mimeType: "image/png" }, { type: "text", text: "embedded notes" }, - { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }, - { type: "text", text: "[binary resource file:///data.bin (unknown type) omitted]" }, + { type: "image", data: expect.any(String), mimeType: "image/png" }, + { type: "text", text: `[Binary resource file:///data.bin (unknown type, 3B) saved to ${saved}]` }, { type: "text", text: "[audio audio/wav omitted]" }, - { type: "text", text: "readme: file:///README.md" }, + { type: "text", text: '[Resource file:///README.md "readme"]' }, + { type: "text", text: "[Image omitted: could not be converted to a supported inline image format.]" }, ]); - expect(McpContent.fromResult(structured)).toEqual([ + expect(converted.structured).toEqual([ { type: "text", text: JSON.stringify({ answer: 42, items: ["a", "b"] }, null, 2) }, ]); }), @@ -165,7 +183,7 @@ describe("mcp client", () => { summary.httpHappy = { serverInfo: connection.serverInfo, tools: tools.map((tool) => tool.name), - echo: McpContent.fromResult(echo), + echo: yield* McpContent.fromResult("http", echo, content), }; expect(text(echo)).toBe("over http"); }), diff --git a/packages/harness/test/mcp.session.test.ts b/packages/harness/test/mcp.session.test.ts index fb1d4d6d..3a0d6ce6 100644 --- a/packages/harness/test/mcp.session.test.ts +++ b/packages/harness/test/mcp.session.test.ts @@ -4,7 +4,7 @@ import { Effect } from "effect"; import fs from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { expect, it } from "vite-plus/test"; +import { expect, it, onTestFinished } from "vite-plus/test"; import { ContextCodec } from "../src/context/codec.ts"; import { Harness } from "../src/effect/harness.ts"; import { Session } from "../src/effect/session.ts"; @@ -56,7 +56,8 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-turn and the ne const home = join(dir.path, "home"); const project = join(dir.path, "project"); const work = join(dir.path, "work"); - await fs.mkdir(work, { recursive: true }); + // A server's own relative `cwd` resolves against the session's directory on a host sandbox. + await fs.mkdir(join(work, "srv"), { recursive: true }); process.env.MCP_E2E_GREETING = "hello"; // A `!command` printing a notice after the token, say: never sent, and never shown. process.env.MCP_E2E_BAD_HEADER = "Bearer sk-e2e-secret\nnotice"; @@ -66,6 +67,7 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-turn and the ne await write(join(home, "mcp.json"), { mcpServers: { e2e: stdio({ + cwd: "srv", toolExposure: { secret: "hidden" }, env: { E2E_GREETING: "${MCP_E2E_GREETING}", E2E_PEM: "${MCP_E2E_PEM}" }, }), @@ -124,6 +126,23 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-turn and the ne pendingCall("mcp__e2e__args", { word: "abc", extra: true }, "excess"), ], [], + // Third turn: where the server runs, what the model is not shown in full, and resources. + [ + pendingCall("mcp__e2e__cwd", {}, "cwd"), + pendingCall("mcp__e2e__link", {}, "link"), + pendingCall("mcp__e2e__big", {}, "big"), + pendingCall("list_mcp_resources", {}, "resources-all"), + pendingCall("list_mcp_resources", { server: "e2e" }, "resources-page"), + // The server's cursor goes back exactly as it was given, spaces and all. + pendingCall("list_mcp_resources", { server: "e2e", cursor: " page-2 " }, "resources-next"), + // Models fill optional strings with "": a blank cursor is no cursor. + pendingCall("list_mcp_resources", { server: "e2e", cursor: "" }, "resources-blank"), + pendingCall("list_mcp_resource_templates", {}, "templates"), + pendingCall("read_mcp_resource", { server: "e2e", uri: "fixture://notes.txt" }, "read-text"), + pendingCall("read_mcp_resource", { server: "e2e", uri: "fixture://data.bin" }, "read-binary"), + pendingCall("read_mcp_resource", { server: "nope", uri: "fixture://x" }, "read-unknown"), + ], + [], ], requests, ); @@ -136,6 +155,7 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-turn and the ne }); yield* session.main.run("Use the fixture."); yield* session.main.run("Once more."); + yield* session.main.run("Resources."); const messages = yield* Effect.forEach(yield* messageEntries(session.main), ContextCodec.decodeMessage); const status = yield* Mcp.Service.use((mcp) => mcp.status(session.main.id)); const settings = yield* Settings.Service.use((loader) => loader.load(project)); @@ -186,9 +206,30 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-turn and the ne required: ["text"], }); + // The server reports its directory as the OS resolves it, through any symlink in the temp path. + expect(outcome("cwd")?.content).toEqual([await fs.realpath(join(work, "srv"))]); + + // What the model is not shown in full is kept on the host, in files only the user can read. + // Paths are read between the message's own delimiters, so a temp directory with spaces still parses. + const bigFile = /\[Full output: (.+) \(read it with offset\/limit\)\]/.exec(outcome("big")?.content[0] ?? "")?.[1]; + const binaryFile = /saved to (.+)\]$/.exec(outcome("read-binary")?.content[0] ?? "")?.[1]; + onTestFinished(async () => { + for (const file of [bigFile, binaryFile]) if (file) await fs.rm(file, { force: true }); + }); + expect(await fs.readFile(bigFile!, "utf8")).toBe(`start ${"x".repeat(30 * 1024)} end`); + expect([...(await fs.readFile(binaryFile!))]).toEqual([0, 1, 2, 3]); + expect((await fs.stat(binaryFile!)).mode & 0o777).toBe(0o600); + const big = outcome("big")!.content[0]!; + expect(Buffer.byteLength(big)).toBeLessThan(21 * 1024); + expect(big).toMatch(/^start x+…\d+ chars truncated…x+ end$/m); + + const real = await fs.realpath(dir.path); const normalise = (value: unknown) => JSON.parse( JSON.stringify(value) + .replaceAll(bigFile!, ".txt") + .replaceAll(binaryFile!, ".bin") + .replaceAll(real, "") .replaceAll(dir.path, "") .replaceAll(process.execPath, "") .replaceAll(`"${pidBefore}"`, '""') @@ -211,8 +252,22 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-turn and the ne "invalid", "pattern", "excess", + "cwd", + "link", + "resources-all", + "resources-page", + "resources-next", + "resources-blank", + "templates", + "read-text", + "read-binary", + "read-unknown", ].map((id) => [id, outcome(id)]), ), + // The cut text itself is checked above; its frame is what the model reads around it. + big: outcome("big") + ?.content[0]?.split("\n") + .filter((line) => !line.startsWith("start ")), status: result.status, configErrors: result.errors, }); diff --git a/sites/www/content/docs/customize/mcp.mdx b/sites/www/content/docs/customize/mcp.mdx new file mode 100644 index 00000000..ffc65dee --- /dev/null +++ b/sites/www/content/docs/customize/mcp.mdx @@ -0,0 +1,73 @@ +--- +title: MCP Servers +sidebarTitle: MCP +description: Connect Model Context Protocol servers and give their tools to the model. +--- + +CodeWork connects to [MCP](https://modelcontextprotocol.io) servers over stdio or streamable HTTP. Their tools reach the model as `mcp____`. + +## Where Servers Run + +MCP servers always run on your machine, the one running CodeWork, whatever sandbox the session uses. + +- 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. +- HTTP servers are called from your machine. +- Output too large to show, and binary files, are saved to your OS temp directory. + +A sandbox is only where files and commands run. To give MCP access to a remote sandbox, build an HTTP MCP server that talks to it. + +## Configure Servers + +User servers go in `~/.codework/mcp.json`, project servers in `.codework/mcp.json`. The format matches other MCP clients, so you can copy entries over: + +```json title="~/.codework/mcp.json" +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] + }, + "docs": { + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer ${DOCS_TOKEN}" } + } + } +} +``` + +| Field | Server | Meaning | +| ------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------- | +| `command`, `args`, `env`, `cwd` | stdio | One executable and its arguments, not a shell line. `cwd` is relative to where the server starts. | +| `url`, `headers` | HTTP | A streamable HTTP endpoint. SSE is not supported. | +| `enabled`, `timeout`, `exposure`, `toolExposure` | both | Off switch, per-request timeout in seconds (default 60), and which tools the model sees. | + +`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. + +Servers that need you to sign in are not supported yet. + +A broken file or entry is reported and skipped. The other servers still connect. + +## Project Overrides + +A project entry with the same name replaces a user entry. An entry with only `enabled`, `exposure` or `toolExposure` changes just those, so credentials stay in your user file: + +```json title=".codework/mcp.json" +{ "mcpServers": { "internal-tools": { "enabled": false } } } +``` + +## Which Tools the Model Sees + +`exposure` is `direct` (the default) or `hidden`. `toolExposure` sets single tools, by name or with `*` patterns: + +```json +{ "exposure": "hidden", "toolExposure": { "search_*": "direct" } } +``` + +Servers with resources also add `list_mcp_resources`, `list_mcp_resource_templates` and `read_mcp_resource`. + +## Connections + +- 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. +- If a server stops, the next call starts it again. If it fails to connect, your next message tries again. +- Edits to `mcp.json` apply from your next message. Changing only `exposure` or `toolExposure` keeps the server running. +- Server log messages go to `~/.codework/mcp.log`. diff --git a/sites/www/content/docs/customize/meta.json b/sites/www/content/docs/customize/meta.json index 307eb8f3..726d002a 100644 --- a/sites/www/content/docs/customize/meta.json +++ b/sites/www/content/docs/customize/meta.json @@ -1,4 +1,4 @@ { "title": "Customize", - "pages": ["instructions", "settings"] + "pages": ["instructions", "settings", "mcp"] }