diff --git a/api/package.json b/api/package.json index 3fadd61ef0..6e1efe18de 100644 --- a/api/package.json +++ b/api/package.json @@ -50,7 +50,7 @@ "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@node-saml/passport-saml": "^5.1.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation-express": "^0.56.0", diff --git a/package-lock.json b/package-lock.json index e8a2e6c600..2ce52af382 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,7 @@ "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@node-saml/passport-saml": "^5.1.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation-express": "^0.56.0", @@ -11344,12 +11344,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -42851,7 +42851,7 @@ "@keyv/redis": "^4.3.3", "@librechat/agents": "^3.4.7", "@librechat/data-schemas": "*", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation-express": "^0.56.0", "@opentelemetry/instrumentation-http": "^0.207.0", diff --git a/packages/api/package.json b/packages/api/package.json index 04197a3245..fc1cd6c713 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -115,7 +115,7 @@ "@keyv/redis": "^4.3.3", "@librechat/agents": "^3.4.7", "@librechat/data-schemas": "*", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation-express": "^0.56.0", "@opentelemetry/instrumentation-http": "^0.207.0", diff --git a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts index 702a073721..83ff8823a6 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts @@ -2051,6 +2051,68 @@ describe('MCP SSRF protection – customFetch input shapes', () => { } }); + /** + * A `Content-Type` whose parameters mention the SSE type is not an SSE response. Classifying + * it by substring made the guard hand the caller a synthetic SSE error frame — parsed as a + * successful response body — instead of throwing, so an oversized body arrived looking well + * formed. + */ + it('should not treat a content type that merely mentions the SSE type as an event stream', async () => { + process.env.MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES = '8'; + const server = await createRawResponseServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain; boundary=text/event-stream' }); + res.end('{"jsonrpc":"2.0","id":1,"result":{"too":"large"}}'); + }); + try { + conn = new MCPConnection({ + serverName: 'customfetch-deceptive-content-type', + serverConfig: { type: 'streamable-http', url: server.url }, + useSSRFProtection: false, + }); + + const customFetch = getGuardedStreamableHTTPCustomFetch(conn); + const response = await customFetch(server.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'ping', id: 1 }), + }); + + await expect(response.text()).rejects.toThrow( + /MCP response exceeded byte limit.*limit=8 bytes/, + ); + } finally { + await server.close(); + } + }); + + it('should still guard a genuine event stream whose content type carries parameters', async () => { + process.env.MCP_STREAMABLE_HTTP_MAX_LINE_BYTES = '16'; + const server = await createRawResponseServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'TEXT/EVENT-STREAM; charset=utf-8' }); + res.end(`data: ${'x'.repeat(256)}\n\n`); + }); + try { + conn = new MCPConnection({ + serverName: 'customfetch-parameterized-sse', + serverConfig: { type: 'streamable-http', url: server.url }, + useSSRFProtection: false, + }); + + const customFetch = getGuardedStreamableHTTPCustomFetch(conn); + const response = await customFetch(server.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'ping', id: 1 }), + }); + + await expect(response.text()).resolves.toContain( + 'MCP response contained an oversized SSE line', + ); + } finally { + await server.close(); + } + }); + it('should reject a POST response with an oversized SSE line before the SSE parser can grow it', async () => { process.env.MCP_STREAMABLE_HTTP_MAX_LINE_BYTES = '16'; const server = await createRawResponseServer((_req, res) => { diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 54805d95b7..64eeed4c57 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -32,6 +32,7 @@ import { createSSRFSafeUndiciConnect, isSSRFTarget, resolveHostnameSSRF } from ' import { reserveMCPToolsChangedRevision } from './toolsChanged'; import { isOAuthServer, sanitizeUrlForLogging } from './utils'; import { runOutsideTracing } from '~/utils/tracing'; +import { mediaTypeEssence } from '~/utils/headers'; import { isAddressAllowed } from '~/auth/domain'; import { withTimeout } from '~/utils/promise'; import { mcpConfig } from './mcpConfig'; @@ -316,7 +317,7 @@ async function guardMCPStreamableHTTPResponse( } const contentType = response.headers.get('content-type') ?? ''; - const isEventStream = contentType.toLowerCase().includes('text/event-stream'); + const isEventStream = mediaTypeEssence(contentType) === 'text/event-stream'; const { maxResponseBytes, maxLineBytes } = getMCPStreamableHTTPResponseLimits(); const canEmitFallbackSSEError = isEventStream && maxLineBytes > 0; if (!isEventStream && maxResponseBytes === 0) { diff --git a/packages/api/src/utils/headers.spec.ts b/packages/api/src/utils/headers.spec.ts index 4f914c07ac..e87c6f20ab 100644 --- a/packages/api/src/utils/headers.spec.ts +++ b/packages/api/src/utils/headers.spec.ts @@ -1,5 +1,37 @@ import type { RunLLMConfig } from '~/types'; -import { mergeHeaders, resolveConfigHeaders } from './headers'; +import { mediaTypeEssence, mergeHeaders, resolveConfigHeaders } from './headers'; + +describe('mediaTypeEssence', () => { + it('returns the bare type for a header with no parameters', () => { + expect(mediaTypeEssence('text/event-stream')).toBe('text/event-stream'); + }); + + it('strips parameters', () => { + expect(mediaTypeEssence('text/event-stream; charset=utf-8')).toBe('text/event-stream'); + expect(mediaTypeEssence('application/json;charset=utf-8')).toBe('application/json'); + }); + + it('lowercases the type', () => { + expect(mediaTypeEssence('TEXT/EVENT-STREAM')).toBe('text/event-stream'); + expect(mediaTypeEssence('Application/JSON; Charset=UTF-8')).toBe('application/json'); + }); + + it('trims surrounding whitespace', () => { + expect(mediaTypeEssence(' text/plain ; charset=utf-8')).toBe('text/plain'); + }); + + it('does not match a type named only inside a parameter', () => { + expect(mediaTypeEssence('text/plain; boundary=text/event-stream')).toBe('text/plain'); + expect(mediaTypeEssence('text/plain; x=application/json')).toBe('text/plain'); + }); + + it('returns an empty string for absent or empty headers', () => { + expect(mediaTypeEssence(undefined)).toBe(''); + expect(mediaTypeEssence(null)).toBe(''); + expect(mediaTypeEssence('')).toBe(''); + expect(mediaTypeEssence(' ')).toBe(''); + }); +}); describe('mergeHeaders', () => { it('returns undefined when neither side has headers', () => { diff --git a/packages/api/src/utils/headers.ts b/packages/api/src/utils/headers.ts index 940e88983f..e2644f0be5 100644 --- a/packages/api/src/utils/headers.ts +++ b/packages/api/src/utils/headers.ts @@ -3,6 +3,22 @@ import type { IUser } from '@librechat/data-schemas'; import type { RequestBody, RunLLMConfig } from '~/types'; import { resolveHeaders } from './env'; +/** + * The media type of a `Content-Type` header — the lowercased `type/subtype` pair with any + * parameters stripped, or `''` when the header is absent or empty. + * + * Substring-matching the raw header is wrong in both directions. A `text/plain; + * boundary=text/event-stream` value contains the SSE type without being one, and a + * `TEXT/EVENT-STREAM` value is one without containing it in the expected case. Callers + * classifying a response by its type must compare against this, not the raw header. + */ +export function mediaTypeEssence(header: string | null | undefined): string { + if (!header) { + return ''; + } + return (header.split(';', 1)[0] ?? '').trim().toLowerCase(); +} + /** Comma-unions two header values (deduped, trimmed), e.g. `anthropic-beta`. */ function unionCsv(a: string, b: string): string { const values = [a, b]