🔌 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:
Danny Avila 2026-08-14 01:12:56 -04:00 committed by GitHub
parent 24d111fde9
commit 2f0cd2eb75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 121 additions and 10 deletions

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -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) => {

View file

@ -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) {

View file

@ -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', () => {

View file

@ -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]