diff --git a/package-lock.json b/package-lock.json index 769c6e8a03..39f4b7b27a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35391,6 +35391,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -43434,7 +43443,7 @@ "version": "1.2.2", "license": "ISC", "dependencies": { - "@modelcontextprotocol/sdk": "^1.9.0", + "@modelcontextprotocol/sdk": "^1.11.2", "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^4.21.2" @@ -43721,14 +43730,6 @@ "node": ">= 0.6" } }, - "packages/mcp/node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "engines": { - "node": ">=16.20.0" - } - }, "packages/mcp/node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", diff --git a/packages/data-provider/specs/mcp.spec.ts b/packages/data-provider/specs/mcp.spec.ts index 0505d70ea1..b3fb61375c 100644 --- a/packages/data-provider/specs/mcp.spec.ts +++ b/packages/data-provider/specs/mcp.spec.ts @@ -1,4 +1,4 @@ -import { StdioOptionsSchema, processMCPEnv, MCPOptions } from '../src/mcp'; +import { StdioOptionsSchema, StreamableHTTPOptionsSchema, processMCPEnv, MCPOptions } from '../src/mcp'; describe('Environment Variable Extraction (MCP)', () => { const originalEnv = process.env; @@ -50,6 +50,74 @@ describe('Environment Variable Extraction (MCP)', () => { }); }); + describe('StreamableHTTPOptionsSchema', () => { + it('should validate a valid streamable-http configuration', () => { + const options = { + type: 'streamable-http', + url: 'https://example.com/api', + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + }, + }; + + const result = StreamableHTTPOptionsSchema.parse(options); + + expect(result).toEqual(options); + }); + + it('should reject websocket URLs', () => { + const options = { + type: 'streamable-http', + url: 'ws://example.com/socket', + }; + + expect(() => StreamableHTTPOptionsSchema.parse(options)).toThrow(); + }); + + it('should reject secure websocket URLs', () => { + const options = { + type: 'streamable-http', + url: 'wss://example.com/socket', + }; + + expect(() => StreamableHTTPOptionsSchema.parse(options)).toThrow(); + }); + + it('should require type field to be set explicitly', () => { + const options = { + url: 'https://example.com/api', + }; + + // Type is now required, so parsing should fail + expect(() => StreamableHTTPOptionsSchema.parse(options)).toThrow(); + + // With type provided, it should pass + const validOptions = { + type: 'streamable-http' as const, + url: 'https://example.com/api', + }; + + const result = StreamableHTTPOptionsSchema.parse(validOptions); + expect(result.type).toBe('streamable-http'); + }); + + it('should validate headers as record of strings', () => { + const options = { + type: 'streamable-http', + url: 'https://example.com/api', + headers: { + 'X-API-Key': '123456', + 'User-Agent': 'MCP Client', + }, + }; + + const result = StreamableHTTPOptionsSchema.parse(options); + + expect(result.headers).toEqual(options.headers); + }); + }); + describe('processMCPEnv', () => { it('should create a deep clone of the input object', () => { const originalObj: MCPOptions = { @@ -173,5 +241,37 @@ describe('Environment Variable Extraction (MCP)', () => { // Second user's config should be unchanged expect('headers' in resultUser2 && resultUser2.headers?.['User-Id']).toBe(user2Id); }); + + it('should process headers in streamable-http options', () => { + const userId = 'test-user-123'; + const obj: MCPOptions = { + type: 'streamable-http', + url: 'https://example.com', + headers: { + Authorization: '${TEST_API_KEY}', + 'User-Id': '{{LIBRECHAT_USER_ID}}', + 'Content-Type': 'application/json', + }, + }; + + const result = processMCPEnv(obj, userId); + + expect('headers' in result && result.headers).toEqual({ + Authorization: 'test-api-key-value', + 'User-Id': 'test-user-123', + 'Content-Type': 'application/json', + }); + }); + + it('should maintain streamable-http type in processed options', () => { + const obj: MCPOptions = { + type: 'streamable-http', + url: 'https://example.com/api', + }; + + const result = processMCPEnv(obj); + + expect(result.type).toBe('streamable-http'); + }); }); }); diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index cdfc516b8f..1fdb6c95f6 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -82,10 +82,25 @@ export const SSEOptionsSchema = BaseOptionsSchema.extend({ ), }); +export const StreamableHTTPOptionsSchema = BaseOptionsSchema.extend({ + type: z.literal('streamable-http'), + headers: z.record(z.string(), z.string()).optional(), + url: z.string().url().refine( + (val) => { + const protocol = new URL(val).protocol; + return protocol !== 'ws:' && protocol !== 'wss:'; + }, + { + message: 'Streamable HTTP URL must not start with ws:// or wss://', + }, + ), +}); + export const MCPOptionsSchema = z.union([ StdioOptionsSchema, WebSocketOptionsSchema, SSEOptionsSchema, + StreamableHTTPOptionsSchema, ]); export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 840e76f5ee..5bfb680e4f 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -67,7 +67,7 @@ "registry": "https://registry.npmjs.org/" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.9.0", + "@modelcontextprotocol/sdk": "^1.11.2", "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^4.21.2" diff --git a/packages/mcp/src/connection.ts b/packages/mcp/src/connection.ts index 662d7f932b..c2729a6d77 100644 --- a/packages/mcp/src/connection.ts +++ b/packages/mcp/src/connection.ts @@ -7,6 +7,7 @@ import { } from '@modelcontextprotocol/sdk/client/stdio.js'; import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; import { ResourceListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Logger } from 'winston'; import type * as t from './types/mcp.js'; @@ -31,6 +32,24 @@ function isSSEOptions(options: t.MCPOptions): options is t.SSEOptions { return false; } +/** + * Checks if the provided options are for a Streamable HTTP transport. + * + * Streamable HTTP is an MCP transport that uses HTTP POST for sending messages + * and supports streaming responses. It provides better performance than + * SSE transport while maintaining compatibility with most network environments. + * + * @param options MCP connection options to check + * @returns True if options are for a streamable HTTP transport + */ +function isStreamableHTTPOptions(options: t.MCPOptions): options is t.StreamableHTTPOptions { + if ('url' in options && options.type === 'streamable-http') { + const protocol = new URL(options.url).protocol; + return protocol !== 'ws:' && protocol !== 'wss:'; + } + return false; +} + const FIVE_MINUTES = 5 * 60 * 1000; export class MCPConnection extends EventEmitter { private static instance: MCPConnection | null = null; @@ -120,6 +139,8 @@ export class MCPConnection extends EventEmitter { type = 'stdio'; } else if (isWebSocketOptions(options)) { type = 'websocket'; + } else if (isStreamableHTTPOptions(options)) { + type = 'streamable-http'; } else if (isSSEOptions(options)) { type = 'sse'; } else { @@ -190,6 +211,41 @@ export class MCPConnection extends EventEmitter { return transport; } + case 'streamable-http': { + if (!isStreamableHTTPOptions(options)) { + throw new Error('Invalid options for streamable-http transport.'); + } + const url = new URL(options.url); + this.logger?.info(`${this.getLogPrefix()} Creating streamable-http transport: ${url.toString()}`); + const abortController = new AbortController(); + + const transport = new StreamableHTTPClientTransport(url, { + requestInit: { + headers: options.headers, + signal: abortController.signal, + }, + }); + + transport.onclose = () => { + this.logger?.info(`${this.getLogPrefix()} Streamable-http transport closed`); + this.emit('connectionChange', 'disconnected'); + }; + + transport.onerror = (error) => { + this.logger?.error(`${this.getLogPrefix()} Streamable-http transport error:`, error); + this.emitError(error, 'Streamable-http transport error:'); + }; + + transport.onmessage = (message) => { + this.logger?.info( + `${this.getLogPrefix()} Message received: ${JSON.stringify(message)}`, + ); + }; + + this.setupTransportErrorHandlers(transport); + return transport; + } + default: { throw new Error(`Unsupported transport type: ${type}`); } diff --git a/packages/mcp/src/types/mcp.ts b/packages/mcp/src/types/mcp.ts index 7840e0e168..024ed8fd30 100644 --- a/packages/mcp/src/types/mcp.ts +++ b/packages/mcp/src/types/mcp.ts @@ -5,6 +5,7 @@ import { MCPServersSchema, StdioOptionsSchema, WebSocketOptionsSchema, + StreamableHTTPOptionsSchema, } from 'librechat-data-provider'; import type { JsonSchemaType, TPlugin } from 'librechat-data-provider'; import { ToolSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; @@ -13,6 +14,7 @@ import type * as t from '@modelcontextprotocol/sdk/types.js'; export type StdioOptions = z.infer; export type WebSocketOptions = z.infer; export type SSEOptions = z.infer; +export type StreamableHTTPOptions = z.infer; export type MCPOptions = z.infer; export type MCPServers = z.infer; export interface MCPResource {