Skip to content

Commit 60557c2

Browse files
committed
fix: re-initialize the session when the server expires it
A 404 to a request carrying a session id means the server dropped the session. The spec requires the client to start a new one with a fresh InitializeRequest; mcpProxy instead logged the error and left the request unanswered, so every later call failed too. Capture the client's initialize, and on a 404 clear the session id, replay it under a sentinel id, send notifications/initialized and retry the original message. Sends that fail for other reasons now return a JSON-RPC error rather than hanging.
1 parent 02619af commit 60557c2

2 files changed

Lines changed: 217 additions & 1 deletion

File tree

src/lib/utils.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
22
import { parseCommandLineArgs, shouldIncludeTool, mcpProxy, setupOAuthCallbackServerWithLongPoll, getServerUrlHash } from './utils'
33
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
4+
import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
45
import { EventEmitter } from 'events'
56
import express from 'express'
67

@@ -979,6 +980,122 @@ describe('Feature: MCP Proxy', () => {
979980
}),
980981
)
981982
})
983+
984+
it('Scenario: Re-establish the session when the server has expired it', async () => {
985+
// Given a client transport
986+
const mockTransportToClient = {
987+
send: vi.fn().mockResolvedValue(undefined),
988+
close: vi.fn().mockResolvedValue(undefined),
989+
start: vi.fn().mockResolvedValue(undefined),
990+
onmessage: vi.fn(),
991+
onclose: vi.fn(),
992+
onerror: vi.fn(),
993+
} as unknown as Transport
994+
995+
// And a server transport that 404s the first tools/call, as a server does
996+
// once it has dropped the session, then answers the fresh handshake
997+
const sent: any[] = []
998+
let expireNextCall = true
999+
const mockTransportToServer = {
1000+
send: vi.fn(async (message: any) => {
1001+
sent.push(message)
1002+
if (typeof message.id === 'string' && message.id.startsWith('mcp-remote-reinit-')) {
1003+
setTimeout(() => (mockTransportToServer as any).onmessage?.({ jsonrpc: '2.0', id: message.id, result: {} }), 0)
1004+
return
1005+
}
1006+
if (expireNextCall && message.method === 'tools/call') {
1007+
expireNextCall = false
1008+
throw new StreamableHTTPError(404, 'Error POSTing to endpoint: Session terminated')
1009+
}
1010+
}),
1011+
close: vi.fn().mockResolvedValue(undefined),
1012+
start: vi.fn().mockResolvedValue(undefined),
1013+
onmessage: vi.fn(),
1014+
onclose: vi.fn(),
1015+
onerror: vi.fn(),
1016+
sessionId: 'session-2',
1017+
} as unknown as Transport
1018+
1019+
mcpProxy({
1020+
transportToClient: mockTransportToClient,
1021+
transportToServer: mockTransportToServer,
1022+
ignoredTools: [],
1023+
})
1024+
1025+
// When the client initializes and then calls a tool
1026+
mockTransportToClient.onmessage?.({
1027+
jsonrpc: '2.0' as const,
1028+
method: 'initialize',
1029+
id: '1',
1030+
params: { clientInfo: { name: 'Test Client', version: '1.0.0' } },
1031+
} as any)
1032+
mockTransportToClient.onmessage?.({
1033+
jsonrpc: '2.0' as const,
1034+
method: 'tools/call',
1035+
id: '2',
1036+
params: { name: 'ping', arguments: {} },
1037+
} as any)
1038+
1039+
await vi.waitFor(() => expect(sent.map((m) => m.method)).toContain('notifications/initialized'))
1040+
1041+
// Then a fresh initialize was sent, carrying the client's own parameters
1042+
const reinitialize = sent.find((m) => typeof m.id === 'string' && m.id.startsWith('mcp-remote-reinit-'))
1043+
expect(reinitialize).toBeDefined()
1044+
expect(reinitialize.method).toBe('initialize')
1045+
expect(reinitialize.params.clientInfo.name).toContain('Test Client')
1046+
1047+
// And the call that triggered it was retried on the new session
1048+
expect(sent.filter((m) => m.method === 'tools/call' && m.id === '2')).toHaveLength(2)
1049+
1050+
// And the handshake response was consumed by the proxy, never shown to the client
1051+
expect(mockTransportToClient.send).not.toHaveBeenCalledWith(expect.objectContaining({ id: reinitialize.id }))
1052+
})
1053+
1054+
it('Scenario: Answer the client when a request cannot be delivered', async () => {
1055+
// Given a server transport that fails for a reason a new session cannot fix
1056+
const mockTransportToClient = {
1057+
send: vi.fn().mockResolvedValue(undefined),
1058+
close: vi.fn().mockResolvedValue(undefined),
1059+
start: vi.fn().mockResolvedValue(undefined),
1060+
onmessage: vi.fn(),
1061+
onclose: vi.fn(),
1062+
onerror: vi.fn(),
1063+
} as unknown as Transport
1064+
1065+
const mockTransportToServer = {
1066+
send: vi.fn().mockRejectedValue(new Error('connection reset')),
1067+
close: vi.fn().mockResolvedValue(undefined),
1068+
start: vi.fn().mockResolvedValue(undefined),
1069+
onmessage: vi.fn(),
1070+
onclose: vi.fn(),
1071+
onerror: vi.fn(),
1072+
} as unknown as Transport
1073+
1074+
mcpProxy({
1075+
transportToClient: mockTransportToClient,
1076+
transportToServer: mockTransportToServer,
1077+
ignoredTools: [],
1078+
})
1079+
1080+
// When the client sends a request
1081+
mockTransportToClient.onmessage?.({
1082+
jsonrpc: '2.0' as const,
1083+
method: 'tools/call',
1084+
id: '7',
1085+
params: { name: 'ping', arguments: {} },
1086+
} as any)
1087+
1088+
// Then it gets an error rather than waiting forever for a reply
1089+
await vi.waitFor(() =>
1090+
expect(mockTransportToClient.send).toHaveBeenCalledWith(
1091+
expect.objectContaining({
1092+
jsonrpc: '2.0',
1093+
id: '7',
1094+
error: expect.objectContaining({ code: -32001 }),
1095+
}),
1096+
),
1097+
)
1098+
})
9821099
})
9831100

