Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,71 @@ registerScenarios(['initialize', 'tools-call'], runBasicClient);
// correct behavior here.
registerScenario('json-schema-ref-no-deref', runBasicClient);

// ============================================================================
// session-renegotiation-404 scenario
// ============================================================================

/**
* The mock server terminates the session mid-trajectory by returning HTTP 404
* to the first session-bearing request. Per the Streamable HTTP spec, when a
* client receives 404 for a request carrying an MCP-Session-Id it MUST start a
* new session by sending a new InitializeRequest without a session ID, then
* continue operating.
*
* The TypeScript SDK transport surfaces the 404 as a StreamableHTTPError rather
* than auto-reconnecting (renegotiation is intentionally a consumer decision),
* so this reference client implements it: drop the transport that holds the
* stale session ID, build a fresh one (no session ID attached), reconnect —
* which sends a clean InitializeRequest — and retry the operation.
*/
async function runSessionRenegotiationClient(serverUrl: string): Promise<void> {
const url = new URL(serverUrl);

const connect = async (): Promise<{
client: Client;
transport: StreamableHTTPClientTransport;
}> => {
const client = new Client(
{ name: 'session-renegotiation-test-client', version: '1.0.0' },
{ capabilities: {} }
);
// A fresh transport carries no session ID, so connect() sends a clean
// InitializeRequest without an MCP-Session-Id header.
const transport = new StreamableHTTPClientTransport(url);
await client.connect(transport);
return { client, transport };
};

const isSessionLost404 = (error: unknown): boolean => {
const code = (error as { code?: unknown } | undefined)?.code;
return code === 404;
};

let { client, transport } = await connect();
logger.debug('Connected; performing first session-bearing request');

try {
await client.listTools();
} catch (error) {
if (!isSessionLost404(error)) {
throw error;
}
// Session was terminated by the server. Renegotiate: a brand-new session.
logger.debug(
'Received HTTP 404 for session-bearing request; renegotiating'
);
await transport.close();
({ client, transport } = await connect());
// Continue operating under the new session.
await client.listTools();
logger.debug('Renegotiated session and continued operating');
}

await transport.close();
}

registerScenario('session-renegotiation-404', runSessionRenegotiationClient);

// ============================================================================
// request-metadata scenario (SEP-2575)
// ============================================================================
Expand Down
101 changes: 101 additions & 0 deletions src/scenarios/client/session-renegotiation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, test, expect } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import {
runClientAgainstScenario,
InlineClientRunner
} from './auth/test_helpers/testClient';
import { SessionRenegotiation404Scenario } from './session-renegotiation';
import { getHandler } from '../../../examples/clients/typescript/everything-client';
import { getScenario } from '../index';

/**
* Streamable HTTP: when a client receives HTTP 404 for a request carrying an
* MCP-Session-Id, it MUST start a new session with a fresh InitializeRequest
* (no session ID) and continue operating. See issue #76.
*
* Positive: the everything-client renegotiates and resumes operating.
* Negative: a client that treats the 404 as fatal (never re-initializes) must
* fail client-session-renegotiate-on-404.
*/

const SCENARIO = 'session-renegotiation-404';

describe('session-renegotiation-404 client scenario', () => {
test('scenario is registered', () => {
expect(getScenario(SCENARIO)).toBeDefined();
});

test('everything-client renegotiates on 404 and keeps operating', async () => {
const clientFn = getHandler(SCENARIO);
if (!clientFn) {
throw new Error(`No handler registered for scenario: ${SCENARIO}`);
}

const scenario = getScenario(SCENARIO);
if (!scenario) {
throw new Error(`Scenario not found: ${SCENARIO}`);
}

await runClientAgainstScenario(new InlineClientRunner(clientFn), SCENARIO);

const checks = scenario.getChecks();
for (const check of checks) {
expect(
check.status,
`Check "${check.id}" failed: ${check.errorMessage ?? ''}`
).toBe('SUCCESS');
}

expect(
checks.find((c) => c.id === 'client-session-renegotiate-on-404')?.status
).toBe('SUCCESS');
expect(
checks.find(
(c) => c.id === 'client-session-continues-after-renegotiation'
)?.status
).toBe('SUCCESS');
});

// A client that initializes, makes one request, gets the 404, and gives up
// without re-initializing. This is the "bricked trajectory" behavior the
// requirement forbids.
async function nonRenegotiatingClient(serverUrl: string): Promise<void> {
const client = new Client(
{ name: 'no-reconnect-client', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
// Triggers the 404; the SDK transport throws and we deliberately do not
// renegotiate — surfacing the failure the scenario must catch.
await client.listTools();
await transport.close();
}

test('client that does not renegotiate fails the 404 renegotiation check', async () => {
await runClientAgainstScenario(
new InlineClientRunner(nonRenegotiatingClient),
SCENARIO,
{ expectedFailureSlugs: ['client-session-renegotiate-on-404'] }
);
});

test('emits a FAILURE when the client never makes a session-bearing request', async () => {
const scenario = new SessionRenegotiation404Scenario();
await scenario.start();
try {
const checks = scenario.getChecks();
const renegotiate = checks.find(
(c) => c.id === 'client-session-renegotiate-on-404'
);
expect(renegotiate?.status).toBe('FAILURE');
const continues = checks.find(
(c) => c.id === 'client-session-continues-after-renegotiation'
);
expect(continues?.status).toBe('SKIPPED');
} finally {
await scenario.stop();
}
});
});
Loading