Skip to content

SSE and Streaming Support POC #1422

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
51 changes: 40 additions & 11 deletions packages/mcp-client/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -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 });
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
5 changes: 3 additions & 2 deletions packages/mcp-client/src/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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({
Expand All @@ -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 });
Expand Down
42 changes: 35 additions & 7 deletions packages/mcp-client/src/McpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -52,15 +57,38 @@ export class McpClient {
this.model = model;
}

async addMcpServers(servers: StdioServerParameters[]): Promise<void> {
async addMcpServers(servers: (ServerConfig | StdioServerParameters)[]): Promise<void> {
await Promise.all(servers.map((s) => this.addMcpServer(s)));
}

async addMcpServer(server: StdioServerParameters): Promise<void> {
const transport = new StdioClientTransport({
...server,
env: { ...server.env, PATH: process.env.PATH ?? "" },
});
async addMcpServer(server: ServerConfig | StdioServerParameters): Promise<void> {
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);

Expand Down Expand Up @@ -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}`;
}
Expand Down
101 changes: 101 additions & 0 deletions packages/mcp-client/src/ResultFormat.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
78 changes: 78 additions & 0 deletions packages/mcp-client/src/ResultFormatter.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
31 changes: 31 additions & 0 deletions packages/mcp-client/src/types.ts
Original file line number Diff line number Diff line change
@@ -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 }