diff --git a/packages/mcp-client/cli.ts b/packages/mcp-client/cli.ts index dbbb346710..fd11e353f7 100644 --- a/packages/mcp-client/cli.ts +++ b/packages/mcp-client/cli.ts @@ -4,21 +4,26 @@ import { stdin, stdout } from "node:process"; import { join } from "node:path"; import { homedir } from "node:os"; import type { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"; -import type { InferenceProvider } from "@huggingface/inference"; -import { ANSI } from "./src/utils"; +import type { ServerConfig } from "./src/types"; +import type { InferenceProviderOrPolicy } from "@huggingface/inference"; +import { ANSI, urlToServerConfig } from "./src/utils"; import { Agent } from "./src"; import { version as packageVersion } from "./package.json"; +import { parseArgs } from "node:util"; const MODEL_ID = process.env.MODEL_ID ?? "Qwen/Qwen2.5-72B-Instruct"; -const PROVIDER = (process.env.PROVIDER as InferenceProvider) ?? "nebius"; +const PROVIDER = (process.env.PROVIDER as InferenceProviderOrPolicy) ?? "nebius"; const ENDPOINT_URL = process.env.ENDPOINT_URL ?? process.env.BASE_URL; -const MCP_EXAMPLER_LOCAL_FOLDER = process.platform === "darwin" ? join(homedir(), "Desktop") : homedir(); -const SERVERS: StdioServerParameters[] = [ +const SERVERS: (ServerConfig | StdioServerParameters)[] = [ { // Filesystem "official" mcp-server with access to your Desktop command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", MCP_EXAMPLER_LOCAL_FOLDER], + args: [ + "-y", + "@modelcontextprotocol/server-filesystem", + process.platform === "darwin" ? join(homedir(), "Desktop") : homedir(), + ], }, { // Playwright MCP @@ -27,17 +32,28 @@ const SERVERS: StdioServerParameters[] = [ }, ]; -if (process.env.EXPERIMENTAL_HF_MCP_SERVER) { - SERVERS.push({ - // Early version of a HF-MCP server - // you can download it from gist.github.com/julien-c/0500ba922e1b38f2dc30447fb81f7dc6 - // and replace the local path below - command: "node", - args: ["--disable-warning=ExperimentalWarning", join(homedir(), "Desktop/hf-mcp/index.ts")], - env: { - HF_TOKEN: process.env.HF_TOKEN ?? "", +// Handle --url parameters from command line: each URL will be parsed into a ServerConfig object +const { + values: { url: urls }, +} = parseArgs({ + options: { + url: { + type: "string", + multiple: true, }, - }); + }, +}); +if (urls?.length) { + while (SERVERS.length) { + SERVERS.pop(); + } + for (const url of urls) { + try { + SERVERS.push(urlToServerConfig(url)); + } catch (error) { + console.error(`Error adding server from URL "${url}": ${error.message}`); + } + } } async function main() { diff --git a/packages/mcp-client/package.json b/packages/mcp-client/package.json index 5d5d5f7066..7cd4915deb 100644 --- a/packages/mcp-client/package.json +++ b/packages/mcp-client/package.json @@ -55,6 +55,6 @@ "dependencies": { "@huggingface/inference": "workspace:^", "@huggingface/tasks": "workspace:^", - "@modelcontextprotocol/sdk": "^1.9.0" + "@modelcontextprotocol/sdk": "^1.11.2" } } diff --git a/packages/mcp-client/pnpm-lock.yaml b/packages/mcp-client/pnpm-lock.yaml index 1d86bc378c..3ac7dd666a 100644 --- a/packages/mcp-client/pnpm-lock.yaml +++ b/packages/mcp-client/pnpm-lock.yaml @@ -15,13 +15,13 @@ importers: specifier: workspace:^ version: link:../tasks '@modelcontextprotocol/sdk': - specifier: ^1.9.0 - version: 1.10.1 + specifier: ^1.11.2 + version: 1.11.2 packages: - '@modelcontextprotocol/sdk@1.10.1': - resolution: {integrity: sha512-xNYdFdkJqEfIaTVP1gPKoEvluACHZsHZegIoICX8DM1o6Qf3G5u2BQJHmgd0n4YgRPqqK/u1ujQvrgAxxSJT9w==} + '@modelcontextprotocol/sdk@1.11.2': + resolution: {integrity: sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==} engines: {node: '>=18'} accepts@2.0.0: @@ -342,7 +342,7 @@ packages: snapshots: - '@modelcontextprotocol/sdk@1.10.1': + '@modelcontextprotocol/sdk@1.11.2': dependencies: content-type: 1.0.5 cors: 2.8.5 diff --git a/packages/mcp-client/src/Agent.ts b/packages/mcp-client/src/Agent.ts index 7e7811ee82..e1a3a84d2e 100644 --- a/packages/mcp-client/src/Agent.ts +++ b/packages/mcp-client/src/Agent.ts @@ -1,10 +1,11 @@ -import type { InferenceProvider } from "@huggingface/inference"; +import type { InferenceProviderOrPolicy } from "@huggingface/inference"; import type { ChatCompletionInputMessageTool } from "./McpClient"; import { McpClient } from "./McpClient"; import type { ChatCompletionInputMessage, ChatCompletionStreamOutput } from "@huggingface/tasks"; import type { ChatCompletionInputTool } from "@huggingface/tasks/src/tasks/chat-completion/inference"; import type { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio"; import { debug } from "./utils"; +import type { ServerConfig } from "./types"; const DEFAULT_SYSTEM_PROMPT = ` You are an agent - please keep going until the user’s query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved, or if you need more info from the user to solve the problem. @@ -44,7 +45,7 @@ const askQuestionTool: ChatCompletionInputTool = { const exitLoopTools = [taskCompletionTool, askQuestionTool]; export class Agent extends McpClient { - private readonly servers: StdioServerParameters[]; + private readonly servers: (ServerConfig | StdioServerParameters)[]; protected messages: ChatCompletionInputMessage[]; constructor({ @@ -56,7 +57,7 @@ export class Agent extends McpClient { prompt, }: ( | { - provider: InferenceProvider; + provider: InferenceProviderOrPolicy; endpointUrl?: undefined; } | { @@ -66,7 +67,7 @@ export class Agent extends McpClient { ) & { model: string; apiKey: string; - servers: StdioServerParameters[]; + servers: (ServerConfig | StdioServerParameters)[]; prompt?: string; }) { super(provider ? { provider, endpointUrl, model, apiKey } : { provider, endpointUrl, model, apiKey }); diff --git a/packages/mcp-client/src/McpClient.ts b/packages/mcp-client/src/McpClient.ts index 1963960d11..0b8dbe2815 100644 --- a/packages/mcp-client/src/McpClient.ts +++ b/packages/mcp-client/src/McpClient.ts @@ -11,6 +11,11 @@ import type { } from "@huggingface/tasks/src/tasks/chat-completion/inference"; import { version as packageVersion } from "../package.json"; import { debug } from "./utils"; +import type { ServerConfig } from "./types"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { ResultFormatter } from "./ResultFormatter.js"; type ToolName = string; @@ -52,15 +57,37 @@ export class McpClient { this.model = model; } - async addMcpServers(servers: StdioServerParameters[]): Promise { + async addMcpServers(servers: (ServerConfig | StdioServerParameters)[]): Promise { await Promise.all(servers.map((s) => this.addMcpServer(s))); } - async addMcpServer(server: StdioServerParameters): Promise { - const transport = new StdioClientTransport({ - ...server, - env: { ...server.env, PATH: process.env.PATH ?? "" }, - }); + async addMcpServer(server: ServerConfig | StdioServerParameters): Promise { + let transport: Transport; + const asUrl = (url: string | URL): URL => { + return typeof url === "string" ? new URL(url) : url; + }; + + if (!("type" in server)) { + transport = new StdioClientTransport({ + ...server, + env: { ...server.env, PATH: process.env.PATH ?? "" }, + }); + } else { + switch (server.type) { + case "stdio": + transport = new StdioClientTransport({ + ...server.config, + env: { ...server.config.env, PATH: process.env.PATH ?? "" }, + }); + break; + case "sse": + transport = new SSEClientTransport(asUrl(server.config.url), server.config.options); + break; + case "http": + transport = new StreamableHTTPClientTransport(asUrl(server.config.url), server.config.options); + break; + } + } const mcp = new Client({ name: "@huggingface/mcp-client", version: packageVersion }); await mcp.connect(transport); @@ -170,7 +197,7 @@ export class McpClient { const client = this.clients.get(toolName); if (client) { const result = await client.callTool({ name: toolName, arguments: toolArgs, signal: opts.abortSignal }); - toolMessage.content = (result.content as Array<{ text: string }>)[0].text; + toolMessage.content = ResultFormatter.format(result); } else { toolMessage.content = `Error: No session found for tool: ${toolName}`; } diff --git a/packages/mcp-client/src/ResultFormatter.ts b/packages/mcp-client/src/ResultFormatter.ts new file mode 100644 index 0000000000..04c93e9e7f --- /dev/null +++ b/packages/mcp-client/src/ResultFormatter.ts @@ -0,0 +1,86 @@ +import type { + TextResourceContents, + BlobResourceContents, + CompatibilityCallToolResult, +} from "@modelcontextprotocol/sdk/types"; + +/** + * A utility class for formatting CallToolResult contents into human-readable text. + * Processes different content types, extracts text, and summarizes binary data. + */ +export class ResultFormatter { + /** + * Formats a CallToolResult's contents into a single string. + * - Text content is included directly + * - Binary content (images, audio, blobs) is summarized + * + * @param result The CallToolResult to format + * @returns A human-readable string representation of the result contents + */ + static format(result: CompatibilityCallToolResult): string { + if (!result.content || !Array.isArray(result.content) || result.content.length === 0) { + return "[No content]"; + } + + const formattedParts: string[] = []; + + for (const item of result.content) { + switch (item.type) { + case "text": + // Extract text content directly + formattedParts.push(item.text); + break; + + case "image": { + // Summarize image content + const imageSize = this.getBase64Size(item.data); + formattedParts.push( + `[Binary Content: Image ${item.mimeType}, ${imageSize} bytes]\nThe task is complete and the content accessible to the User` + ); + break; + } + + case "audio": { + // Summarize audio content + const audioSize = this.getBase64Size(item.data); + formattedParts.push( + `[Binary Content: Audio ${item.mimeType}, ${audioSize} bytes]\nThe task is complete and the content accessible to the User` + ); + break; + } + + case "resource": + // Handle embedded resources - explicitly type the resource + if ("text" in item.resource) { + // It's a text resource with a text property + const textResource = item.resource as TextResourceContents; + formattedParts.push(textResource.text); + } else if ("blob" in item.resource) { + // It's a binary resource with a blob property + const blobResource = item.resource as BlobResourceContents; + const blobSize = this.getBase64Size(blobResource.blob); + const uri = blobResource.uri ? ` (${blobResource.uri})` : ""; + const mimeType = blobResource.mimeType ? blobResource.mimeType : "unknown type"; + formattedParts.push( + `[Binary Content${uri}: ${mimeType}, ${blobSize} bytes]\nThe task is complete and the content accessible to the User` + ); + } + break; + } + } + + return formattedParts.join("\n"); + } + + /** + * Calculates the approximate size in bytes of base64-encoded data + */ + private static getBase64Size(base64: string): number { + // Remove base64 header if present (e.g., data:image/png;base64,) + const cleanBase64 = base64.includes(",") ? base64.split(",")[1] : base64; + + // Calculate size: Base64 encodes 3 bytes into 4 characters + const padding = cleanBase64.endsWith("==") ? 2 : cleanBase64.endsWith("=") ? 1 : 0; + return Math.floor((cleanBase64.length * 3) / 4 - padding); + } +} diff --git a/packages/mcp-client/src/types.ts b/packages/mcp-client/src/types.ts new file mode 100644 index 0000000000..5c9077ceb5 --- /dev/null +++ b/packages/mcp-client/src/types.ts @@ -0,0 +1,30 @@ +// src/types.ts +import type { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js"; +import type { StreamableHTTPClientTransportOptions } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +/** StdioServerParameters is usable as-is */ + +/** + * Configuration for an SSE MCP server + */ +export interface SSEServerConfig { + url: string | URL; + options?: SSEClientTransportOptions; +} + +/** + * Configuration for a StreamableHTTP MCP server + */ +export interface StreamableHTTPServerConfig { + url: string | URL; + options?: StreamableHTTPClientTransportOptions; +} + +/** + * Discriminated union type for different MCP server types + */ +export type ServerConfig = + | { type: "stdio"; config: StdioServerParameters } + | { type: "sse"; config: SSEServerConfig } + | { type: "http"; config: StreamableHTTPServerConfig }; diff --git a/packages/mcp-client/src/utils.ts b/packages/mcp-client/src/utils.ts index 2440cfe8de..05f29f9033 100644 --- a/packages/mcp-client/src/utils.ts +++ b/packages/mcp-client/src/utils.ts @@ -1,4 +1,5 @@ import { inspect } from "util"; +import type { ServerConfig } from "./types"; export function debug(...args: unknown[]): void { if (process.env.DEBUG) { @@ -13,3 +14,77 @@ export const ANSI = { RED: "\x1b[31m", RESET: "\x1b[0m", }; + +export function urlToServerConfig(urlStr: string, token?: string): ServerConfig { + if (!urlStr.startsWith("http:") && !urlStr.startsWith("https:")) { + throw new Error(`Unsupported URL format: ${urlStr}. Use http:// or https:// prefix.`); + } + + const url = new URL(urlStr); + const hostname = url.hostname; + const path = url.pathname; + + let type: "http" | "sse"; + if (path.endsWith("/sse")) { + type = "sse"; + } else if (path.endsWith("/mcp")) { + type = "http"; + } else { + throw new Error(`Unsupported endpoint: ${urlStr}. URL must end with /sse or /mcp`); + } + + // Check if we should include the token + const authToken = token ?? process.env.HF_TOKEN; + const shouldIncludeToken = + !!authToken && + (hostname.endsWith(".hf.space") || + hostname.endsWith("huggingface.co") || + hostname === "localhost" || + hostname === "127.0.0.1"); + + // Create appropriate config based on type and authorization requirements + if (type === "http") { + return { + type: "http", + config: { + url: urlStr, + options: shouldIncludeToken + ? { + requestInit: { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }, + } + : undefined, + }, + }; + } else { + return { + type: "sse", + config: { + url: urlStr, + options: shouldIncludeToken + ? { + requestInit: { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }, + // workaround for https://github.com/modelcontextprotocol/typescript-sdk/issues/436 + eventSourceInit: { + fetch: (url, init) => { + const reqHeaders = new Headers(init?.headers ?? {}); + reqHeaders.set("Authorization", `Bearer ${authToken}`); + return fetch(url, { + ...init, + headers: reqHeaders, + }); + }, + }, + } + : undefined, + }, + }; + } +} diff --git a/packages/mcp-client/test/ResultFormatter.spec.ts b/packages/mcp-client/test/ResultFormatter.spec.ts new file mode 100644 index 0000000000..4826e26580 --- /dev/null +++ b/packages/mcp-client/test/ResultFormatter.spec.ts @@ -0,0 +1,101 @@ +// test/CallToolResultFormatter.test.ts +import { describe, expect, it } from "vitest"; +import { ResultFormatter } from "../src/ResultFormatter"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types"; + +describe("CallToolResultFormatter", () => { + it("should handle empty content", () => { + const result: CallToolResult = { + content: [], + }; + expect(ResultFormatter.format(result)).toBe("[No content]"); + }); + + it("should format text content", () => { + const result: CallToolResult = { + content: [ + { + type: "text", + text: "Hello, world!", + }, + { + type: "text", + text: "This is a test.", + }, + ], + }; + expect(ResultFormatter.format(result)).toBe("Hello, world!\nThis is a test."); + }); + + it("should format binary content with summaries", () => { + const result: CallToolResult = { + content: [ + { + type: "image", + mimeType: "image/png", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + }, + { + type: "audio", + mimeType: "audio/mp3", + data: "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQwAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8", + }, + ], + }; + + const formatted = ResultFormatter.format(result); + expect(formatted).toContain("[Binary Content: Image image/png"); + expect(formatted).toContain("[Binary Content: Audio audio/mp3"); + expect(formatted).toContain("bytes]"); + }); + + it("should format resource content correctly", () => { + const result: CallToolResult = { + content: [ + { + type: "resource", + resource: { + uri: "https://example.com/text.txt", + text: "This is text from a resource.", + }, + }, + { + type: "resource", + resource: { + uri: "https://example.com/image.png", + mimeType: "image/png", + blob: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + }, + }, + ], + }; + + const formatted = ResultFormatter.format(result); + expect(formatted).toContain("This is text from a resource."); + expect(formatted).toContain("[Binary Content (https://example.com/image.png): image/png"); + expect(formatted).toContain("bytes]"); + }); + + it("should handle mixed content types", () => { + const result: CallToolResult = { + content: [ + { + type: "text", + text: "Here's a file I found:", + }, + { + type: "resource", + resource: { + uri: "https://example.com/document.pdf", + mimeType: "application/pdf", + blob: "JVBERi0xLjMKJcTl8uXrp/Og0MTGCjQgMCBvYmoKPDwgL0xlbmd0aCA1IDAgUiAvRmlsdGVyIC9GbGF0ZURlY29kZSA+PgpzdHJlYW0KeAEtjTsKgDAUBHt", + }, + }, + ], + }; + + const formatted = ResultFormatter.format(result); + expect(formatted).toContain("Here's a file I found:"); + expect(formatted).toContain("[Binary Content (https://example.com/document.pdf): application/pdf"); + }); +}); diff --git a/packages/mcp-client/test/UrlConversion.spec.ts b/packages/mcp-client/test/UrlConversion.spec.ts new file mode 100644 index 0000000000..c89e48d486 --- /dev/null +++ b/packages/mcp-client/test/UrlConversion.spec.ts @@ -0,0 +1,110 @@ +// test/UrlConversion.spec.ts +import { describe, expect, it } from "vitest"; +import { urlToServerConfig } from "../src/utils"; + +describe("urlToServerConfig", () => { + const TOKEN = "test-token"; + + describe("Transport Type Selection", () => { + it("should create streamableHttp config for URLs ending with /mcp", () => { + const urls = ["https://example.com/api/mcp", "https://test.hf.space/mcp", "http://localhost:3000/mcp"]; + + for (const url of urls) { + const config = urlToServerConfig(url, TOKEN); + expect(config.type).toBe("http"); + if (config.type === "http") { + expect(config.config.url).toBe(url); + } + } + }); + + it("should create sse config for URLs ending with /sse", () => { + const urls = ["https://example.com/api/sse", "https://test.hf.space/sse", "http://localhost:3000/sse"]; + + for (const url of urls) { + const config = urlToServerConfig(url, TOKEN); + expect(config.type).toBe("sse"); + if (config.type === "sse") { + expect(config.config.url).toBe(url); + } + } + }); + + it("should throw for URLs not ending with /mcp or /sse", () => { + expect(() => urlToServerConfig("https://example.com/api")).toThrow("Unsupported endpoint"); + }); + }); + + describe("Authorization Token Inclusion", () => { + it("should include token for Hugging Face domains regardless of transport type", () => { + const urls = [ + "https://test.hf.space/api/mcp", // StreamableHTTP + "https://api.huggingface.co/models/mcp", // StreamableHTTP + "https://test.hf.space/api/sse", // SSE + "https://api.huggingface.co/models/sse", // SSE + ]; + + for (const url of urls) { + const config = urlToServerConfig(url, TOKEN); + if (config.type === "http") { + const authHeader = config.config.options?.requestInit?.headers as Record | undefined; + expect(authHeader?.Authorization).toBe(`Bearer ${TOKEN}`); + } else if (config.type === "sse") { + const authHeader = config.config.options?.requestInit?.headers as Record | undefined; + expect(authHeader?.Authorization).toBe(`Bearer ${TOKEN}`); + } + } + }); + + it("should include token for localhost domains regardless of transport type", () => { + const urls = [ + "http://localhost:3000/mcp", // StreamableHTTP + "http://127.0.0.1:8000/api/mcp", // StreamableHTTP + "http://localhost:3000/sse", // SSE + "http://127.0.0.1:8000/api/sse", // SSE + ]; + + for (const url of urls) { + const config = urlToServerConfig(url, TOKEN); + if (config.type === "http") { + const authHeader = config.config.options?.requestInit?.headers as Record | undefined; + expect(authHeader?.Authorization).toBe(`Bearer ${TOKEN}`); + } else if (config.type === "sse") { + const authHeader = config.config.options?.requestInit?.headers as Record | undefined; + expect(authHeader?.Authorization).toBe(`Bearer ${TOKEN}`); + } + } + }); + + it("should NOT include token for non-HF/non-local domains regardless of transport type", () => { + const urls = [ + "https://example.com/api/mcp", // StreamableHTTP + "https://someother.org/mcp", // StreamableHTTP + "https://example.com/api/sse", // SSE + "https://someother.org/sse", // SSE + ]; + + for (const url of urls) { + const config = urlToServerConfig(url, TOKEN); + if (config.type === "http" || config.type === "sse") { + expect(config.config.options).toBeUndefined(); + } + } + }); + + it("should not include token when not provided even for HF domains", () => { + const urls = ["https://test.hf.space/api/mcp", "https://test.hf.space/api/sse"]; + + for (const url of urls) { + const config = urlToServerConfig(url); // No token provided + if (config.type === "http" || config.type === "sse") { + expect(config.config.options).toBeUndefined(); + } + } + }); + }); + + it("should throw for unsupported URL formats", () => { + expect(() => urlToServerConfig("ftp://example.com/mcp")).toThrow("Unsupported URL format"); + }); +});