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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist/
.idea/
.claude/settings.local.json
.sdk-under-test/
.sync-schema-tmp/
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@
# repo's `prettier --check .` would reformat the file and fight the generator's
# output (and the refresh workflow's `git diff` check).
src/seps/traceability.json

# Vendored verbatim from modelcontextprotocol/schema/{version}/schema.ts via
# `npm run sync-schema`. Keep byte-identical with upstream so the SOURCE pin
# is meaningful and re-syncing produces a clean diff.
src/spec-types/*.ts
126 changes: 126 additions & 0 deletions examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import {
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import {
ElicitResultSchema,
ResultSchema,
ProgressNotificationSchema,
LoggingMessageNotificationSchema,
ListToolsRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
Expand All @@ -30,6 +33,8 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import cors from 'cors';
import { randomUUID, createHmac } from 'crypto';

Expand Down Expand Up @@ -72,6 +77,49 @@ function getMrtInputText(inputResponse: unknown, field: string): string {
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
const servers: { [sessionId: string]: McpServer } = {};

// In-memory client connected to a fully-registered McpServer. Used by the
// stateless POST handler to serve carry-forward methods (tools/call,
// resources/*, prompts/get, completion/complete) without duplicating the
// registrations. The SDK doesn't yet support a stateless server natively,
// so this bridges via the in-memory transport after a one-time initialize.
//
// A fresh server+client pair is built per request so concurrent requests
// can't observe each other's notifications.
type DispatchClient = {
client: Client;
drainNotifications: () => unknown[];
close: () => Promise<void>;
};
async function getStatelessDispatchClient(): Promise<DispatchClient> {
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
const server = createMcpServer();
await server.connect(serverT);
const client = new Client(
{ name: 'stateless-dispatch', version: '1.0.0' },
{ capabilities: { sampling: {}, elicitation: {} } }
);
await client.connect(clientT);

// Buffer notifications so the stateless handler can flush them to the SSE
// response after the request completes. The SDK pre-registers a handler for
// notifications/progress so a fallback alone would miss it.
const buffer: unknown[] = [];
const collect = async (n: unknown) =>
void buffer.push({ jsonrpc: '2.0', ...(n as object) });
client.setNotificationHandler(ProgressNotificationSchema, collect);
client.setNotificationHandler(LoggingMessageNotificationSchema, collect);
client.fallbackNotificationHandler = collect;

return {
client,
drainNotifications: () => buffer.splice(0, buffer.length),
close: async () => {
await client.close();
await server.close();
}
};
}

// In-memory event store for SEP-1699 resumability
const eventStoreData = new Map<
string,
Expand Down Expand Up @@ -1304,11 +1352,19 @@ app.post('/mcp', async (req, res) => {
}

if (method === 'tools/list') {
const dispatch = await getStatelessDispatchClient();
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: the other changes in everything-server here sit inside the if (!session && (reqVersion || meta)) { ... } block which only applies in stateless mode.

A stateful request falls straight through to transport.handleRequest() unchanged.

I think it would be nice to refactor everything-server to have just 2 paths though, handleStateful and handleStateless, but didn't want to mix that in here.

const fromServer = (await dispatch.client.request(
{ method: 'tools/list', params: {} },
ResultSchema as any
)) as { tools: any[]; [k: string]: unknown };
await dispatch.close();
return res.json({
jsonrpc: '2.0',
id,
result: {
...fromServer,
tools: [
...fromServer.tools,
{
name: 'test_missing_capability',
description: 'Test tool requiring sampling',
Expand Down Expand Up @@ -1378,11 +1434,19 @@ app.post('/mcp', async (req, res) => {

// Mock fallbacks to answer prompts capability matches safely
if (method === 'prompts/list') {
const dispatch = await getStatelessDispatchClient();
const fromServer = (await dispatch.client.request(
{ method: 'prompts/list', params: {} },
ResultSchema as any
)) as { prompts: any[]; [k: string]: unknown };
await dispatch.close();
return res.json({
jsonrpc: '2.0',
id,
result: {
...fromServer,
prompts: [
...fromServer.prompts,
{
name: 'test_input_required_result_prompt',
description: 'MRTR: prompt that requires elicitation input'
Expand Down Expand Up @@ -1994,6 +2058,68 @@ app.post('/mcp', async (req, res) => {
}
}

// Carry-forward methods that fell through the MRTR-specific handlers above
// (tools/call for non-MRTR tools, resources/*, prompts/get for non-MRTR
// prompts, completion/complete) are dispatched to the same McpServer the
// stateful path uses, via an in-memory client. This avoids duplicating the
// tool/resource/prompt registrations for the stateless path.
//
// tools/call is served as text/event-stream so progress and logging
// notifications from the underlying tool reach the conformance client.
if (method === 'tools/call') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
});
const write = (msg: unknown) =>
res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);
const dispatch = await getStatelessDispatchClient();
try {
const result = await dispatch.client.request(
{ method, params },
ResultSchema as any
);
for (const n of dispatch.drainNotifications()) write(n);
write({ jsonrpc: '2.0', id, result });
} catch (e: any) {
for (const n of dispatch.drainNotifications()) write(n);
write({
jsonrpc: '2.0',
id,
error: { code: e.code ?? -32603, message: e.message, data: e.data }
});
} finally {
await dispatch.close();
}
return res.end();
}
if (
[
'resources/list',
'resources/read',
'resources/templates/list',
'prompts/get',
'completion/complete'
].includes(method)
) {
const dispatch = await getStatelessDispatchClient();
try {
const result = await dispatch.client.request(
{ method, params },
ResultSchema as any
);
return res.json({ jsonrpc: '2.0', id, result });
} catch (e: any) {
return res.json({
jsonrpc: '2.0',
id,
error: { code: e.code ?? -32603, message: e.message, data: e.data }
});
} finally {
await dispatch.close();
}
}

// Removed Methods per SEP-2575 (Changed status from 200 to 400/404 per Transport Spec)
if (
[
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"lint:fix_check": "npm run lint:fix && git diff --exit-code --quiet",
"tier-check": "node dist/index.js tier-check",
"traceability": "tsx src/index.ts traceability",
"sync-schema": "tsx scripts/sync-schema.ts",
"check": "npm run typecheck && npm run lint",
"typecheck": "tsgo --noEmit",
"prepack": "npm run build",
Expand Down
52 changes: 52 additions & 0 deletions scripts/sync-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env -S npx tsx
/**
* Vendor schema/{version}/schema.ts from the modelcontextprotocol spec repo
* into src/spec-types/{version}.ts at a pinned SHA.
*
* Usage: npm run sync-schema -- <sha-or-ref>
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, writeFileSync, rmSync, copyFileSync } from 'node:fs';
import { join } from 'node:path';

const VERSIONS = ['2025-03-26', '2025-06-18', '2025-11-25', 'draft'] as const;
const SPEC_REPO =
'https://github.com/modelcontextprotocol/modelcontextprotocol.git';
const OUT_DIR = join(process.cwd(), 'src', 'spec-types');

const ref = process.argv[2];
if (!ref) {
console.error('Usage: npm run sync-schema -- <sha-or-ref>');
process.exit(1);
}

const tmp = join(process.cwd(), '.sync-schema-tmp');
rmSync(tmp, { recursive: true, force: true });
mkdirSync(tmp, { recursive: true });
mkdirSync(OUT_DIR, { recursive: true });

const git = (args: string[]) =>
execFileSync('git', args, { cwd: tmp, encoding: 'utf8' });

try {
console.log(`Fetching ${SPEC_REPO} @ ${ref} ...`);
git(['init', '-q']);
git(['remote', 'add', 'origin', SPEC_REPO]);
git(['fetch', '-q', '--depth', '1', 'origin', ref]);
git(['checkout', '-q', 'FETCH_HEAD']);
const sha = git(['rev-parse', 'HEAD']).trim();

for (const v of VERSIONS) {
copyFileSync(join(tmp, 'schema', v, 'schema.ts'), join(OUT_DIR, `${v}.ts`));
console.log(` ${v} -> src/spec-types/${v}.ts`);
}

writeFileSync(
join(OUT_DIR, 'SOURCE'),
`modelcontextprotocol@${sha}\n`,
'utf8'
);
console.log(`Pinned: modelcontextprotocol@${sha}`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
125 changes: 125 additions & 0 deletions src/connection/connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { connectFor } from './select';
import { connectStateful } from './stateful';
import { connectStateless } from './stateless';
import { JsonRpcError } from './index';

describe('connectFor', () => {
it('returns stateful for dated 2025-x versions', () => {
expect(connectFor('2025-03-26')).toBe(connectStateful);
expect(connectFor('2025-06-18')).toBe(connectStateful);
expect(connectFor('2025-11-25')).toBe(connectStateful);
});
it('returns stateless for the draft version', () => {
expect(connectFor('DRAFT-2026-v1')).toBe(connectStateless);
});
});

describe('connectStateless', () => {
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
afterEach(() => mockFetch.mockReset());

function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
});
}

function sseResponse(events: string[]) {
return new Response(events.join(''), {
status: 200,
headers: { 'content-type': 'text/event-stream' }
});
}

it('injects required _meta keys and MCP-Protocol-Version header', async () => {
mockFetch.mockResolvedValue(
jsonResponse({ jsonrpc: '2.0', id: 1, result: { ok: true } })
);
const conn = await connectStateless('http://test/mcp');
await conn.request('tools/list');

const [, init] = mockFetch.mock.calls[0];
expect(init.headers['MCP-Protocol-Version']).toBe('DRAFT-2026-v1');
const sent = JSON.parse(init.body);
expect(sent.params._meta['io.modelcontextprotocol/protocolVersion']).toBe(
'DRAFT-2026-v1'
);
expect(
sent.params._meta['io.modelcontextprotocol/clientInfo']
).toBeDefined();
expect(
sent.params._meta['io.modelcontextprotocol/clientCapabilities']
).toBeDefined();
});

it('throws JsonRpcError on JSON-RPC error responses', async () => {
mockFetch.mockResolvedValue(
jsonResponse({
jsonrpc: '2.0',
id: 1,
error: { code: -32601, message: 'Method not found' }
})
);
const conn = await connectStateless('http://test/mcp');
await expect(conn.request('nope')).rejects.toSatisfy(
(e) => e instanceof JsonRpcError && e.code === -32601
);
});

it('throws on non-2xx JSON without a JSON-RPC error envelope', async () => {
mockFetch.mockResolvedValue(
jsonResponse({ detail: 'gateway rejected' }, 502)
);
const conn = await connectStateless('http://test/mcp');
await expect(conn.request('tools/list')).rejects.toThrow(/HTTP 502/);
});

it('throws a useful error for non-JSON non-SSE responses', async () => {
mockFetch.mockResolvedValue(
new Response('<html>500</html>', {
status: 500,
headers: { 'content-type': 'text/html' }
})
);
const conn = await connectStateless('http://test/mcp');
await expect(conn.request('tools/list')).rejects.toThrow(/HTTP 500/);
});

it('parses SSE: collects notifications and returns final result (LF)', async () => {
mockFetch.mockResolvedValue(
sseResponse([
'event: message\ndata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":1}}\n\n',
'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"done":true}}\n\n'
])
);
const conn = await connectStateless('http://test/mcp');
const result = await conn.request<{ done: boolean }>('tools/call', {});
expect(result.done).toBe(true);
expect(conn.notifications).toHaveLength(1);
expect(conn.notifications[0].method).toBe('notifications/progress');
});

it('parses SSE with CRLF line endings', async () => {
mockFetch.mockResolvedValue(
sseResponse([
'event: message\r\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\r\n\r\n'
])
);
const conn = await connectStateless('http://test/mcp');
const result = await conn.request<{ ok: boolean }>('tools/call', {});
expect(result.ok).toBe(true);
});

it('rejects server-to-client requests on the SSE stream', async () => {
mockFetch.mockResolvedValue(
sseResponse([
'event: message\ndata: {"jsonrpc":"2.0","id":99,"method":"elicitation/create","params":{}}\n\n'
])
);
const conn = await connectStateless('http://test/mcp');
await expect(conn.request('tools/call', {})).rejects.toThrow(/MRTR/);
});
});
Loading
Loading