9841101
describe('setupOAuthCallbackServerWithLongPoll', () => {

src/lib/utils.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ export function mcpProxy({
139139
}) {
140140
let transportToClientClosed = false
141141
let transportToServerClosed = false
142+
let lastInitialize: Message | null = null
143+
let reinitSeq = 0
144+
const pendingReinit = new Map<string, (message: Message) => void>()
142145

143146
const messageTransformer = createMessageTransformer({
144147
transformRequestFunction: (request: Message) => {
@@ -199,12 +202,23 @@ export function mcpProxy({
199202
log(JSON.stringify(message, null, 2))
200203

201204
debugLog('Initialize message with modified client info', { clientInfo })
205+
206+
lastInitialize = message
202207
}
203208

204-
transportToServer.send(message).catch(onServerError)
209+
sendToServer(message)
205210
}
206211

207212
transportToServer.onmessage = (_message) => {
213+
// Responses to our own re-initialize handshake are ours to consume, not the client's
214+
const reinitId = (_message as any).id
215+
if (typeof reinitId === 'string' && pendingReinit.has(reinitId)) {
216+
const settle = pendingReinit.get(reinitId)!
217+
pendingReinit.delete(reinitId)
218+
settle(_message as any)
219+
return
220+
}
221+
208222
// TODO: fix types
209223
const message = messageTransformer.interceptResponse(_message as any)
210224
log('[Remote→Local]', message.method || message.id)
@@ -250,6 +264,91 @@ export function mcpProxy({
250264
log('Error from remote server:', error)
251265
debugLog('Error from remote server', { stack: error.stack })
252266
}
267+
268+
/**
269+
* A 404 to a request that carried a session id means the server dropped the
270+
* session (idle expiry, restart, eviction). The spec says the client must then
271+
* start a new session with a fresh InitializeRequest.
272+
*/
273+
function isSessionExpired(error: Error) {
274+
return error instanceof StreamableHTTPError && error.code === 404
275+
}
276+
277+
async function reinitializeSession() {
278+
if (!lastInitialize) {
279+
throw new Error('no initialize request was seen, cannot re-establish the session')
280+
}
281+
282+
// Must be cleared before we send, or the transport re-attaches the dead id
283+
// and the server 404s the handshake too. The SDK exposes sessionId read-only.
284+
;(transportToServer as unknown as { _sessionId?: string })._sessionId = undefined
285+
286+
const id = `mcp-remote-reinit-${++reinitSeq}`
287+
const response = await new Promise<Message>((resolve, reject) => {
288+
const timer = setTimeout(() => {
289+
pendingReinit.delete(id)
290+
reject(new Error('timed out waiting for the re-initialize response'))
291+
}, 30000)
292+
pendingReinit.set(id, (message) => {
293+
clearTimeout(timer)
294+
resolve(message)
295+
})
296+
transportToServer.send({ ...lastInitialize, id }).catch((error) => {
297+
clearTimeout(timer)
298+
pendingReinit.delete(id)
299+
reject(error)
300+
})
301+
})
302+
303+
if (response.error) {
304+
throw new Error(`server rejected re-initialize: ${JSON.stringify(response.error)}`)
305+
}
306+
307+
await transportToServer.send({ jsonrpc: '2.0', method: 'notifications/initialized' })
308+
log(`Re-established session ${transportToServer.sessionId ?? '(none)'} after server expiry`)
309+
}
310+
311+
async function sendToServer(message: Message) {
312+
try {
313+
await transportToServer.send(message)
314+
return
315+
} catch (error) {
316+
// Re-initializing in response to a failed initialize would loop
317+
if (!isSessionExpired(error as Error) || message.method === 'initialize') {
318+
onServerError(error as Error)
319+
replyWithError(message, error as Error)
320+
return
321+
}
322+
323+
log('Remote session expired, re-initializing')
324+
debugLog('Remote session expired', { id: message.id, method: message.method })
325+
326+
try {
327+
await reinitializeSession()
328+
await transportToServer.send(message)
329+
} catch (retryError) {
330+
onServerError(retryError as Error)
331+
replyWithError(message, retryError as Error)
332+
}
333+
}
334+
}
335+
336+
/**
337+
* Without this a failed send leaves the client waiting forever on a request
338+
* that will never be answered.
339+
*/
340+
function replyWithError(message: Message, error: Error) {
341+
if (message.id === undefined || message.id === null) {
342+
return
343+
}
344+
transportToClient
345+
.send({
346+
jsonrpc: '2.0',
347+
id: message.id,
348+
error: { code: -32001, message: `mcp-remote: ${error.message ?? String(error)}` },
349+
})
350+
.catch(onClientError)
351+
}
253352
}
254353

255354
/**

0 commit comments

Comments
 (0)