mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🔌 chore: Bump the MCP SDK to 1.30.0 and Parse Content-Type Instead of Searching It (#14820)
`@modelcontextprotocol/sdk@1.30.0` is a small maintenance release on the 1.x line (upstream's active line is now the 2.0.0 scoped packages). The range was already `^1.29.0`, so only the lockfile pinned the old version; the manifests move too so the floor matches what we test against. Nothing in it is breaking. The four changed type declarations are additive — optional `maxBufferSize` on `StdioServerParameters`, an optional third constructor argument on `StdioServerTransport`, optional options on `ReadBuffer`, optional `keepAliveMs` on the server transport — and the only manifest change is `@hono/node-server` widening to `^1.19.9 || ^2.0.5`. No new dependencies. Two behavior changes are worth knowing about even though neither is an API break. `ReadBuffer` now caps a single stdio message at 10 MB (previously unbounded) and errors the transport instead of growing, which is reachable through `StdioClientTransport` if a stdio server returns a very large single result; it takes `maxBufferSize` if that ever needs raising. And Content-Type handling switched from substring search to parsed media types, client and server. Most of the release is Streamable HTTP server hardening we do not run — a 15s SSE keep-alive, `X-Accel-Buffering: no` on SSE responses, guards so a stale stream's cancel cannot tear down its successor, and `_closed` checks so a transport closing mid-request stops registering streams into swept maps. None of it changes how we behave as a client. In particular it does not address the stale-stream 409 in #14816: that keep-alive runs in whichever server we connect to, not here. The same substring-vs-parse mistake the SDK corrected exists in our streamable HTTP response guard, which classified a response as SSE with `contentType.includes('text/event-stream')`. A `Content-Type` naming the SSE type in a parameter — `text/plain; boundary=text/event-stream` — is not an event stream, but matched. The guard then took `canEmitFallbackSSEError`, so an oversized body was answered with a synthetic SSE error frame the caller reads as a well-formed response body, rather than the throw a non-SSE response gets. The check now compares the parsed media type, via a `mediaTypeEssence` helper added to the header utils where `mergeHeaders` already lives. Verified against 1.30.0 rather than assuming: the package was staged into the worktree's own `node_modules` so it shadowed the shared install, and `packages/api` `src/mcp` ran green on it — same four pre-existing red suites as on 1.29.0 (`MCPReinitRecovery` plus three Redis `cache_integration` suites that need a live Redis), no new failures.
This commit is contained in:
parent
24d111fde9
commit
2f0cd2eb75
7 changed files with 121 additions and 10 deletions
|
|
@ -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",
|
||||
|
|
|
|||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue