mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +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": ">= 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",
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue