feat(internal-urls): added mcp:// protocol for MCP resource access via read tool

- Added mcp:// internal URL protocol for reading MCP server resources via the read tool.
- Removed read_resource tool; MCP resource reading now integrated into read tool with mcp:// URLs.
- Implemented McpProtocolHandler with URI template matching for resolving MCP server resources.
- Updated MCP resource notifications to recommend read(path="mcp://<uri>") syntax.
This commit is contained in:
can1357
2026-03-03 04:45:26 +01:00
parent c22ba6b49c
commit 9935b9b351
11 changed files with 267 additions and 287 deletions
+7 -1
View File
@@ -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://<uri>")` 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 `<system-notification>` 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
@@ -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";
@@ -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://<resource-uri>");
}
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://<resource-uri> (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<InternalResource> {
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}`],
};
}
}
@@ -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";
@@ -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 ://) */
+4 -2
View File
@@ -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),
);
+1 -4
View File
@@ -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<string, ToolFactory> = {
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<string, ToolFactory> = {
@@ -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<typeof mcpReadResourceSchema>;
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<typeof mcpReadResourceSchema, McpReadResourceDetails> {
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<McpReadResourceDetails>,
): Promise<AgentToolResult<McpReadResourceDetails>> {
const { uri } = params;
const mcpManager = this.#getMcpManager();
if (!mcpManager) {
return toolResult<McpReadResourceDetails>({ 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<McpReadResourceDetails>({ 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<McpReadResourceDetails>({
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<McpReadResourceDetails>({
serverName: targetServer,
uri,
})
.text(textParts.join("\n---\n") || "(empty resource)")
.done();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return toolResult<McpReadResourceDetails>({
serverName: targetServer,
uri,
isError: true,
})
.text(`MCP resource read error: ${message}`)
.done();
}
}
}
@@ -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;
}
+3 -7
View File
@@ -582,7 +582,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
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<typeof readSchema, ReadToolDetails> {
}
/**
* 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<AgentToolResult<ReadToolDetails>> {
@@ -859,13 +859,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
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");
@@ -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<string, { resources: MCPResource[]; templates: MCPResourceTemplate[] }>();
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,");
});
});