41ce810cff
- Native (non-mcp://) resource URIs now pass through byte-for-byte via rawHref; slash elision applies only to the legacy mcp:// wrapper, so catalog://root/ style URIs match exact-equality server lookups. - resources/templates/list failure no longer discards a successful resources/list (Promise.allSettled; templates retried later). - Opaque RFC 3986 URIs (urn:doc, custom:item) are recognized by both the router and read-cli discovery gates, with drive-path and read-selector false positives guarded. - Review follow-up for PR #6790.
166 lines
5.6 KiB
TypeScript
166 lines
5.6 KiB
TypeScript
import { MCPManager } from "../mcp/manager";
|
|
import type { MCPResourceReadResult } from "../mcp/types";
|
|
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
|
|
|
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 scheme = url.protocol.replace(/:$/, "").toLowerCase();
|
|
if (scheme !== "mcp") {
|
|
// Server-advertised native URI (hierarchical or opaque). Preserve the
|
|
// input byte-for-byte: `resolveTargetServer` matches by exact string
|
|
// equality, so e.g. `catalog://root/` must keep its trailing slash.
|
|
return url.rawHref ?? url.href;
|
|
}
|
|
// Legacy `mcp://<resource-uri>` wrapper: reconstruct the wrapped URI and
|
|
// elide a bare trailing `/` that URL parsing adds to host-only forms.
|
|
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 resources.
|
|
*
|
|
* URL forms:
|
|
* - mcp://<resource-uri> (e.g. mcp://test://notes, mcp://ibkr://portfolio/positions)
|
|
* - A resource's native URI when its scheme has no OMP handler (e.g. ags://capabilities/current-host)
|
|
*/
|
|
export class McpProtocolHandler implements ProtocolHandler {
|
|
readonly scheme = "mcp";
|
|
readonly immutable = true;
|
|
|
|
async resolve(url: InternalUrl): Promise<InternalResource> {
|
|
const mcpManager = MCPManager.instance();
|
|
if (!mcpManager) {
|
|
throw new Error("No MCP manager available. MCP servers may not be configured.");
|
|
}
|
|
|
|
const uri = extractResourceUri(url);
|
|
let targetServer = resolveTargetServer(mcpManager, uri);
|
|
if (!targetServer) {
|
|
await Promise.allSettled(mcpManager.getConnectedServers().map(name => mcpManager.ensureServerResources(name)));
|
|
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 !== undefined && item.text !== null) {
|
|
textParts.push(item.text);
|
|
} else if (item.blob) {
|
|
textParts.push(`[Binary content: ${item.mimeType ?? "unknown"}, base64 length ${item.blob.length}]`);
|
|
}
|
|
}
|
|
|
|
const content = textParts.length > 0 ? textParts.join("\n---\n") : "(empty resource)";
|
|
return {
|
|
url: url.href,
|
|
content,
|
|
contentType: "text/plain",
|
|
size: Buffer.byteLength(content, "utf-8"),
|
|
notes: [`MCP server: ${targetServer}`],
|
|
};
|
|
}
|
|
}
|