Skip to content

Commit f405f80

Browse files
committed
feat: add WebSocket server transport
1 parent 06a4fd2 commit f405f80

File tree

2 files changed

+184
-10
lines changed

2 files changed

+184
-10
lines changed

package-lock.json

Lines changed: 0 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/server/websocket.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import type { Server as HttpServer } from 'node:http';
2+
import WebSocket, { WebSocketServer } from 'ws';
3+
import { Transport } from '../shared/transport.js';
4+
import {
5+
JSONRPCMessage,
6+
JSONRPCMessageSchema,
7+
type MessageExtraInfo
8+
} from '../types.js';
9+
10+
const SUBPROTOCOL = 'mcp';
11+
12+
export interface WebSocketServerTransportOptions {
13+
/**
14+
* Optional existing HTTP(S) server to attach the WebSocket server to.
15+
* If provided, `port` and `host` are ignored.
16+
*/
17+
server?: HttpServer;
18+
19+
/**
20+
* Port to listen on if no HTTP server is provided.
21+
* Defaults to 0 (OS picks a free port).
22+
*/
23+
port?: number;
24+
25+
/**
26+
* Host to bind to when creating a standalone WebSocket server.
27+
*/
28+
host?: string;
29+
30+
/**
31+
* Optional path for the WebSocket endpoint, e.g. "/mcp".
32+
*/
33+
path?: string;
34+
}
35+
36+
/**
37+
* Server transport for WebSocket: this communicates with an MCP client
38+
* over the WebSocket protocol.
39+
*
40+
* This is the WebSocket analogue of StdioServerTransport: it expects
41+
* exactly one client per transport instance and delivers JSON-RPC
42+
* messages via the Transport interface.
43+
*/
44+
export class WebSocketServerTransport implements Transport {
45+
private _wss: WebSocketServer;
46+
private _socket?: WebSocket;
47+
private _started = false;
48+
49+
// Transport interface fields / callbacks
50+
sessionId?: string;
51+
onclose?: () => void;
52+
onerror?: (error: Error) => void;
53+
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
54+
setProtocolVersion?: (version: string) => void;
55+
56+
constructor(options: WebSocketServerTransportOptions = {}) {
57+
const { server, port, host, path } = options;
58+
59+
this._wss = new WebSocketServer({
60+
server,
61+
port: server ? undefined : (port ?? 0),
62+
host: server ? undefined : host,
63+
path,
64+
handleProtocols: (protocols /* , req */) => {
65+
// Require the MCP subprotocol if offered
66+
if (protocols.has(SUBPROTOCOL)) {
67+
return SUBPROTOCOL;
68+
}
69+
// Reject if the client doesn't offer the MCP subprotocol
70+
return false;
71+
}
72+
});
73+
}
74+
75+
/**
76+
* Starts listening for a single WebSocket client and sets up MCP message handling.
77+
*
78+
* Resolves once a client connects successfully.
79+
*/
80+
start(): Promise<void> {
81+
if (this._started) {
82+
throw new Error(
83+
'WebSocketServerTransport already started! If using Server class, note that connect() calls start() automatically.'
84+
);
85+
}
86+
87+
this._started = true;
88+
89+
return new Promise((resolve, reject) => {
90+
const handleError = (err: Error) => {
91+
this._wss.off('connection', handleConnection);
92+
this.onerror?.(err);
93+
reject(err);
94+
};
95+
96+
const handleConnection = (socket: WebSocket) => {
97+
// Only allow one client per transport instance
98+
if (this._socket) {
99+
socket.close(1013, 'Only one client is allowed per transport');
100+
return;
101+
}
102+
103+
// Enforce negotiated subprotocol
104+
if (socket.protocol !== SUBPROTOCOL) {
105+
socket.close(1002, 'MCP subprotocol (mcp) required');
106+
return;
107+
}
108+
109+
this._socket = socket;
110+
111+
socket.on('message', data => {
112+
try {
113+
const parsed = JSON.parse(data.toString());
114+
const message = JSONRPCMessageSchema.parse(parsed);
115+
this.onmessage?.(message);
116+
} catch (error) {
117+
this.onerror?.(error as Error);
118+
}
119+
});
120+
121+
socket.on('error', err => {
122+
this.onerror?.(err as Error);
123+
});
124+
125+
socket.on('close', () => {
126+
this._socket = undefined;
127+
this.onclose?.();
128+
});
129+
130+
this._wss.off('error', handleError);
131+
this._wss.off('connection', handleConnection);
132+
resolve();
133+
};
134+
135+
this._wss.on('connection', handleConnection);
136+
this._wss.once('error', handleError);
137+
});
138+
}
139+
140+
/**
141+
* Sends a JSON-RPC message to the connected WebSocket client.
142+
*/
143+
send(message: JSONRPCMessage): Promise<void> {
144+
return new Promise((resolve, reject) => {
145+
if (!this._socket || this._socket.readyState !== WebSocket.OPEN) {
146+
const error = new Error('Not connected');
147+
this.onerror?.(error);
148+
reject(error);
149+
return;
150+
}
151+
152+
const payload = JSON.stringify(message);
153+
this._socket.send(payload, err => {
154+
if (err) {
155+
this.onerror?.(err);
156+
reject(err);
157+
} else {
158+
resolve();
159+
}
160+
});
161+
});
162+
}
163+
164+
/**
165+
* Closes the WebSocket connection and the underlying WebSocket server.
166+
*/
167+
async close(): Promise<void> {
168+
if (this._socket && this._socket.readyState === WebSocket.OPEN) {
169+
this._socket.close();
170+
}
171+
172+
await new Promise<void>((resolve, reject) => {
173+
this._wss.close(err => {
174+
if (err) {
175+
this.onerror?.(err);
176+
reject(err);
177+
} else {
178+
this.onclose?.();
179+
resolve();
180+
}
181+
});
182+
});
183+
}
184+
}

0 commit comments

Comments
 (0)