diff --git a/packages/mcp-client/cli.ts b/packages/mcp-client/cli.ts index a838689dd..8cf214a7d 100644 --- a/packages/mcp-client/cli.ts +++ b/packages/mcp-client/cli.ts @@ -4,6 +4,7 @@ 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 { ServerConfig } from "./src/types"; import type { InferenceProvider } from "@huggingface/inference"; import { ANSI } from "./src/utils"; import { Agent } from "./src"; @@ -13,17 +14,45 @@ const MODEL_ID = process.env.MODEL_ID ?? "Qwen/Qwen2.5-72B-Instruct"; const PROVIDER = (process.env.PROVIDER as InferenceProvider) ?? "nebius"; const BASE_URL = process.env.BASE_URL; -const SERVERS: StdioServerParameters[] = [ +const SERVERS: (ServerConfig | StdioServerParameters)[] = [ + // { + // // Filesystem "official" mcp-server with access to your Desktop + // command: "npx", + // args: ["-y", "@modelcontextprotocol/server-filesystem", join(homedir(), "Desktop")], + // }, + // { + // // Playwright MCP + // command: "npx", + // args: ["@playwright/mcp@latest"], + // }, { - // Filesystem "official" mcp-server with access to your Desktop - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", join(homedir(), "Desktop")], - }, - { - // Playwright MCP - command: "npx", - args: ["@playwright/mcp@latest"], + type: "sse", + config: { + url: "https://evalstate-flux1-schnell.hf.space/gradio_api/mcp/sse", + options: { + requestInit: { + headers: { + "Authorization": `Bearer ${process.env.HF_TOKEN || ""}` + } + }, + // workaround for https://github.com/modelcontextprotocol/typescript-sdk/issues/436 + eventSourceInit: { + fetch: (url, init) => { + const headers = new Headers(init?.headers || {}); + headers.set("Authorization", `Bearer ${process.env.HF_TOKEN || ""}`); + return fetch(url, { + ...init, + headers + }); + } + }, + }, + }, }, + // { + // command: "npx", + // args: ["-y","@llmindset/mcp-hfspace","--work-dir",join(homedir(),"temp/hfspace/")] + // } ]; if (process.env.EXPERIMENTAL_HF_MCP_SERVER) { @@ -56,13 +85,13 @@ async function main() { model: MODEL_ID, apiKey: process.env.HF_TOKEN, servers: SERVERS, - } + } : { provider: PROVIDER, model: MODEL_ID, apiKey: process.env.HF_TOKEN, servers: SERVERS, - } + } ); const rl = readline.createInterface({ input: stdin, output: stdout }); diff --git a/packages/mcp-client/package.json b/packages/mcp-client/package.json index 3775a9bf3..d8df1ff17 100644 --- a/packages/mcp-client/package.json +++ b/packages/mcp-client/package.json @@ -55,6 +55,7 @@ "dependencies": { "@huggingface/inference": "workspace:^", "@huggingface/tasks": "workspace:^", - "@modelcontextprotocol/sdk": "^1.9.0" + "@modelcontextprotocol/sdk": "^1.9.0", + "vitest": "^3.1.2" } } diff --git a/packages/mcp-client/src/Agent.ts b/packages/mcp-client/src/Agent.ts index 4dfb1e2f5..ffac602de 100644 --- a/packages/mcp-client/src/Agent.ts +++ b/packages/mcp-client/src/Agent.ts @@ -5,6 +5,7 @@ import type { ChatCompletionInputMessage, ChatCompletionStreamOutput } from "@hu import type { ChatCompletionInputTool } from "@huggingface/tasks/src/tasks/chat-completion/inference"; import type { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio"; import { debug } from "./utils"; +import { 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({ @@ -66,7 +67,7 @@ export class Agent extends McpClient { ) & { model: string; apiKey: string; - servers: StdioServerParameters[]; + servers: (ServerConfig|StdioServerParameters)[]; prompt?: string; }) { super(provider ? { provider, baseUrl, model, apiKey } : { provider, baseUrl, model, apiKey }); diff --git a/packages/mcp-client/src/McpClient.ts b/packages/mcp-client/src/McpClient.ts index 94d772b25..75b2e97a8 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 { ServerConfig } from "./types"; +import { 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,38 @@ 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 'streamableHttp': + 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 +198,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/ResultFormat.spec.ts b/packages/mcp-client/src/ResultFormat.spec.ts new file mode 100644 index 000000000..c3de6b918 --- /dev/null +++ b/packages/mcp-client/src/ResultFormat.spec.ts @@ -0,0 +1,101 @@ +// test/CallToolResultFormatter.test.ts +import { describe, expect, it } from "vitest"; +import { ResultFormatter } from "./ResultFormatter.js"; +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/src/ResultFormatter.ts b/packages/mcp-client/src/ResultFormatter.ts new file mode 100644 index 000000000..cdf9b56b8 --- /dev/null +++ b/packages/mcp-client/src/ResultFormatter.ts @@ -0,0 +1,78 @@ +import type { + CallToolResult, + TextResourceContents, + BlobResourceContents +} 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: CallToolResult): 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 000000000..cb5bae228 --- /dev/null +++ b/packages/mcp-client/src/types.ts @@ -0,0 +1,31 @@ +// 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: 'streamableHttp'; config: StreamableHTTPServerConfig }