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:
@@ -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 ://) */
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+88
-87
@@ -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,");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user