🎏 feat: Add MCP support for Streamable HTTP Transport (#7353)

This commit is contained in:
Ben Verhees 2025-05-13 19:14:15 +02:00 committed by GitHub
parent 502617db24
commit 0b44142383
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 185 additions and 11 deletions

19
package-lock.json generated
View file

@ -35391,6 +35391,15 @@
"node": ">= 6" "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": { "node_modules/pkg-dir": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@ -43434,7 +43443,7 @@
"version": "1.2.2", "version": "1.2.2",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.9.0", "@modelcontextprotocol/sdk": "^1.11.2",
"diff": "^7.0.0", "diff": "^7.0.0",
"eventsource": "^3.0.2", "eventsource": "^3.0.2",
"express": "^4.21.2" "express": "^4.21.2"
@ -43721,14 +43730,6 @@
"node": ">= 0.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": { "packages/mcp/node_modules/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",

View file

@ -1,4 +1,4 @@
import { StdioOptionsSchema, processMCPEnv, MCPOptions } from '../src/mcp'; import { StdioOptionsSchema, StreamableHTTPOptionsSchema, processMCPEnv, MCPOptions } from '../src/mcp';
describe('Environment Variable Extraction (MCP)', () => { describe('Environment Variable Extraction (MCP)', () => {
const originalEnv = process.env; 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', () => { describe('processMCPEnv', () => {
it('should create a deep clone of the input object', () => { it('should create a deep clone of the input object', () => {
const originalObj: MCPOptions = { const originalObj: MCPOptions = {
@ -173,5 +241,37 @@ describe('Environment Variable Extraction (MCP)', () => {
// Second user's config should be unchanged // Second user's config should be unchanged
expect('headers' in resultUser2 && resultUser2.headers?.['User-Id']).toBe(user2Id); 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');
});
}); });
}); });

View file

@ -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([ export const MCPOptionsSchema = z.union([
StdioOptionsSchema, StdioOptionsSchema,
WebSocketOptionsSchema, WebSocketOptionsSchema,
SSEOptionsSchema, SSEOptionsSchema,
StreamableHTTPOptionsSchema,
]); ]);
export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema); export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema);

View file

@ -67,7 +67,7 @@
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.9.0", "@modelcontextprotocol/sdk": "^1.11.2",
"diff": "^7.0.0", "diff": "^7.0.0",
"eventsource": "^3.0.2", "eventsource": "^3.0.2",
"express": "^4.21.2" "express": "^4.21.2"

View file

@ -7,6 +7,7 @@ import {
} from '@modelcontextprotocol/sdk/client/stdio.js'; } from '@modelcontextprotocol/sdk/client/stdio.js';
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
import { ResourceListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.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 { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Logger } from 'winston'; import type { Logger } from 'winston';
import type * as t from './types/mcp.js'; import type * as t from './types/mcp.js';
@ -31,6 +32,24 @@ function isSSEOptions(options: t.MCPOptions): options is t.SSEOptions {
return false; 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; const FIVE_MINUTES = 5 * 60 * 1000;
export class MCPConnection extends EventEmitter { export class MCPConnection extends EventEmitter {
private static instance: MCPConnection | null = null; private static instance: MCPConnection | null = null;
@ -120,6 +139,8 @@ export class MCPConnection extends EventEmitter {
type = 'stdio'; type = 'stdio';
} else if (isWebSocketOptions(options)) { } else if (isWebSocketOptions(options)) {
type = 'websocket'; type = 'websocket';
} else if (isStreamableHTTPOptions(options)) {
type = 'streamable-http';
} else if (isSSEOptions(options)) { } else if (isSSEOptions(options)) {
type = 'sse'; type = 'sse';
} else { } else {
@ -190,6 +211,41 @@ export class MCPConnection extends EventEmitter {
return transport; 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: { default: {
throw new Error(`Unsupported transport type: ${type}`); throw new Error(`Unsupported transport type: ${type}`);
} }

View file

@ -5,6 +5,7 @@ import {
MCPServersSchema, MCPServersSchema,
StdioOptionsSchema, StdioOptionsSchema,
WebSocketOptionsSchema, WebSocketOptionsSchema,
StreamableHTTPOptionsSchema,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { JsonSchemaType, TPlugin } from 'librechat-data-provider'; import type { JsonSchemaType, TPlugin } from 'librechat-data-provider';
import { ToolSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; 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<typeof StdioOptionsSchema>; export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>; export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
export type SSEOptions = z.infer<typeof SSEOptionsSchema>; export type SSEOptions = z.infer<typeof SSEOptionsSchema>;
export type StreamableHTTPOptions = z.infer<typeof StreamableHTTPOptionsSchema>;
export type MCPOptions = z.infer<typeof MCPOptionsSchema>; export type MCPOptions = z.infer<typeof MCPOptionsSchema>;
export type MCPServers = z.infer<typeof MCPServersSchema>; export type MCPServers = z.infer<typeof MCPServersSchema>;
export interface MCPResource { export interface MCPResource {