diff --git a/src/lib/server/mcp/client.spec.ts b/src/lib/server/mcp/client.spec.ts new file mode 100644 index 00000000000..6aa5f1f5767 --- /dev/null +++ b/src/lib/server/mcp/client.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from "vitest"; + +const constructed = vi.hoisted(() => [] as Array<{ info: unknown; options: unknown }>); + +vi.mock("@modelcontextprotocol/sdk/client", () => ({ + Client: class { + constructor(info: unknown, options: unknown) { + constructed.push({ info, options }); + } + }, +})); + +const { createMcpClient, MCP_CLIENT_CAPABILITIES } = await import("./client"); + +function infoAndOptionsOf(index: number) { + return constructed[index] as { + info: { name: string; version: string }; + options: { capabilities: Record }; + }; +} + +describe("createMcpClient", () => { + it("declares capabilities on every client it builds", () => { + constructed.length = 0; + + createMcpClient(); + createMcpClient("health"); + + // Same declaration everywhere, or which client the server met decides the outcome. + expect(infoAndOptionsOf(0).options.capabilities).toBe(MCP_CLIENT_CAPABILITIES); + expect(infoAndOptionsOf(1).options.capabilities).toBe(MCP_CLIENT_CAPABILITIES); + }); + + it("keeps the session and health identities distinct", () => { + constructed.length = 0; + + createMcpClient("session"); + createMcpClient("health"); + + expect(infoAndOptionsOf(0).info).toEqual({ name: "chat-ui-mcp", version: "0.1.0" }); + expect(infoAndOptionsOf(1).info).toEqual({ name: "chat-ui-health-check", version: "1.0.0" }); + }); + + it("builds a session client by default", () => { + constructed.length = 0; + + createMcpClient(); + + expect(infoAndOptionsOf(0).info).toEqual({ name: "chat-ui-mcp", version: "0.1.0" }); + }); + + it("declares nothing it cannot yet answer", () => { + expect(MCP_CLIENT_CAPABILITIES).toEqual({}); + }); +}); diff --git a/src/lib/server/mcp/client.ts b/src/lib/server/mcp/client.ts new file mode 100644 index 00000000000..7f1ef9b9eba --- /dev/null +++ b/src/lib/server/mcp/client.ts @@ -0,0 +1,26 @@ +import { Client } from "@modelcontextprotocol/sdk/client"; +import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Empty on purpose: a capability declared without a handler makes servers issue a + * request the SDK can only answer with method-not-found. Add one only in the change + * that implements its handler. + */ +export const MCP_CLIENT_CAPABILITIES: ClientCapabilities = {}; + +// Servers can tell these apart, so a health probe stays distinguishable from a session. +const CLIENT_INFO = { + session: { name: "chat-ui-mcp", version: "0.1.0" }, + health: { name: "chat-ui-health-check", version: "1.0.0" }, +} as const; + +export type McpClientKind = keyof typeof CLIENT_INFO; + +/** + * The one place an MCP client is constructed. Only the pooled client is alive while a + * tool runs, so a capability added to the listing client in `tools.ts` instead of here + * would look declared and never fire. + */ +export function createMcpClient(kind: McpClientKind = "session"): Client { + return new Client(CLIENT_INFO[kind], { capabilities: MCP_CLIENT_CAPABILITIES }); +} diff --git a/src/lib/server/mcp/clientPool.ts b/src/lib/server/mcp/clientPool.ts index 5caebba43bb..b179a1c33a7 100644 --- a/src/lib/server/mcp/clientPool.ts +++ b/src/lib/server/mcp/clientPool.ts @@ -1,4 +1,5 @@ -import { Client } from "@modelcontextprotocol/sdk/client"; +import type { Client } from "@modelcontextprotocol/sdk/client"; +import { createMcpClient } from "./client"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import type { McpServerConfig } from "./httpClient"; @@ -73,7 +74,7 @@ export async function getClient(server: McpServerConfig, signal?: AbortSignal): } let firstError: unknown; - const client = new Client({ name: "chat-ui-mcp", version: "0.1.0" }); + const client = createMcpClient(); const url = new URL(server.url); // Pooled clients outlive the request that created them, so never bind the per-request // abort signal to the transport. Per-call cancellation goes through RequestOptions instead. diff --git a/src/lib/server/mcp/tools.ts b/src/lib/server/mcp/tools.ts index 58d8ddfccad..832a976e78c 100644 --- a/src/lib/server/mcp/tools.ts +++ b/src/lib/server/mcp/tools.ts @@ -1,4 +1,4 @@ -import { Client } from "@modelcontextprotocol/sdk/client"; +import { createMcpClient } from "./client"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import type { McpServerConfig } from "./httpClient"; @@ -152,7 +152,7 @@ async function listServerTools( opts: { signal?: AbortSignal } = {} ): Promise { const url = new URL(server.url); - const client = new Client({ name: "chat-ui-mcp", version: "0.1.0" }); + const client = createMcpClient(); try { try { const transport = new StreamableHTTPClientTransport(url, { diff --git a/src/routes/api/mcp/health/+server.ts b/src/routes/api/mcp/health/+server.ts index 2403d3c6541..84d4aa2ef4c 100644 --- a/src/routes/api/mcp/health/+server.ts +++ b/src/routes/api/mcp/health/+server.ts @@ -1,4 +1,5 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { createMcpClient } from "$lib/server/mcp/client"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import type { KeyValuePair } from "$lib/types/Tool"; @@ -106,10 +107,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { // Try Streamable HTTP transport first try { logger.info({}, `[MCP Health] Trying HTTP transport for ${url}`); - client = new Client({ - name: "chat-ui-health-check", - version: "1.0.0", - }); + client = createMcpClient("health"); const transport = new StreamableHTTPClientTransport(baseUrl, { requestInit, @@ -172,10 +170,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { // Try SSE transport try { logger.info({}, `[MCP Health] Trying SSE transport for ${url}`); - client = new Client({ - name: "chat-ui-health-check", - version: "1.0.0", - }); + client = createMcpClient("health"); const sseTransport = new SSEClientTransport(baseUrl, { requestInit,