mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
🎏 feat: Add MCP support for Streamable HTTP Transport (#7353)
This commit is contained in:
parent
502617db24
commit
0b44142383
6 changed files with 185 additions and 11 deletions
19
package-lock.json
generated
19
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof StdioOptionsSchema>;
|
||||
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
||||
export type SSEOptions = z.infer<typeof SSEOptionsSchema>;
|
||||
export type StreamableHTTPOptions = z.infer<typeof StreamableHTTPOptionsSchema>;
|
||||
export type MCPOptions = z.infer<typeof MCPOptionsSchema>;
|
||||
export type MCPServers = z.infer<typeof MCPServersSchema>;
|
||||
export interface MCPResource {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue