Skip to content

Commit c660a23

Browse files
[SEP-2575] core: StreamDriver + stdio/InMemory sendAndReceive
NEW core/shared/streamDriver.ts: minimal request->response correlator for pipe-shaped client transports (one _pending map keyed by RequestId AND String(id) for _meta.subscriptionId routing). sendAndReceive yields notifications then one response (or indefinite for subscriptions/listen); break/return() sends notifications/cancelled. Send-failure -> synthetic error response (no hang). onMessage routes; close() ends all pending. InMemoryTransport: composes StreamDriver. send() routes via _receive() (driver claims first, falls through to onmessage). close() closes driver. StdioClientTransport: composes StreamDriver. processReadBuffer routes to driver when protocolVersion is unset or stateless. setProtocolVersion() gates back to onmessage for legacy. Satisfies: 2575-R12 (client sendAndReceive contract, pipe transports)
1 parent af76de4 commit c660a23

5 files changed

Lines changed: 325 additions & 5 deletions

File tree

packages/client/src/client/stdio.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import process from 'node:process';
33
import type { Stream } from 'node:stream';
44
import { PassThrough } from 'node:stream';
55

6-
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
7-
import { ReadBuffer, SdkError, SdkErrorCode, serializeMessage } from '@modelcontextprotocol/core';
6+
import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core';
7+
import { isStatelessProtocolVersion, ReadBuffer, SdkError, SdkErrorCode, serializeMessage, StreamDriver } from '@modelcontextprotocol/core';
88
import spawn from 'cross-spawn';
99

