Initial import
This commit is contained in:
commit
02e421c1f1
5 changed files with 8130 additions and 0 deletions
82
src/client/index.ts
Normal file
82
src/client/index.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Protocol } from "../shared/protocol.js";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
import {
|
||||
ClientNotification,
|
||||
ClientRequest,
|
||||
ClientResult,
|
||||
Implementation,
|
||||
InitializeResult,
|
||||
PROTOCOL_VERSION,
|
||||
ServerCapabilities,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
ServerResult,
|
||||
} from "../types/index.js";
|
||||
|
||||
/**
|
||||
* An MCP client on top of a pluggable transport.
|
||||
*
|
||||
* The client will automatically begin the initialization flow with the server when connect() is called.
|
||||
*/
|
||||
export class Client extends Protocol<
|
||||
ServerRequest,
|
||||
ServerNotification,
|
||||
ServerResult,
|
||||
ClientRequest,
|
||||
ClientNotification,
|
||||
ClientResult
|
||||
> {
|
||||
private _serverCapabilities?: ServerCapabilities;
|
||||
private _serverVersion?: Implementation;
|
||||
|
||||
/**
|
||||
* Initializes this client with the given name and version information.
|
||||
*/
|
||||
constructor(private _clientInfo: Implementation) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async connect(transport: Transport): Promise<void> {
|
||||
await super.connect(transport);
|
||||
|
||||
const result = (await this.request({
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: 1,
|
||||
capabilities: {},
|
||||
clientInfo: this._clientInfo,
|
||||
},
|
||||
})) as InitializeResult;
|
||||
|
||||
if (result === undefined) {
|
||||
throw new Error(`Server sent invalid initialize result: ${result}`);
|
||||
}
|
||||
|
||||
if (result.protocolVersion !== PROTOCOL_VERSION) {
|
||||
throw new Error(
|
||||
`Server's protocol version is not supported: ${result.protocolVersion}`,
|
||||
);
|
||||
}
|
||||
|
||||
this._serverCapabilities = result.capabilities;
|
||||
this._serverVersion = result.serverInfo;
|
||||
|
||||
await this.notification({
|
||||
method: "notifications/initialized",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* After initialization has completed, this will be populated with the server's reported capabilities.
|
||||
*/
|
||||
getServerCapabilities(): ServerCapabilities | undefined {
|
||||
return this._serverCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* After initialization has completed, this will be populated with information about the server's name and version.
|
||||
*/
|
||||
getServerVersion(): Implementation | undefined {
|
||||
return this._serverVersion;
|
||||
}
|
||||
}
|
||||
104
src/client/sse.ts
Normal file
104
src/client/sse.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { validateMessage } from "../shared/message.js";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
import { JSONRPCMessage } from "../types/index.js";
|
||||
|
||||
/**
|
||||
* Client transport for SSE: this will connect to a server using Server-Sent Events for receiving
|
||||
* messages and make separate POST requests for sending messages.
|
||||
*
|
||||
* This uses the EventSource API in browsers. You can install the `eventsource` package for Node.js.
|
||||
*/
|
||||
export class SSEClientTransport implements Transport {
|
||||
private _eventSource?: EventSource;
|
||||
private _endpoint?: URL;
|
||||
private _abortController?: AbortController;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
connect(url: URL): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._eventSource = new EventSource(url.href);
|
||||
this._abortController = new AbortController();
|
||||
|
||||
this._eventSource.onerror = (event) => {
|
||||
const error = new Error(`SSE error: ${JSON.stringify(event)}`);
|
||||
reject(error);
|
||||
this.onerror?.(error);
|
||||
};
|
||||
|
||||
this._eventSource.onopen = () => {
|
||||
// The connection is open, but we need to wait for the endpoint to be received.
|
||||
};
|
||||
|
||||
this._eventSource.addEventListener("endpoint", (event: Event) => {
|
||||
const messageEvent = event as MessageEvent;
|
||||
|
||||
try {
|
||||
this._endpoint = new URL(messageEvent.data, url);
|
||||
if (this._endpoint.origin !== url.origin) {
|
||||
throw new Error(
|
||||
`Endpoint origin does not match connection origin: ${this._endpoint.origin}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
this.onerror?.(error as Error);
|
||||
|
||||
void this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
this._eventSource.onmessage = (event: Event) => {
|
||||
const messageEvent = event as MessageEvent;
|
||||
let message: JSONRPCMessage;
|
||||
try {
|
||||
message = JSON.parse(messageEvent.data);
|
||||
validateMessage(message);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.onmessage?.(message);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this._abortController?.abort();
|
||||
this._eventSource?.close();
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (!this._endpoint) {
|
||||
throw new Error("Not connected");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this._endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
signal: this._abortController?.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => null);
|
||||
throw new Error(
|
||||
`Error POSTing to endpoint (HTTP ${response.status}): ${text}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/client/stdio.test.ts
Normal file
61
src/client/stdio.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { JSONRPCMessage } from "../types/index.js";
|
||||
import { StdioClientTransport, StdioServerParameters } from "./stdio.js";
|
||||
|
||||
const serverParameters: StdioServerParameters = {
|
||||
command: "/usr/bin/tee",
|
||||
};
|
||||
|
||||
test("should start then close cleanly", async () => {
|
||||
const client = new StdioClientTransport();
|
||||
client.onerror = (error) => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
let didClose = false;
|
||||
client.onclose = () => {
|
||||
didClose = true;
|
||||
};
|
||||
|
||||
await client.spawn(serverParameters);
|
||||
expect(didClose).toBeFalsy();
|
||||
await client.close();
|
||||
expect(didClose).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should read messages", async () => {
|
||||
const client = new StdioClientTransport();
|
||||
client.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) => {
|
||||
client.onmessage = (message) => {
|
||||
readMessages.push(message);
|
||||
|
||||
if (JSON.stringify(message) === JSON.stringify(messages[1])) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await client.spawn(serverParameters);
|
||||
await client.send(messages[0]);
|
||||
await client.send(messages[1]);
|
||||
await finished;
|
||||
expect(readMessages).toEqual(messages);
|
||||
|
||||
await client.close();
|
||||
});
|
||||
121
src/client/stdio.ts
Normal file
121
src/client/stdio.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { ChildProcess, spawn } from "node:child_process";
|
||||
import { ReadBuffer, serializeMessage } from "../shared/stdio.js";
|
||||
import { JSONRPCMessage } from "../types/index.js";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
|
||||
export type StdioServerParameters = {
|
||||
/**
|
||||
* The executable to run to start the server.
|
||||
*/
|
||||
command: string;
|
||||
|
||||
/**
|
||||
* Command line arguments to pass to the executable.
|
||||
*/
|
||||
args?: string[];
|
||||
|
||||
/**
|
||||
* The environment to use when spawning the process.
|
||||
*
|
||||
* The environment is NOT inherited from the parent process by default.
|
||||
*/
|
||||
env?: object;
|
||||
};
|
||||
|
||||
/**
|
||||
* Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout.
|
||||
*
|
||||
* This transport is only available in Node.js environments.
|
||||
*/
|
||||
export class StdioClientTransport implements Transport {
|
||||
private _process?: ChildProcess;
|
||||
private _abortController: AbortController = new AbortController();
|
||||
private _readBuffer: ReadBuffer = new ReadBuffer();
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
/**
|
||||
* Spawns the server process and prepare to communicate with it.
|
||||
*/
|
||||
spawn(server: StdioServerParameters): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._process = spawn(server.command, server.args ?? [], {
|
||||
// The parent process may have sensitive secrets in its env, so don't inherit it automatically.
|
||||
env: server.env === undefined ? {} : { ...server.env },
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
signal: this._abortController.signal,
|
||||
});
|
||||
|
||||
this._process.on("error", (error) => {
|
||||
if (error.name === "AbortError") {
|
||||
// Expected when close() is called.
|
||||
this.onclose?.();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(error);
|
||||
this.onerror?.(error);
|
||||
});
|
||||
|
||||
this._process.on("spawn", () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
this._process.on("close", (_code) => {
|
||||
this._process = undefined;
|
||||
this.onclose?.();
|
||||
});
|
||||
|
||||
this._process.stdin?.on("error", (error) => {
|
||||
this.onerror?.(error);
|
||||
});
|
||||
|
||||
this._process.stdout?.on("data", (chunk) => {
|
||||
this._readBuffer.append(chunk);
|
||||
this.processReadBuffer();
|
||||
});
|
||||
|
||||
this._process.stdout?.on("error", (error) => {
|
||||
this.onerror?.(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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._abortController.abort();
|
||||
this._process = undefined;
|
||||
this._readBuffer.clear();
|
||||
}
|
||||
|
||||
send(message: JSONRPCMessage): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this._process?.stdin) {
|
||||
throw new Error("Not connected");
|
||||
}
|
||||
|
||||
const json = serializeMessage(message);
|
||||
if (this._process.stdin.write(json)) {
|
||||
resolve();
|
||||
} else {
|
||||
this._process.stdin.once("drain", resolve);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
68
src/client/websocket.ts
Normal file
68
src/client/websocket.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { validateMessage } from "../shared/message.js";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
import { JSONRPCMessage } from "../types/index.js";
|
||||
|
||||
const SUBPROTOCOL = "mcp";
|
||||
|
||||
/**
|
||||
* Client transport for WebSocket: this will connect to a server over the WebSocket protocol.
|
||||
*/
|
||||
export class WebSocketClientTransport implements Transport {
|
||||
private _socket?: WebSocket;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
connect(url: URL): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._socket = new WebSocket(url, SUBPROTOCOL);
|
||||
|
||||
this._socket.onerror = (event) => {
|
||||
const error =
|
||||
"error" in event
|
||||
? (event.error as Error)
|
||||
: new Error(`WebSocket error: ${JSON.stringify(event)}`);
|
||||
reject(error);
|
||||
this.onerror?.(error);
|
||||
};
|
||||
|
||||
this._socket.onopen = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
this._socket.onclose = () => {
|
||||
this.onclose?.();
|
||||
};
|
||||
|
||||
this._socket.onmessage = (event: MessageEvent) => {
|
||||
let message: JSONRPCMessage;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
validateMessage(message);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.onmessage?.(message);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this._socket?.close();
|
||||
}
|
||||
|
||||
send(message: JSONRPCMessage): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this._socket) {
|
||||
reject(new Error("Not connected"));
|
||||
return;
|
||||
}
|
||||
|
||||
this._socket?.send(JSON.stringify(message));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue