Initial import
This commit is contained in:
commit
02e421c1f1
4 changed files with 8130 additions and 0 deletions
83
src/server/index.ts
Normal file
83
src/server/index.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Protocol } from "../shared/protocol.js";
|
||||
import {
|
||||
ClientCapabilities,
|
||||
ClientNotification,
|
||||
ClientRequest,
|
||||
ClientResult,
|
||||
Implementation,
|
||||
InitializeRequest,
|
||||
InitializeResult,
|
||||
PROTOCOL_VERSION,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
ServerResult,
|
||||
} from "../types/index.js";
|
||||
|
||||
/**
|
||||
* An MCP server on top of a pluggable transport.
|
||||
*
|
||||
* This server will automatically respond to the initialization flow as initiated from the client.
|
||||
*/
|
||||
export class Server extends Protocol<
|
||||
ClientRequest,
|
||||
ClientNotification,
|
||||
ClientResult,
|
||||
ServerRequest,
|
||||
ServerNotification,
|
||||
ServerResult
|
||||
> {
|
||||
private _clientCapabilities?: ClientCapabilities;
|
||||
private _clientVersion?: Implementation;
|
||||
|
||||
/**
|
||||
* Callback for when initialization has fully completed (i.e., the client has sent an `initialized` notification).
|
||||
*/
|
||||
oninitialized?: () => void;
|
||||
|
||||
/**
|
||||
* Initializes this server with the given name and version information.
|
||||
*/
|
||||
constructor(private _serverInfo: Implementation) {
|
||||
super();
|
||||
|
||||
this.setRequestHandler("initialize", (request) =>
|
||||
this._oninitialize(request as InitializeRequest),
|
||||
);
|
||||
this.setNotificationHandler("notification/initialized", () =>
|
||||
this.oninitialized?.(),
|
||||
);
|
||||
}
|
||||
|
||||
private async _oninitialize(
|
||||
request: InitializeRequest,
|
||||
): Promise<InitializeResult> {
|
||||
if (request.params.protocolVersion !== PROTOCOL_VERSION) {
|
||||
throw new Error(
|
||||
`Client's protocol version is not supported: ${request.params.protocolVersion}`,
|
||||
);
|
||||
}
|
||||
|
||||
this._clientCapabilities = request.params.capabilities;
|
||||
this._clientVersion = request.params.clientInfo;
|
||||
|
||||
return {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
serverInfo: this._serverInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* After initialization has completed, this will be populated with the client's reported capabilities.
|
||||
*/
|
||||
getClientCapabilities(): ClientCapabilities | undefined {
|
||||
return this._clientCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* After initialization has completed, this will be populated with information about the client's name and version.
|
||||
*/
|
||||
getClientVersion(): Implementation | undefined {
|
||||
return this._clientVersion;
|
||||
}
|
||||
}
|
||||
129
src/server/sse.ts
Normal file
129
src/server/sse.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { validateMessage } from "../shared/message.js";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
import { JSONRPCMessage } from "../types/index.js";
|
||||
import getRawBody from "raw-body";
|
||||
import contentType from "content-type";
|
||||
|
||||
const MAXIMUM_MESSAGE_SIZE = "4mb";
|
||||
|
||||
/**
|
||||
* Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests.
|
||||
*
|
||||
* This transport is only available in Node.js environments.
|
||||
*/
|
||||
export class SSEServerTransport implements Transport {
|
||||
private _sseResponse?: ServerResponse;
|
||||
private _sessionId: string;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
/**
|
||||
* Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`.
|
||||
*/
|
||||
constructor(private _endpoint: string) {
|
||||
this._sessionId = randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the initial SSE connection request.
|
||||
*
|
||||
* This should be called when a GET request is made to establish the SSE stream.
|
||||
*/
|
||||
async connectSSE(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
if (this._sseResponse) {
|
||||
throw new Error("Already connected!");
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
|
||||
// Send the endpoint event
|
||||
res.write(
|
||||
`event: endpoint\ndata: ${encodeURI(this._endpoint)}?sessionId=${this._sessionId}\n\n`,
|
||||
);
|
||||
|
||||
this._sseResponse = res;
|
||||
res.on("close", () => {
|
||||
this._sseResponse = undefined;
|
||||
this.onclose?.();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming POST messages.
|
||||
*
|
||||
* This should be called when a POST request is made to send a message to the server.
|
||||
*/
|
||||
async handlePostMessage(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
if (!this._sseResponse) {
|
||||
const message = "SSE connection not established";
|
||||
res.writeHead(500).end(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
const ct = contentType.parse(req.headers["content-type"] ?? "");
|
||||
if (ct.type !== "application/json") {
|
||||
throw new Error(`Unsupported content-type: ${ct}`);
|
||||
}
|
||||
|
||||
body = await getRawBody(req, {
|
||||
limit: MAXIMUM_MESSAGE_SIZE,
|
||||
encoding: ct.parameters.charset ?? "utf-8",
|
||||
});
|
||||
} catch (error) {
|
||||
res.writeHead(400).end(String(error));
|
||||
this.onerror?.(error as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
let message: JSONRPCMessage;
|
||||
try {
|
||||
message = JSON.parse(body);
|
||||
validateMessage(message);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
res.writeHead(400).end(`Invalid message: ${body}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.onmessage?.(message);
|
||||
res.writeHead(202).end("Accepted");
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this._sseResponse?.end();
|
||||
this._sseResponse = undefined;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (!this._sseResponse) {
|
||||
throw new Error("Not connected");
|
||||
}
|
||||
|
||||
this._sseResponse.write(
|
||||
`event: message\ndata: ${JSON.stringify(message)}\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session ID for this transport.
|
||||
*
|
||||
* This can be used to route incoming POST requests.
|
||||
*/
|
||||
get sessionId(): string {
|
||||
return this._sessionId;
|
||||
}
|
||||
}
|
||||
102
src/server/stdio.test.ts
Normal file
102
src/server/stdio.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { Readable, Writable } from "node:stream";
|
||||
import { ReadBuffer, serializeMessage } from "../shared/stdio.js";
|
||||
import { JSONRPCMessage } from "../types/index.js";
|
||||
import { StdioServerTransport } from "./stdio.js";
|
||||
|
||||
let input: Readable;
|
||||
let outputBuffer: ReadBuffer;
|
||||
let output: Writable;
|
||||
|
||||
beforeEach(() => {
|
||||
input = new Readable({
|
||||
// We'll use input.push() instead.
|
||||
read: () => {},
|
||||
});
|
||||
|
||||
outputBuffer = new ReadBuffer();
|
||||
output = new Writable({
|
||||
write(chunk, encoding, callback) {
|
||||
outputBuffer.append(chunk);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should start then close cleanly", async () => {
|
||||
const server = new StdioServerTransport(input, output);
|
||||
server.onerror = (error) => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
let didClose = false;
|
||||
server.onclose = () => {
|
||||
didClose = true;
|
||||
};
|
||||
|
||||
await server.start();
|
||||
expect(didClose).toBeFalsy();
|
||||
await server.close();
|
||||
expect(didClose).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should not read until started", async () => {
|
||||
const server = new StdioServerTransport(input, output);
|
||||
server.onerror = (error) => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
let didRead = false;
|
||||
const readMessage = new Promise((resolve) => {
|
||||
server.onmessage = (message) => {
|
||||
didRead = true;
|
||||
resolve(message);
|
||||
};
|
||||
});
|
||||
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "ping",
|
||||
};
|
||||
input.push(serializeMessage(message));
|
||||
|
||||
expect(didRead).toBeFalsy();
|
||||
await server.start();
|
||||
expect(await readMessage).toEqual(message);
|
||||
});
|
||||
|
||||
test("should read multiple messages", async () => {
|
||||
const server = new StdioServerTransport(input, output);
|
||||
server.onerror = (error) => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const messages: JSONRPCMessage[] = [
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "ping",
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
},
|
||||
];
|
||||
|
||||
const readMessages: JSONRPCMessage[] = [];
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
server.onmessage = (message) => {
|
||||
readMessages.push(message);
|
||||
if (JSON.stringify(message) === JSON.stringify(messages[1])) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
input.push(serializeMessage(messages[0]));
|
||||
input.push(serializeMessage(messages[1]));
|
||||
|
||||
await server.start();
|
||||
await finished;
|
||||
expect(readMessages).toEqual(messages);
|
||||
});
|
||||
73
src/server/stdio.ts
Normal file
73
src/server/stdio.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import process from "node:process";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { ReadBuffer, serializeMessage } from "../shared/stdio.js";
|
||||
import { JSONRPCMessage } from "../types/index.js";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
|
||||
/**
|
||||
* Server transport for stdio: this communicates with a MCP client by reading from the current process' stdin and writing to stdout.
|
||||
*
|
||||
* This transport is only available in Node.js environments.
|
||||
*/
|
||||
export class StdioServerTransport implements Transport {
|
||||
private _readBuffer: ReadBuffer = new ReadBuffer();
|
||||
|
||||
constructor(
|
||||
private _stdin: Readable = process.stdin,
|
||||
private _stdout: Writable = process.stdout,
|
||||
) {}
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
// Arrow functions to bind `this` properly, while maintaining function identity.
|
||||
_ondata = (chunk: Buffer) => {
|
||||
this._readBuffer.append(chunk);
|
||||
this.processReadBuffer();
|
||||
};
|
||||
_onerror = (error: Error) => {
|
||||
this.onerror?.(error);
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts listening for messages on stdin.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
this._stdin.on("data", this._ondata);
|
||||
this._stdin.on("error", this._onerror);
|
||||
}
|
||||
|
||||
private processReadBuffer() {
|
||||
while (true) {
|
||||
try {
|
||||
const message = this._readBuffer.readMessage();
|
||||
if (message === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.onmessage?.(message);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this._stdin.off("data", this._ondata);
|
||||
this._stdin.off("error", this._onerror);
|
||||
this._readBuffer.clear();
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
send(message: JSONRPCMessage): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const json = serializeMessage(message);
|
||||
if (this._stdout.write(json)) {
|
||||
resolve();
|
||||
} else {
|
||||
this._stdout.once("drain", resolve);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue