diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 508fd63a2..c914a2361 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog ## [Unreleased] - ### Added +- Added `mcp://` internal URL protocol for reading MCP server resources directly via the read tool (e.g., `read(path="mcp://resource-uri")`) - Added LM Studio integration to the model registry and discovery flow. - Added support for authenticating with LM Studio using the `/login lm-studio` command. - Added `fuse-projfs` task isolation mode for Windows ProjFS-backed overlays. @@ -13,17 +13,23 @@ ### Changed +- Updated MCP resource update notifications to recommend using `read(path="mcp://")` instead of the deprecated `read_resource` tool - Updated Anthropic Foundry environment variable documentation and CLI help text to the canonical names: `CLAUDE_CODE_USE_FOUNDRY`, `CLAUDE_CODE_CLIENT_CERT`, and `CLAUDE_CODE_CLIENT_KEY` - Documented Foundry-specific Anthropic runtime configuration (`FOUNDRY_BASE_URL`, `ANTHROPIC_FOUNDRY_API_KEY`, `ANTHROPIC_CUSTOM_HEADERS`, `NODE_EXTRA_CA_CERTS`) in environment variable reference docs - `fuse-overlay` task isolation now targets `fuse-overlayfs` on Unix hosts only; on Windows it falls back to `worktree` with a `` suggesting `fuse-projfs`. - `fuse-projfs` now performs Windows ProjFS preflight checks and falls back to `worktree` when host or repository prerequisites are unavailable. - Cross-repo patch capture now uses the platform null device (`NUL` on Windows, `/dev/null` elsewhere) for `git diff --no-index`. +### Removed + +- Removed `read_resource` tool; MCP resource reading is now integrated into the `read` tool via `mcp://` URLs + ### Fixed - Fixed MCP resource subscription handling to prevent unsubscribing when notifications are re-enabled after being disabled - Fixed LM Studio base URL validation to preserve invalid configured URLs instead of silently falling back to localhost - Fixed URI template matching to correctly handle expressions that expand to empty strings + ## [13.5.6] - 2026-03-01 ### Changed diff --git a/packages/coding-agent/src/internal-urls/index.ts b/packages/coding-agent/src/internal-urls/index.ts index 8dda9c877..fb1dbbcb3 100644 --- a/packages/coding-agent/src/internal-urls/index.ts +++ b/packages/coding-agent/src/internal-urls/index.ts @@ -1,5 +1,5 @@ /** - * Internal URL routing system for internal protocols like agent://, memory://, skill://, and local://. + * Internal URL routing system for internal protocols like agent://, memory://, skill://, mcp://, and local://. * * This module provides a unified way to resolve internal URLs without * exposing filesystem paths to the agent. @@ -25,6 +25,7 @@ export * from "./artifact-protocol"; export * from "./jobs-protocol"; export * from "./json-query"; export * from "./local-protocol"; +export * from "./mcp-protocol"; export * from "./memory-protocol"; export * from "./pi-protocol"; export * from "./router"; diff --git a/packages/coding-agent/src/internal-urls/mcp-protocol.ts b/packages/coding-agent/src/internal-urls/mcp-protocol.ts new file mode 100644 index 000000000..6fe0c110d --- /dev/null +++ b/packages/coding-agent/src/internal-urls/mcp-protocol.ts @@ -0,0 +1,156 @@ +import type { MCPManager } from "../mcp/manager"; +import type { MCPResourceReadResult } from "../mcp/types"; +import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; + +export interface McpProtocolOptions { + getMcpManager: () => MCPManager | undefined; +} + +function escapeRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function getUriTemplateMatchScore( + uri: string, + uriTemplate: string, +): { literalChars: number; expressionCount: number } | undefined { + const expressionPattern = /\{[^}]+\}/g; + const literalSegments = uriTemplate.split(expressionPattern); + const expressionCount = (uriTemplate.match(expressionPattern) ?? []).length; + const pattern = literalSegments.map(escapeRegex).join("(.*?)"); + const regex = new RegExp(`^${pattern}$`); + if (!regex.test(uri)) return undefined; + const literalChars = literalSegments.reduce((total, segment) => total + segment.length, 0); + return { literalChars, expressionCount }; +} + +function extractResourceUri(url: InternalUrl): string { + const host = url.rawHost || url.hostname; + const rawPathname = url.rawPathname ?? url.pathname; + const hasPath = rawPathname && rawPathname !== "/"; + const uri = `${host}${hasPath ? rawPathname : ""}${url.search}${url.hash}`.trim(); + if (!uri) { + throw new Error("mcp:// URL requires a resource URI: mcp://"); + } + return uri; +} + +function resolveTargetServer(mcpManager: MCPManager, uri: string): string | undefined { + const servers = mcpManager.getConnectedServers(); + for (const name of servers) { + const serverResources = mcpManager.getServerResources(name); + if (serverResources?.resources.some(r => r.uri === uri)) { + return name; + } + } + + let bestTemplateMatch: + | { + serverName: string; + literalChars: number; + expressionCount: number; + serverIndex: number; + templateIndex: number; + } + | undefined; + + for (const [serverIndex, name] of servers.entries()) { + const serverResources = mcpManager.getServerResources(name); + if (!serverResources) continue; + + for (const [templateIndex, template] of serverResources.templates.entries()) { + const match = getUriTemplateMatchScore(uri, template.uriTemplate); + if (!match) continue; + + const isBetterMatch = + !bestTemplateMatch || + match.literalChars > bestTemplateMatch.literalChars || + (match.literalChars === bestTemplateMatch.literalChars && + (match.expressionCount < bestTemplateMatch.expressionCount || + (match.expressionCount === bestTemplateMatch.expressionCount && + (serverIndex < bestTemplateMatch.serverIndex || + (serverIndex === bestTemplateMatch.serverIndex && + templateIndex < bestTemplateMatch.templateIndex))))); + + if (isBetterMatch) { + bestTemplateMatch = { + serverName: name, + literalChars: match.literalChars, + expressionCount: match.expressionCount, + serverIndex, + templateIndex, + }; + } + } + } + + return bestTemplateMatch?.serverName; +} + +function formatAvailableResources(mcpManager: MCPManager): string { + const available = mcpManager + .getConnectedServers() + .flatMap(name => { + const serverResources = mcpManager.getServerResources(name); + return (serverResources?.resources ?? []).map(r => ` ${r.uri} (${name})`); + }) + .join("\n"); + return available || " (none)"; +} + +/** + * Protocol handler for mcp:// URLs. + * + * URL form: + * - mcp:// (e.g. mcp://test://notes, mcp://ibkr://portfolio/positions) + */ +export class McpProtocolHandler implements ProtocolHandler { + readonly scheme = "mcp"; + + constructor(private readonly options: McpProtocolOptions) {} + + async resolve(url: InternalUrl): Promise { + const mcpManager = this.options.getMcpManager(); + if (!mcpManager) { + throw new Error("No MCP manager available. MCP servers may not be configured."); + } + + const uri = extractResourceUri(url); + const targetServer = resolveTargetServer(mcpManager, uri); + if (!targetServer) { + throw new Error( + `No MCP server has resource "${uri}".\n\nAvailable resources:\n${formatAvailableResources(mcpManager)}`, + ); + } + + let result: MCPResourceReadResult | undefined; + try { + result = await mcpManager.readServerResource(targetServer, uri); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`MCP resource read error: ${message}`); + } + + if (!result) { + throw new Error(`Server "${targetServer}" returned no content for "${uri}".`); + } + + const textParts: string[] = []; + for (const item of result.contents) { + if (item.text) { + textParts.push(item.text); + } else if (item.blob) { + textParts.push(`[Binary content: ${item.mimeType ?? "unknown"}, base64 length ${item.blob.length}]`); + } + } + + const content = textParts.join("\n---\n") || "(empty resource)"; + return { + url: url.href, + content, + contentType: "text/plain", + size: Buffer.byteLength(content, "utf-8"), + notes: [`MCP server: ${targetServer}`], + }; + } +} diff --git a/packages/coding-agent/src/internal-urls/router.ts b/packages/coding-agent/src/internal-urls/router.ts index 83f6abf50..7e06b775a 100644 --- a/packages/coding-agent/src/internal-urls/router.ts +++ b/packages/coding-agent/src/internal-urls/router.ts @@ -1,5 +1,5 @@ /** - * Internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, pi://, local://). + * Internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://). */ import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; diff --git a/packages/coding-agent/src/internal-urls/types.ts b/packages/coding-agent/src/internal-urls/types.ts index f8c4fd718..aec3b108b 100644 --- a/packages/coding-agent/src/internal-urls/types.ts +++ b/packages/coding-agent/src/internal-urls/types.ts @@ -1,8 +1,8 @@ /** * Types for the internal URL routing system. * - * Internal URLs (agent://, artifact://, memory://, skill://, rule://, pi://, local://) are resolved by tools like fetch and read, - * providing access to agent outputs and skill files without exposing filesystem paths. + * Internal URLs (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://) are resolved by tools like fetch and read, + * providing access to agent outputs and server resources without exposing filesystem paths. */ /** @@ -38,7 +38,7 @@ export interface InternalUrl extends URL { } /** - * Handler for a specific internal URL scheme (e.g., agent://, memory://, skill://). + * Handler for a specific internal URL scheme (e.g., agent://, memory://, skill://, mcp://). */ export interface ProtocolHandler { /** The scheme this handler processes (without trailing ://) */ diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index b0902c8c3..3b3d53e8a 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -56,6 +56,7 @@ import { InternalUrlRouter, JobsProtocolHandler, LocalProtocolHandler, + McpProtocolHandler, MemoryProtocolHandler, PiProtocolHandler, RuleProtocolHandler, @@ -872,7 +873,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} pendingActionStore, }; - // Initialize internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, local://) + // Initialize internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, local://) const internalRouter = new InternalUrlRouter(); const getArtifactsDir = () => sessionManager.getArtifactsDir(); internalRouter.register(new AgentProtocolHandler({ getArtifactsDir })); @@ -900,6 +901,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} ); internalRouter.register(new PiProtocolHandler()); internalRouter.register(new JobsProtocolHandler({ getAsyncJobManager: () => asyncJobManager })); + internalRouter.register(new McpProtocolHandler({ getMcpManager: () => mcpManager })); toolSession.internalRouter = internalRouter; toolSession.getArtifactsDir = getArtifactsDir; toolSession.agentOutputManager = new AgentOutputManager( @@ -1513,7 +1515,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Re-check: user may have disabled notifications during the debounce window if (!settings.get("mcp.notifications")) return; void session.followUp( - `[MCP notification] Server "${serverName}" reports resource \`${uri}\` was updated. Use read_resource to inspect if relevant.`, + `[MCP notification] Server "${serverName}" reports resource \`${uri}\` was updated. Use read(path="mcp://${uri}") to inspect if relevant.`, ); }, debounceMs), ); diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 6f4495e99..edffdcadc 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -27,7 +27,6 @@ import { ExitPlanModeTool } from "./exit-plan-mode"; import { FetchTool } from "./fetch"; import { FindTool } from "./find"; import { GrepTool } from "./grep"; -import { McpReadResourceTool } from "./mcp-read-resource"; import { NotebookTool } from "./notebook"; import { wrapToolWithMetaNotice } from "./output-meta"; import { PythonTool } from "./python"; @@ -63,7 +62,6 @@ export * from "./fetch"; export * from "./find"; export * from "./gemini-image"; export * from "./grep"; -export * from "./mcp-read-resource"; export * from "./notebook"; export * from "./pending-action"; export * from "./python"; @@ -131,7 +129,7 @@ export interface ToolSession { modelRegistry?: import("../config/model-registry").ModelRegistry; /** MCP manager for proxying MCP calls through parent */ mcpManager?: import("../mcp/manager").MCPManager; - /** Internal URL router for agent:// and skill:// URLs */ + /** Internal URL router for protocols like agent://, skill://, and mcp:// */ internalRouter?: InternalUrlRouter; /** Agent output manager for unique agent:// IDs across task invocations */ agentOutputManager?: AgentOutputManager; @@ -182,7 +180,6 @@ export const BUILTIN_TOOLS: Record = { fetch: s => new FetchTool(s), web_search: s => new SearchTool(s), write: s => new WriteTool(s), - read_resource: s => new McpReadResourceTool(() => s.mcpManager), }; export const HIDDEN_TOOLS: Record = { diff --git a/packages/coding-agent/src/tools/mcp-read-resource.ts b/packages/coding-agent/src/tools/mcp-read-resource.ts deleted file mode 100644 index 6237803d9..000000000 --- a/packages/coding-agent/src/tools/mcp-read-resource.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Built-in tool for reading MCP resources by URI. - * - * Global tool (not per-server) — resolves the server from the URI - * by checking which connected server exposes a matching resource. - */ -import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { type Static, Type } from "@sinclair/typebox"; -import type { MCPManager } from "../mcp/manager"; -import type { OutputMeta } from "./output-meta"; -import { toolResult } from "./tool-result"; - -const mcpReadResourceSchema = Type.Object({ - uri: Type.String({ - description: "Resource URI to read (e.g. test://notes, ibkr://portfolio/positions)", - }), -}); - -type McpReadResourceParams = Static; - -export interface McpReadResourceDetails { - serverName?: string; - uri: string; - isError?: boolean; - meta?: OutputMeta; -} - -function escapeRegex(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function getUriTemplateMatchScore( - uri: string, - uriTemplate: string, -): { literalChars: number; expressionCount: number } | undefined { - const expressionPattern = /\{[^}]+\}/g; - const literalSegments = uriTemplate.split(expressionPattern); - const expressionCount = (uriTemplate.match(expressionPattern) ?? []).length; - const pattern = literalSegments.map(escapeRegex).join("(.*?)"); - const regex = new RegExp(`^${pattern}$`); - if (!regex.test(uri)) return undefined; - const literalChars = literalSegments.reduce((total, segment) => total + segment.length, 0); - return { literalChars, expressionCount }; -} -export class McpReadResourceTool implements AgentTool { - readonly name = "read_resource"; - readonly label = "MCP Read Resource"; - readonly description = - "Read a resource from a connected MCP server by URI. " + - "Use this to inspect MCP resource content when notified of updates or when you need server-provided context."; - readonly parameters = mcpReadResourceSchema; - - #getMcpManager: () => MCPManager | undefined; - - constructor(getMcpManager: () => MCPManager | undefined) { - this.#getMcpManager = getMcpManager; - } - - async execute( - _toolCallId: string, - params: McpReadResourceParams, - signal?: AbortSignal, - _onUpdate?: AgentToolUpdateCallback, - ): Promise> { - const { uri } = params; - const mcpManager = this.#getMcpManager(); - - if (!mcpManager) { - return toolResult({ uri, isError: true }) - .text("No MCP manager available. MCP servers may not be configured.") - .done(); - } - - // Find which server has this resource - const servers = mcpManager.getConnectedServers(); - let targetServer: string | undefined; - - for (const name of servers) { - const serverResources = mcpManager.getServerResources(name); - if (serverResources?.resources.some(r => r.uri === uri)) { - targetServer = name; - break; - } - } - - // If no exact match, try full URI-template matching and pick the most specific match - if (!targetServer) { - let bestTemplateMatch: - | { - serverName: string; - literalChars: number; - expressionCount: number; - serverIndex: number; - templateIndex: number; - } - | undefined; - - for (const [serverIndex, name] of servers.entries()) { - const serverResources = mcpManager.getServerResources(name); - if (!serverResources) continue; - - for (const [templateIndex, template] of serverResources.templates.entries()) { - const match = getUriTemplateMatchScore(uri, template.uriTemplate); - if (!match) continue; - - const isBetterMatch = - !bestTemplateMatch || - match.literalChars > bestTemplateMatch.literalChars || - (match.literalChars === bestTemplateMatch.literalChars && - (match.expressionCount < bestTemplateMatch.expressionCount || - (match.expressionCount === bestTemplateMatch.expressionCount && - (serverIndex < bestTemplateMatch.serverIndex || - (serverIndex === bestTemplateMatch.serverIndex && - templateIndex < bestTemplateMatch.templateIndex))))); - - if (isBetterMatch) { - bestTemplateMatch = { - serverName: name, - literalChars: match.literalChars, - expressionCount: match.expressionCount, - serverIndex, - templateIndex, - }; - } - } - } - - targetServer = bestTemplateMatch?.serverName; - } - if (!targetServer) { - const available = servers - .flatMap(name => { - const serverResources = mcpManager.getServerResources(name); - return (serverResources?.resources ?? []).map(r => ` ${r.uri} (${name})`); - }) - .join("\n"); - return toolResult({ uri, isError: true }) - .text(`No MCP server has resource "${uri}".\n\nAvailable resources:\n${available || " (none)"}`) - .done(); - } - - try { - const result = await mcpManager.readServerResource(targetServer, uri, { signal }); - if (!result) { - return toolResult({ - serverName: targetServer, - uri, - isError: true, - }) - .text(`Server "${targetServer}" returned no content for "${uri}".`) - .done(); - } - - const textParts: string[] = []; - for (const item of result.contents) { - if (item.text) { - textParts.push(item.text); - } else if (item.blob) { - textParts.push(`[Binary content: ${item.mimeType ?? "unknown"}, base64 length ${item.blob.length}]`); - } - } - - return toolResult({ - serverName: targetServer, - uri, - }) - .text(textParts.join("\n---\n") || "(empty resource)") - .done(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return toolResult({ - serverName: targetServer, - uri, - isError: true, - }) - .text(`MCP resource read error: ${message}`) - .done(); - } - } -} diff --git a/packages/coding-agent/src/tools/path-utils.ts b/packages/coding-agent/src/tools/path-utils.ts index eff357eb7..69dde311d 100644 --- a/packages/coding-agent/src/tools/path-utils.ts +++ b/packages/coding-agent/src/tools/path-utils.ts @@ -56,7 +56,8 @@ function normalizeAtPrefix(filePath: string): string { withoutAt.startsWith("artifact://") || withoutAt.startsWith("skill://") || withoutAt.startsWith("rule://") || - withoutAt.startsWith("local://") + withoutAt.startsWith("local://") || + withoutAt.startsWith("mcp://") ) { return withoutAt; } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index e3c381b76..d9e74d9c9 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -582,7 +582,7 @@ export class ReadTool implements AgentTool { const displayMode = resolveFileDisplayMode(this.session); - // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://) + // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://) const internalRouter = this.session.internalRouter; if (internalRouter?.canHandle(readPath)) { return this.#handleInternalUrl(readPath, offset, limit); @@ -841,7 +841,7 @@ export class ReadTool implements AgentTool { } /** - * Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://). + * Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://). * Supports pagination via offset/limit but rejects them when query extraction is used. */ async #handleInternalUrl(url: string, offset?: number, limit?: number): Promise> { @@ -859,13 +859,9 @@ export class ReadTool implements AgentTool { const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); const hasPathExtraction = parsed.pathname && parsed.pathname !== "/" && parsed.pathname !== ""; const queryParam = parsed.searchParams.get("q"); - const hasQueryExtraction = queryParam !== null && queryParam !== ""; + const hasQueryExtraction = scheme === "agent" && queryParam !== null && queryParam !== ""; const hasExtraction = scheme === "agent" && (hasPathExtraction || hasQueryExtraction); - if (scheme !== "agent" && hasQueryExtraction) { - throw new ToolError("Only agent:// URLs support ?q= query extraction"); - } - // Reject offset/limit with query extraction if (hasExtraction && (offset !== undefined || limit !== undefined)) { throw new ToolError("Cannot combine query extraction with offset/limit"); diff --git a/packages/coding-agent/test/mcp-read-resource.test.ts b/packages/coding-agent/test/internal-urls/mcp-protocol.test.ts similarity index 60% rename from packages/coding-agent/test/mcp-read-resource.test.ts rename to packages/coding-agent/test/internal-urls/mcp-protocol.test.ts index 24f058c50..ddf9fde1c 100644 --- a/packages/coding-agent/test/mcp-read-resource.test.ts +++ b/packages/coding-agent/test/internal-urls/mcp-protocol.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import type { MCPManager } from "../src/mcp/manager"; -import type { MCPResource, MCPResourceReadResult, MCPResourceTemplate } from "../src/mcp/types"; -import { McpReadResourceTool } from "../src/tools/mcp-read-resource"; +import { InternalUrlRouter, McpProtocolHandler } from "../../src/internal-urls"; +import type { MCPManager } from "../../src/mcp/manager"; +import type { MCPResource, MCPResourceReadResult, MCPResourceTemplate } from "../../src/mcp/types"; function createMockManager(opts: { servers?: string[]; @@ -19,15 +19,26 @@ function createMockManager(opts: { } as unknown as MCPManager; } -describe("McpReadResourceTool", () => { - it("returns error when no MCP manager is available", async () => { - const tool = new McpReadResourceTool(() => undefined); - const result = await tool.execute("call-1", { uri: "test://resource" }); +function createRouter(manager?: MCPManager): InternalUrlRouter { + const router = new InternalUrlRouter(); + router.register( + new McpProtocolHandler({ + getMcpManager: () => manager, + }), + ); + return router; +} - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toContain("No MCP manager"); - expect(result.details?.isError).toBe(true); - expect(result.details?.uri).toBe("test://resource"); +describe("McpProtocolHandler", () => { + it("returns error when no MCP manager is available", async () => { + const router = createRouter(); + await expect(router.resolve("mcp://test://resource")).rejects.toThrow("No MCP manager"); + }); + + it("requires resource URI in mcp URL", async () => { + const manager = createMockManager({ servers: ["server-a"] }); + const router = createRouter(manager); + await expect(router.resolve("mcp://")).rejects.toThrow("mcp:// URL requires a resource URI"); }); it("returns error listing available resources when no server matches", async () => { @@ -37,14 +48,11 @@ describe("McpReadResourceTool", () => { templates: [], }); const manager = createMockManager({ servers: ["server-a"], resources }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://missing" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toContain("No MCP server has resource"); - expect(text).toContain("file://known"); - expect(text).toContain("server-a"); - expect(result.details?.isError).toBe(true); + await expect(router.resolve("mcp://test://missing")).rejects.toThrow("No MCP server has resource"); + await expect(router.resolve("mcp://test://missing")).rejects.toThrow("file://known"); + await expect(router.resolve("mcp://test://missing")).rejects.toThrow("server-a"); }); it("reads resource by exact URI match", async () => { @@ -58,13 +66,28 @@ describe("McpReadResourceTool", () => { resources, readResult: { contents: [{ uri: "test://doc", text: "hello world" }] }, }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://doc" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toBe("hello world"); - expect(result.details?.serverName).toBe("my-server"); - expect(result.details?.isError).toBeUndefined(); + const resource = await router.resolve("mcp://test://doc"); + expect(resource.content).toBe("hello world"); + expect(resource.notes).toEqual(["MCP server: my-server"]); + }); + + it("preserves query parameters in MCP resource URI", async () => { + const resources = new Map(); + resources.set("query-server", { + resources: [{ uri: "test://doc?q=1", name: "doc" }], + templates: [], + }); + const manager = createMockManager({ + servers: ["query-server"], + resources, + readResult: { contents: [{ uri: "test://doc?q=1", text: "query resource" }] }, + }); + const router = createRouter(manager); + + const resource = await router.resolve("mcp://test://doc?q=1"); + expect(resource.content).toBe("query resource"); }); it("matches URI templates when no exact URI exists", async () => { @@ -78,12 +101,10 @@ describe("McpReadResourceTool", () => { resources, readResult: { contents: [{ uri: "test://docs/foo/raw", text: "from template" }] }, }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://docs/foo/raw" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toBe("from template"); - expect(result.details?.serverName).toBe("tmpl-server"); + const resource = await router.resolve("mcp://test://docs/foo/raw"); + expect(resource.content).toBe("from template"); }); it("matches templates when an expression expands to an empty string", async () => { @@ -97,12 +118,10 @@ describe("McpReadResourceTool", () => { resources, readResult: { contents: [{ uri: "test://docs", text: "empty expansion" }] }, }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://docs" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toBe("empty expansion"); - expect(result.details?.serverName).toBe("query-template-server"); + const resource = await router.resolve("mcp://test://docs"); + expect(resource.content).toBe("empty expansion"); }); it("picks the most specific matching template across overlapping schemes", async () => { @@ -120,10 +139,10 @@ describe("McpReadResourceTool", () => { resources, readResult: { contents: [{ uri: "test://foo/123", text: "from specific" }] }, }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://foo/123" }); + const router = createRouter(manager); - expect(result.details?.serverName).toBe("specific-server"); + const resource = await router.resolve("mcp://test://foo/123"); + expect(resource.notes).toEqual(["MCP server: specific-server"]); }); it("uses connected server order when matching templates are equally specific", async () => { @@ -141,10 +160,10 @@ describe("McpReadResourceTool", () => { resources, readResult: { contents: [{ uri: "test://foo", text: "from first" }] }, }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://foo" }); + const router = createRouter(manager); - expect(result.details?.serverName).toBe("first"); + const resource = await router.resolve("mcp://test://foo"); + expect(resource.notes).toEqual(["MCP server: first"]); }); it("does not match template with different scheme prefix", async () => { @@ -154,12 +173,9 @@ describe("McpReadResourceTool", () => { templates: [{ uriTemplate: "testing://{id}", name: "testing-template" }], }); const manager = createMockManager({ servers: ["tmpl-server"], resources }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://foo" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toContain("No MCP server has resource"); - expect(result.details?.isError).toBe(true); + await expect(router.resolve("mcp://test://foo")).rejects.toThrow("No MCP server has resource"); }); it("returns error when readServerResource returns undefined", async () => { @@ -173,13 +189,10 @@ describe("McpReadResourceTool", () => { resources, readResult: undefined, }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://empty" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toContain("returned no content"); - expect(text).toContain("null-server"); - expect(result.details?.isError).toBe(true); + await expect(router.resolve("mcp://test://empty")).rejects.toThrow("returned no content"); + await expect(router.resolve("mcp://test://empty")).rejects.toThrow("null-server"); }); it("formats binary content with mime type and base64 length", async () => { @@ -196,13 +209,12 @@ describe("McpReadResourceTool", () => { contents: [{ uri: "test://image", mimeType: "image/png", blob: blobData }], }, }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://image" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toContain("[Binary content:"); - expect(text).toContain("image/png"); - expect(text).toContain(`base64 length ${blobData.length}`); + const resource = await router.resolve("mcp://test://image"); + expect(resource.content).toContain("[Binary content:"); + expect(resource.content).toContain("image/png"); + expect(resource.content).toContain(`base64 length ${blobData.length}`); }); it("joins mixed text and binary content with --- separator", async () => { @@ -221,13 +233,12 @@ describe("McpReadResourceTool", () => { ], }, }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://mixed" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toContain("part one"); - expect(text).toContain("\n---\n"); - expect(text).toContain("[Binary content:"); + const resource = await router.resolve("mcp://test://mixed"); + expect(resource.content).toContain("part one"); + expect(resource.content).toContain("\n---\n"); + expect(resource.content).toContain("[Binary content:"); }); it("returns (empty resource) when content items have neither text nor blob", async () => { @@ -243,12 +254,10 @@ describe("McpReadResourceTool", () => { contents: [{ uri: "test://blank" }], }, }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://blank" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toBe("(empty resource)"); - expect(result.details?.isError).toBeUndefined(); + const resource = await router.resolve("mcp://test://blank"); + expect(resource.content).toBe("(empty resource)"); }); it("returns error with message when readServerResource throws", async () => { @@ -262,14 +271,10 @@ describe("McpReadResourceTool", () => { resources, readError: new Error("connection refused"), }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://fail" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toContain("MCP resource read error:"); - expect(text).toContain("connection refused"); - expect(result.details?.isError).toBe(true); - expect(result.details?.serverName).toBe("err-server"); + await expect(router.resolve("mcp://test://fail")).rejects.toThrow("MCP resource read error:"); + await expect(router.resolve("mcp://test://fail")).rejects.toThrow("connection refused"); }); it("picks the first server with a matching resource", async () => { @@ -287,20 +292,17 @@ describe("McpReadResourceTool", () => { resources, readResult: { contents: [{ uri: "test://shared", text: "from first" }] }, }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://shared" }); + const router = createRouter(manager); - expect(result.details?.serverName).toBe("first"); + const resource = await router.resolve("mcp://test://shared"); + expect(resource.notes).toEqual(["MCP server: first"]); }); it("shows (none) when no servers have any resources", async () => { const manager = createMockManager({ servers: ["lonely-server"] }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://anything" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toContain("(none)"); - expect(result.details?.isError).toBe(true); + await expect(router.resolve("mcp://test://anything")).rejects.toThrow("(none)"); }); it("uses unknown for binary content without mimeType", async () => { @@ -316,10 +318,9 @@ describe("McpReadResourceTool", () => { contents: [{ uri: "test://bin", blob: "data" }], }, }); - const tool = new McpReadResourceTool(() => manager); - const result = await tool.execute("call-1", { uri: "test://bin" }); + const router = createRouter(manager); - const text = (result.content[0] as { type: string; text: string }).text; - expect(text).toContain("[Binary content: unknown,"); + const resource = await router.resolve("mcp://test://bin"); + expect(resource.content).toContain("[Binary content: unknown,"); }); });