1010
export type StdioServerParameters = {
@@ -95,11 +95,26 @@ export class StdioClientTransport implements Transport {
9595
private _readBuffer: ReadBuffer = new ReadBuffer();
9696
private _serverParams: StdioServerParameters;
9797
private _stderrStream: PassThrough | null = null;
98+
private _protocolVersion?: string;
99+
/* eslint-disable-next-line unicorn/consistent-function-scoping */
100+
private readonly _driver = new StreamDriver(m => this.send(m));
98101

99102
onclose?: () => void;
100103
onerror?: (error: Error) => void;
101104
onmessage?: (message: JSONRPCMessage) => void;
102105

106+
/**
107+
* Sends one request and returns the server's messages for it. Backed by
108+
* `StreamDriver`; bypasses `Protocol.request()`.
109+
*/
110+
sendAndReceive(request: Omit<JSONRPCRequest, 'jsonrpc' | 'id'>): AsyncIterable<JSONRPCMessage> {
111+
return this._driver.sendAndReceive(request);
112+
}
113+
114+
setProtocolVersion(version: string): void {
115+
this._protocolVersion = version;
116+
}
117+
103118
constructor(server: StdioServerParameters) {
104119
this._serverParams = server;
105120
if (server.stderr === 'pipe' || server.stderr === 'overlapped') {
@@ -195,6 +210,16 @@ export class StdioClientTransport implements Transport {
195210
break;
196211
}
197212

213+
// Default to StreamDriver until setProtocolVersion is called with
214+
// a pre-2026 version. The discover/initialize probe goes via
215+
// sendAndReceive, so the driver claims those.
216+
if (
217+
(this._protocolVersion === undefined || isStatelessProtocolVersion(this._protocolVersion)) &&
218+
this._driver.onMessage(message)
219+
) {
220+
continue;
221+
}
222+
198223
this.onmessage?.(message);
199224
} catch (error) {
200225
this.onerror?.(error as Error);
@@ -203,6 +228,7 @@ export class StdioClientTransport implements Transport {
203228
}
204229

205230
async close(): Promise<void> {
231+
this._driver.close();
206232
if (this._process) {
207233
const processToClose = this._process;
208234
this._process = undefined;

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './shared/metadataUtils.js';
77
export * from './shared/protocol.js';
88
export * from './shared/stateless.js';
99
export * from './shared/stdio.js';
10+
export * from './shared/streamDriver.js';
1011
export * from './shared/toolNameValidation.js';
1112
export * from './shared/transport.js';
1213
export * from './shared/uriTemplate.js';
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { isJSONRPCNotification, isJSONRPCResponse } from '../types/guards.js';
2+
import type { JSONRPCMessage, JSONRPCRequest, RequestId } from '../types/index.js';
3+
import { JSONRPC_VERSION, ProtocolErrorCode } from '../types/index.js';
4+
import { AsyncQueue } from '../util/asyncQueue.js';
5+
import { META_KEYS } from './stateless.js';
6+
7+
/**
8+
* Minimal request→response correlator for pipe-shaped client transports
9+
* (stdio, in-memory) under the 2026-06 stateless model. Provides
10+
* `sendAndReceive(request) → AsyncIterable<JSONRPCMessage>` so the Client can
11+
* make stateless calls without going through `Protocol.request()` and its
12+
* `_responseHandlers` map.
13+
*
14+
* The transport feeds every inbound message to {@linkcode onMessage}; the
15+
* driver routes responses by `id` and notifications by `_meta.subscriptionId`
16+
* (which is the originating request's id, per SEP-2575) to the matching
17+
* iterator. Closing/breaking the iterator sends `notifications/cancelled`.
18+
*/
19+
export class StreamDriver {
20+
// Seed in a range Protocol's `_requestMessageId` (which starts at 0) will
21+
// not reach, so a stdio fallback that mixes a discover-probe (StreamDriver)
22+
// with a legacy initialize (Protocol.request) on the same pipe cannot
23+
// collide on id 0.
24+
private _nextId = 0x40_00_00_00;
25+
private readonly _pending = new Map<RequestId, AsyncQueue<JSONRPCMessage>>();
26+
27+
constructor(private readonly _send: (m: JSONRPCMessage) => Promise<void>) {}
28+
29+
/**
30+
* Sends one request and returns an async-iterable of the messages the server
31+
* emits for it: zero or more notifications, then exactly one response (which
32+
* ends the iteration). For `subscriptions/listen`, the iteration continues
33+
* until `break`/`return()` (which sends `notifications/cancelled`).
34+
*
35+
* The request is dispatched and registered in `_pending` immediately, before
36+
* the first `next()`. Callers MUST consume the iterable (`for await` is
37+
* sufficient: it calls `return()` on break/throw); obtaining it and never
38+
* iterating leaks the `_pending` entry until {@linkcode close}.
39+
*/
40+
sendAndReceive(request: Omit<JSONRPCRequest, 'jsonrpc' | 'id'>, opts?: { signal?: AbortSignal }): AsyncIterable<JSONRPCMessage> {
41+
const id = this._nextId++;
42+
const isListen = request.method === 'subscriptions/listen';
43+
const queue = new AsyncQueue<JSONRPCMessage>(256);
44+
45+
const cleanup = () => {
46+
this._pending.delete(id);
47+
this._pending.delete(String(id));
48+
opts?.signal?.removeEventListener('abort', onAbort);
49+
};
50+
const cancel = () => {
51+
if (queue.closed) return;
52+
this._send({ jsonrpc: JSONRPC_VERSION, method: 'notifications/cancelled', params: { requestId: id } }).catch(() => {});
53+
queue.close();
54+
cleanup();
55+
};
56+
const onAbort = () => cancel();
57+
opts?.signal?.addEventListener('abort', onAbort, { once: true });
58+
59+
this._pending.set(id, queue);
60+
// `_meta.subscriptionId` on inbound notifications equals the string form
61+
// of the request id (SEP-2575). Register the same queue under that key
62+
// so {@linkcode onMessage} can route notifications without a second map.
63+
this._pending.set(String(id), queue);
64+
65+
this._send({ jsonrpc: JSONRPC_VERSION, id, ...request }).catch(error => {
66+
// Surface send failure to the iterator instead of hanging forever.
67+
queue.push({
68+
jsonrpc: JSONRPC_VERSION,
69+
id,
70+
error: {
71+
code: ProtocolErrorCode.InternalError,
72+
message: `Transport send failed: ${error instanceof Error ? error.message : String(error)}`
73+
}
74+
});
75+
queue.close();
76+
cleanup();
77+
});
78+
79+
const inner = queue.iterate();
80+
return {
81+
[Symbol.asyncIterator]: () => ({
82+
async next(): Promise<IteratorResult<JSONRPCMessage>> {
83+
const r = await inner.next();
84+
if (r.done) {
85+
cleanup();
86+
} else if (!isListen && isJSONRPCResponse(r.value)) {
87+
// Non-listen: end after the response.
88+
queue.close();
89+
cleanup();
90+
}
91+
return r;
92+
},
93+
async return(): Promise<IteratorResult<JSONRPCMessage>> {
94+
cancel();
95+
return { value: undefined, done: true };
96+
}
97+
})
98+
};
99+
}
100+
101+
/**
102+
* Feeds one inbound message to the driver. The transport calls this for
103+
* every message received while in stateless mode. Returns `true` if the
104+
* message was claimed (routed to a pending iterator).
105+
*/
106+
onMessage(m: JSONRPCMessage): boolean {
107+
if ('id' in m && m.id !== null && m.id !== undefined) {
108+
const q = this._pending.get(m.id);
109+
if (q) {
110+
q.push(m);
111+
return true;
112+
}
113+
}
114+
if (isJSONRPCNotification(m)) {
115+
const sid = (m.params?._meta as Record<string, unknown> | undefined)?.[META_KEYS.subscriptionId];
116+
if (typeof sid === 'string') {
117+
const q = this._pending.get(sid);
118+
if (q) {
119+
q.push(m);
120+
return true;
121+
}
122+
}
123+
}
124+
return false;
125+
}
126+
127+
/** Ends every pending iterator (e.g., on transport close). */
128+
close(): void {
129+
for (const q of this._pending.values()) q.close();
130+
this._pending.clear();
131+
}
132+
}

packages/core/src/util/inMemory.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js';
2+
import { StreamDriver } from '../shared/streamDriver.js';
23
import type { Transport } from '../shared/transport.js';
3-
import type { AuthInfo, JSONRPCMessage, RequestId } from '../types/index.js';
4+
import type { AuthInfo, JSONRPCMessage, JSONRPCRequest, RequestId } from '../types/index.js';
45

56
interface QueuedMessage {
67
message: JSONRPCMessage;
@@ -18,11 +19,19 @@ export class InMemoryTransport implements Transport {
1819
private _messageQueue: QueuedMessage[] = [];
1920
private _closed = false;
2021

22+
/* eslint-disable-next-line unicorn/consistent-function-scoping */
23+
private readonly _driver = new StreamDriver(m => this.send(m));
24+
2125
onclose?: () => void;
2226
onerror?: (error: Error) => void;
2327
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
2428
sessionId?: string;
2529

30+
/** Client-side: backed by `StreamDriver`. */
31+
sendAndReceive(request: Omit<JSONRPCRequest, 'jsonrpc' | 'id'>): AsyncIterable<JSONRPCMessage> {
32+
return this._driver.sendAndReceive(request);
33+
}
34+
2635
/**
2736
* Creates a pair of linked in-memory transports that can communicate with each other. One should be passed to a {@linkcode @modelcontextprotocol/client!client/client.Client | Client} and one to a {@linkcode @modelcontextprotocol/server!server/server.Server | Server}.
2837
*/
@@ -38,13 +47,20 @@ export class InMemoryTransport implements Transport {
3847
// Process any messages that were queued before start was called
3948
while (this._messageQueue.length > 0) {
4049
const queuedMessage = this._messageQueue.shift()!;
41-
this.onmessage?.(queuedMessage.message, queuedMessage.extra);
50+
this._receive(queuedMessage.message, queuedMessage.extra);
4251
}
4352
}
4453

54+
/** Receive path: route to the StreamDriver first; fall through to `onmessage` for unclaimed. */
55+
private _receive(message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }): void {
56+
if (this._driver.onMessage(message)) return;
57+
this.onmessage?.(message, extra);
58+
}
59+
4560
async close(): Promise<void> {
4661
if (this._closed) return;
4762
this._closed = true;
63+
this._driver.close();
4864

4965
const other = this._otherTransport;
5066
this._otherTransport = undefined;
@@ -65,7 +81,7 @@ export class InMemoryTransport implements Transport {
6581
}
6682

6783
if (this._otherTransport.onmessage) {
68-
this._otherTransport.onmessage(message, { authInfo: options?.authInfo });
84+
this._otherTransport._receive(message, { authInfo: options?.authInfo });
6985
} else {
7086
this._otherTransport._messageQueue.push({ message, extra: { authInfo: options?.authInfo } });
7187
}

0 commit comments

Comments
 (0)