Skip to content
Merged
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
23 changes: 1 addition & 22 deletions src/connection/connection.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,9 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
connectFor,
isStatefulVersion,
STATELESS_SPEC_VERSIONS
} from './select';
import { connectStateful } from './stateful';
import { isStatefulVersion, STATELESS_SPEC_VERSIONS } from './select';
import { connectStateless } from './stateless';
import { JsonRpcError } from './index';
import { DRAFT_PROTOCOL_VERSION } from '../types';

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', () => {
// connectFor wraps connectStateless in a closure (to pass the spec
// version through), so identity with connectStateless no longer holds;
// assert it did not select the stateful implementation. The wire-level
// behaviour of the wrapper is covered in stateless.test.ts.
expect(connectFor('DRAFT-2026-v1')).not.toBe(connectStateful);
expect(connectFor('DRAFT-2026-v1')).not.toBe(connectStateless);
});
});

describe('STATELESS_SPEC_VERSIONS', () => {
it('contains exactly the versions isStatefulVersion rejects', () => {
expect(STATELESS_SPEC_VERSIONS.length).toBeGreaterThan(0);
Expand Down
51 changes: 40 additions & 11 deletions src/connection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,37 @@
import type { SpecVersion } from '../types';
import type { JSONRPCNotification } from '../spec-types/2025-11-25';

/**
* Options accepted at session bootstrap. On the stateful (2025-x) wire
* these flow into the `initialize` request params; on the stateless
* (2026-x) wire they live in `_meta.io.modelcontextprotocol/*` on the
* `server/discover` request.
*/
export interface ConnectOptions {
/**
* Capabilities declared during session bootstrap (e.g.
* `{ extensions: { 'io.modelcontextprotocol/tasks': {} }, elicitation: {} }`).
*/
capabilities?: Record<string, unknown>;
/** Client info advertised at bootstrap; defaults to the harness's own info. */
clientInfo?: { name: string; version: string };
}

export interface Connection {
/**
* Send a JSON-RPC request and return its result.
* Throws `JsonRpcError` on JSON-RPC error responses.
*
* `extraHeaders` extend or override the standard headers
* (Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name)
* for this call only — used by SEP-2243 routing-header tests that
* inject a mismatch. Honored on the stateless wire; ignored with a
* warning on the stateful wire (the SDK transport manages headers).
*/
request<R = unknown>(
method: string,
params?: Record<string, unknown>
params?: Record<string, unknown>,
extraHeaders?: Record<string, string>
): Promise<R>;

/**
Expand All @@ -33,6 +56,20 @@ export interface Connection {
*/
readonly notifications: JSONRPCNotification[];

/**
* Return the server's advertised capabilities, serverInfo, and
* instructions. On the stateful wire this is synthesized from the
* SDK Client's post-`initialize` accessors and resolves immediately;
* on the stateless wire this issues `server/discover` (SEP-2575's
* equivalent of the missing handshake) on first call and memoizes
* the result.
*
* Scenarios that don't inspect server-side state never call this —
* SEP-2575 has no required handshake, so paying for the extra request
* is opt-in.
*/
discover(): Promise<Record<string, unknown>>;

close(): Promise<void>;
}

Expand All @@ -48,15 +85,7 @@ export interface RunContext {
* Scenarios that test the connection mechanics themselves (initialize,
* GET-SSE, DNS rebinding) bypass this and use raw fetch.
*/
connect(): Promise<Connection>;
/**
* Wire mode override for suites that exercise both the legacy and
* SEP-2575 stateless wires against a single spec version (notably
* SEP-2663 tasks and SEP-2322 MRTR). When absent the wire is implied
* by `specVersion` — `2025-x` ⇒ legacy, draft ⇒ stateless. Scenarios
* outside the tasks/mrtr suites ignore this knob.
*/
wire?: 'legacy' | 'stateless';
connect(opts?: ConnectOptions): Promise<Connection>;
}

export class JsonRpcError extends Error {
Expand All @@ -83,4 +112,4 @@ export {
type JsonRpcResponse,
type StatelessResponse
} from './stateless';
export { connectFor } from './select';
export { connectFor, isStateless } from './select';
36 changes: 21 additions & 15 deletions src/connection/sdk-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,36 @@ import {
ProgressNotificationSchema
} from '@modelcontextprotocol/sdk/types.js';

import type { ConnectOptions } from './index';

const DEFAULT_CLIENT_INFO = {
name: 'conformance-test-client',
version: '1.0.0'
} as const;

const DEFAULT_CAPABILITIES = {
sampling: {},
elicitation: {}
} as const;

export interface MCPClientConnection {
client: Client;
close: () => Promise<void>;
}

/**
* Create and connect an MCP client to a server
* Create and connect an MCP client to a server. `opts.capabilities` and
* `opts.clientInfo` override the harness defaults — scenarios that
* negotiate extensions (tasks, EMA, ...) pass them through to drive a
* conformant `initialize`.
*/
export async function connectToServer(
serverUrl: string
serverUrl: string,
opts: ConnectOptions = {}
): Promise<MCPClientConnection> {
const client = new Client(
{
name: 'conformance-test-client',
version: '1.0.0'
},
{
capabilities: {
// Client capabilities
sampling: {},
elicitation: {}
}
}
);
const client = new Client(opts.clientInfo ?? DEFAULT_CLIENT_INFO, {
capabilities: opts.capabilities ?? DEFAULT_CAPABILITIES
});

const transport = new StreamableHTTPClientTransport(new URL(serverUrl));

Expand Down
24 changes: 19 additions & 5 deletions src/connection/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import {
DRAFT_PROTOCOL_VERSION,
type SpecVersion
} from '../types';
import type { Connection } from './index';
import type { Connection, ConnectOptions, RunContext } from './index';
import { connectStateful } from './stateful';
import { connectStateless } from './stateless';

/**
* Spec versions that use the stateful lifecycle (initialize handshake,
* Mcp-Session-Id). Anything not in this list uses the stateless lifecycle.
* Mcp-Session-Id). Anything not in this list uses the stateless lifecycle
* — SEP-2575 (Accepted) removed the initialize handshake on DRAFT-2026-v1
* and later.
*/
const STATEFUL_VERSIONS: ReadonlySet<string> = new Set([
'2024-11-05',
Expand Down Expand Up @@ -40,10 +42,22 @@ export const STATELESS_SPEC_VERSIONS: readonly SpecVersion[] =

export function connectFor(
specVersion: SpecVersion
): (serverUrl: string) => Promise<Connection> {
): (serverUrl: string, opts?: ConnectOptions) => Promise<Connection> {
return isStatefulVersion(specVersion)
? connectStateful
? (serverUrl, opts) => connectStateful(serverUrl, opts)
: // Pass the version through so stateless requests declare the spec
// version the run was invoked with (matters under --force).
(serverUrl) => connectStateless(serverUrl, specVersion);
(serverUrl, opts) => connectStateless(serverUrl, specVersion, opts);
}

/**
* True when the spec version on the context requires the SEP-2575
* stateless wire (no initialize handshake; per-request `_meta` envelope).
*
* Mirrors `connectFor` so scenarios that drive the wire directly (not via
* the SDK-wrapped Connection) pick the wire the same way the connection
* factory does.
*/
export function isStateless(ctx: Pick<RunContext, 'specVersion'>): boolean {
return !isStatefulVersion(ctx.specVersion);
}
31 changes: 27 additions & 4 deletions src/connection/stateful.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import { connectToServer } from './sdk-client';
import type { JSONRPCNotification } from '../spec-types/2025-11-25';
import { JsonRpcError, type Connection } from './index';
import { JsonRpcError, type Connection, type ConnectOptions } from './index';

export async function connectStateful(serverUrl: string): Promise<Connection> {
const { client, close } = await connectToServer(serverUrl);
export async function connectStateful(
serverUrl: string,
opts: ConnectOptions = {}
): Promise<Connection> {
const { client, close } = await connectToServer(serverUrl, opts);

const notifications: JSONRPCNotification[] = [];
const collect = (n: unknown) => {
Expand All @@ -45,10 +48,30 @@ export async function connectStateful(serverUrl: string): Promise<Connection> {
return {
notifications,

// Synthesize the discover-shape from the SDK Client's post-`initialize`
// accessors so the stateful Connection exposes the same surface the
// stateless wire's `server/discover` produces.
async discover(): Promise<Record<string, unknown>> {
return {
capabilities: client.getServerCapabilities() ?? {},
serverInfo: client.getServerVersion() ?? {},
instructions: client.getInstructions()
};
},

async request<R>(
method: string,
params: Record<string, unknown> = {}
params: Record<string, unknown> = {},
extraHeaders?: Record<string, string>
): Promise<R> {
if (extraHeaders && Object.keys(extraHeaders).length > 0) {
// The SDK Client transport manages headers internally; per-call
// override would require dropping to raw fetch. No 2025-x
// scenario needs this today; flag loudly if one shows up.
throw new Error(
'connectStateful.request: extraHeaders is unsupported on the stateful wire (per-call header overrides require raw fetch on the stateless wire only)'
);
}
try {
return (await client.request({ method, params }, ResultSchema)) as R;
} catch (e) {
Expand Down
2 changes: 1 addition & 1 deletion src/connection/stateless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe('spec version plumbing', () => {
});

test('withRequestMeta declares the requested spec version in _meta', () => {
const params = withRequestMeta({}, '2025-11-25');
const params = withRequestMeta({}, { specVersion: '2025-11-25' });
const meta = params._meta as Record<string, unknown>;
expect(meta['io.modelcontextprotocol/protocolVersion']).toBe('2025-11-25');
});
Expand Down
Loading
Loading