From 2d70c4fe992a4979568a66d838d75934e1627fc5 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Tue, 29 Apr 2025 14:08:35 +0100 Subject: [PATCH 1/3] simple sse/streamable http connectivity, backwards compatible --- packages/mcp-client/cli.ts | 28 ++++++++++++------- packages/mcp-client/src/Agent.ts | 5 ++-- packages/mcp-client/src/McpClient.ts | 42 ++++++++++++++++++++++++---- packages/mcp-client/src/types.ts | 31 ++++++++++++++++++++ 4 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 packages/mcp-client/src/types.ts diff --git a/packages/mcp-client/cli.ts b/packages/mcp-client/cli.ts index 713de3b23..723ee3bbd 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"; @@ -12,17 +13,24 @@ import { version as packageVersion } from "./package.json"; const MODEL_ID = process.env.MODEL_ID ?? "Qwen/Qwen2.5-72B-Instruct"; const PROVIDER = (process.env.PROVIDER as InferenceProvider) ?? "nebius"; -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://abidlabs-mcp-tools.hf.space/gradio_api/mcp/sse", + } + } ]; if (process.env.EXPERIMENTAL_HF_MCP_SERVER) { diff --git a/packages/mcp-client/src/Agent.ts b/packages/mcp-client/src/Agent.ts index be4aeb3bb..791c0e77d 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({ @@ -57,7 +58,7 @@ export class Agent extends McpClient { provider: InferenceProvider; model: string; apiKey: string; - servers: StdioServerParameters[]; + servers: (ServerConfig|StdioServerParameters)[]; prompt?: string; }) { super({ provider, model, apiKey }); diff --git a/packages/mcp-client/src/McpClient.ts b/packages/mcp-client/src/McpClient.ts index 93f951194..2b570042e 100644 --- a/packages/mcp-client/src/McpClient.ts +++ b/packages/mcp-client/src/McpClient.ts @@ -11,6 +11,10 @@ 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"; type ToolName = string; @@ -34,15 +38,41 @@ 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); 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 } From 98dd695b879a16b64b63c9e3480897b409b96a41 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Tue, 29 Apr 2025 18:06:11 +0100 Subject: [PATCH 2/3] handle multipart responses --- packages/mcp-client/cli.ts | 4 + packages/mcp-client/package.json | 3 +- packages/mcp-client/src/McpClient.ts | 6 +- packages/mcp-client/src/ResultFormat.spec.ts | 101 +++++++++++++++++++ packages/mcp-client/src/ResultFormatter.ts | 78 ++++++++++++++ 5 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 packages/mcp-client/src/ResultFormat.spec.ts create mode 100644 packages/mcp-client/src/ResultFormatter.ts diff --git a/packages/mcp-client/cli.ts b/packages/mcp-client/cli.ts index 723ee3bbd..778b17ef0 100644 --- a/packages/mcp-client/cli.ts +++ b/packages/mcp-client/cli.ts @@ -30,6 +30,10 @@ const SERVERS: (ServerConfig|StdioServerParameters)[] = [ { url: "https://abidlabs-mcp-tools.hf.space/gradio_api/mcp/sse", } + }, + { + command: "npx", + args: ["-y","@llmindset/mcp-hfspace","--work-dir",join(homedir(),"temp/hfspace/")] } ]; 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/McpClient.ts b/packages/mcp-client/src/McpClient.ts index 2b570042e..81da9841d 100644 --- a/packages/mcp-client/src/McpClient.ts +++ b/packages/mcp-client/src/McpClient.ts @@ -15,6 +15,7 @@ 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; @@ -38,9 +39,6 @@ export class McpClient { this.model = model; } - - - async addMcpServers(servers: (ServerConfig | StdioServerParameters)[]): Promise { await Promise.all(servers.map((s) => this.addMcpServer(s))); } @@ -182,7 +180,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); + } +} From bf0cee2352704664f69ad49454015b4624f558b1 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Thu, 1 May 2025 17:40:44 +0100 Subject: [PATCH 3/3] workaround for passing HF_TOKEN with the current TS SDK --- packages/mcp-client/cli.ts | 41 +++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/mcp-client/cli.ts b/packages/mcp-client/cli.ts index c70f9f3f5..8cf214a7d 100644 --- a/packages/mcp-client/cli.ts +++ b/packages/mcp-client/cli.ts @@ -14,7 +14,7 @@ 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: (ServerConfig|StdioServerParameters)[] = [ +const SERVERS: (ServerConfig | StdioServerParameters)[] = [ // { // // Filesystem "official" mcp-server with access to your Desktop // command: "npx", @@ -26,16 +26,33 @@ const SERVERS: (ServerConfig|StdioServerParameters)[] = [ // args: ["@playwright/mcp@latest"], // }, { - type:"sse", - config: - { - url: "https://abidlabs-mcp-tools.hf.space/gradio_api/mcp/sse", - } + 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/")] - } + // { + // command: "npx", + // args: ["-y","@llmindset/mcp-hfspace","--work-dir",join(homedir(),"temp/hfspace/")] + // } ]; if (process.env.EXPERIMENTAL_HF_MCP_SERVER) { @@ -68,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 });