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
20 changes: 19 additions & 1 deletion examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2129,6 +2129,18 @@ app.post('/mcp', async (req, res) => {
await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else if (sessionId) {
// Session ID was provided but no transport matches it — the session
// has been terminated or was never issued. Spec: HTTP 404.
res.status(404).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Session not found'
},
id: null
});
return;
} else {
res.status(400).json({
jsonrpc: '2.0',
Expand Down Expand Up @@ -2188,11 +2200,17 @@ app.get('/mcp', async (req, res) => {
app.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;

if (!sessionId || !transports[sessionId]) {
if (!sessionId) {
res.status(400).send('Invalid or missing session ID');
return;
}

if (!transports[sessionId]) {
// Session has been terminated or was never issued. Spec: HTTP 404.
res.status(404).send('Session not found');
return;
}

console.log(`Received session termination request for session ${sessionId}`);

try {
Expand Down
2 changes: 2 additions & 0 deletions src/scenarios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { MRTRClientScenario } from './client/mrtr-client';

// Import all new server test scenarios
import { ServerInitializeScenario } from './server/lifecycle';
import { SessionLifecycleScenario } from './server/session-lifecycle';
import { ServerStatelessScenario } from './server/stateless';

import {
Expand Down Expand Up @@ -129,6 +130,7 @@ const pendingClientScenariosList: ClientScenario[] = [
const allClientScenariosList: ClientScenario[] = [
// Lifecycle scenarios
new ServerInitializeScenario(),
new SessionLifecycleScenario(),
new ServerStatelessScenario(),

// Utilities scenarios
Expand Down
121 changes: 121 additions & 0 deletions src/scenarios/server/session-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { SessionLifecycleScenario } from './session-lifecycle';

describe('SessionLifecycleScenario', () => {
const serverUrl = 'http://localhost:3000/mcp';
const fetchMock = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('fetch', fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('emits INFO and skips the lifecycle checks when the server is stateless', async () => {
fetchMock.mockResolvedValueOnce(new Response(null));

const checks = await new SessionLifecycleScenario().run(serverUrl);

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(checks).toHaveLength(1);
expect(checks[0]).toMatchObject({
id: 'server-session-lifecycle-skipped',
status: 'INFO'
});
});

it('reports SUCCESS for both checks on the happy path (DELETE 200, then POST 404)', async () => {
fetchMock
// initialize
.mockResolvedValueOnce(
new Response(null, {
headers: { 'mcp-session-id': 'session-abc' }
})
)
// notifications/initialized
.mockResolvedValueOnce(new Response(null, { status: 202 }))
// DELETE
.mockResolvedValueOnce(new Response(null, { status: 200 }))
// POST after termination
.mockResolvedValueOnce(new Response(null, { status: 404 }));

const checks = await new SessionLifecycleScenario().run(serverUrl);

const deleteCall = fetchMock.mock.calls[2];
expect(deleteCall?.[0]).toBe(serverUrl);
expect((deleteCall?.[1] as RequestInit).method).toBe('DELETE');
expect((deleteCall?.[1] as RequestInit).headers).toMatchObject({
'mcp-session-id': 'session-abc'
});

const postAfterDelete = fetchMock.mock.calls[3];
expect((postAfterDelete?.[1] as RequestInit).method).toBe('POST');
expect((postAfterDelete?.[1] as RequestInit).headers).toMatchObject({
'mcp-session-id': 'session-abc'
});

expect(checks).toHaveLength(2);
expect(checks[0]).toMatchObject({
id: 'server-session-delete-accepted',
status: 'SUCCESS',
details: { statusCode: 200 }
});
expect(checks[1]).toMatchObject({
id: 'server-session-terminated-returns-404',
status: 'SUCCESS',
details: { statusCode: 404 }
});
});

it('marks both checks as SKIPPED when the server returns 405 on DELETE', async () => {
fetchMock
.mockResolvedValueOnce(
new Response(null, {
headers: { 'mcp-session-id': 'session-no-delete' }
})
)
.mockResolvedValueOnce(new Response(null, { status: 202 }))
.mockResolvedValueOnce(new Response(null, { status: 405 }));

const checks = await new SessionLifecycleScenario().run(serverUrl);

// Should NOT POST again after a 405 — the 404 check is meaningless then.
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(checks).toHaveLength(2);
expect(checks[0]).toMatchObject({
id: 'server-session-delete-accepted',
status: 'SKIPPED',
details: { statusCode: 405 }
});
expect(checks[1]).toMatchObject({
id: 'server-session-terminated-returns-404',
status: 'SKIPPED'
});
});

it('reports FAILURE on the terminated-returns-404 check when the server returns 200 after DELETE', async () => {
fetchMock
.mockResolvedValueOnce(
new Response(null, {
headers: { 'mcp-session-id': 'session-buggy' }
})
)
.mockResolvedValueOnce(new Response(null, { status: 202 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));

const checks = await new SessionLifecycleScenario().run(serverUrl);

expect(checks[0]).toMatchObject({
id: 'server-session-delete-accepted',
status: 'SUCCESS'
});
expect(checks[1]).toMatchObject({
id: 'server-session-terminated-returns-404',
status: 'FAILURE',
details: { statusCode: 200 }
});
});
});
Loading
Loading