Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/lib/server/mcp/client.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
};
}

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({});
});
});
26 changes: 26 additions & 0 deletions src/lib/server/mcp/client.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
5 changes: 3 additions & 2 deletions src/lib/server/mcp/clientPool.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/lib/server/mcp/tools.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -152,7 +152,7 @@ async function listServerTools(
opts: { signal?: AbortSignal } = {}
): Promise<ListedTool[]> {
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, {
Expand Down
13 changes: 4 additions & 9 deletions src/routes/api/mcp/health/+server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading