mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 22:18:26 +00:00
* Feat: v2 * feat: add chat functionality * First cut: integrations * Feat: add conversation API * Enhance conversation handling and memory management * Feat: added conversation --------- Co-authored-by: Manoj K <saimanoj58@gmail.com>
257 lines
6.5 KiB
TypeScript
257 lines
6.5 KiB
TypeScript
import { type ChildProcess, type IOType } from "node:child_process";
|
|
import process from "node:process";
|
|
import { type Stream } from "node:stream";
|
|
|
|
import { type Transport } from "@modelcontextprotocol/sdk/shared/transport";
|
|
import {
|
|
type JSONRPCMessage,
|
|
JSONRPCMessageSchema,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import { execa } from "execa";
|
|
|
|
/**
|
|
* Buffers a continuous stdio stream into discrete JSON-RPC messages.
|
|
*/
|
|
export class ReadBuffer {
|
|
private _buffer?: Buffer;
|
|
|
|
append(chunk: Buffer): void {
|
|
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
|
|
}
|
|
|
|
readMessage(): JSONRPCMessage | null {
|
|
if (!this._buffer) {
|
|
return null;
|
|
}
|
|
|
|
const index = this._buffer.indexOf("\n");
|
|
if (index === -1) {
|
|
return null;
|
|
}
|
|
|
|
const line = this._buffer.toString("utf8", 0, index).replace(/\r$/, "");
|
|
this._buffer = this._buffer.subarray(index + 1);
|
|
return deserializeMessage(line);
|
|
}
|
|
|
|
clear(): void {
|
|
this._buffer = undefined;
|
|
}
|
|
}
|
|
|
|
export function deserializeMessage(line: string): JSONRPCMessage {
|
|
return JSONRPCMessageSchema.parse(JSON.parse(line));
|
|
}
|
|
|
|
export function serializeMessage(message: JSONRPCMessage): string {
|
|
return `${JSON.stringify(message)}\n`;
|
|
}
|
|
|
|
export interface 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.
|
|
*
|
|
* If not specified, the result of getDefaultEnvironment() will be used.
|
|
*/
|
|
env?: Record<string, string>;
|
|
|
|
/**
|
|
* How to handle stderr of the child process. This matches the semantics of Node's `child_process.spawn`.
|
|
*
|
|
* The default is "inherit", meaning messages to stderr will be printed to the parent process's stderr.
|
|
*/
|
|
stderr?: IOType | Stream | number;
|
|
|
|
/**
|
|
* The working directory to use when spawning the process.
|
|
*
|
|
* If not specified, the current working directory will be inherited.
|
|
*/
|
|
cwd?: string;
|
|
}
|
|
|
|
/**
|
|
* Environment variables to inherit by default, if an environment is not explicitly given.
|
|
*/
|
|
export const DEFAULT_INHERITED_ENV_VARS =
|
|
process.platform === "win32"
|
|
? [
|
|
"APPDATA",
|
|
"HOMEDRIVE",
|
|
"HOMEPATH",
|
|
"LOCALAPPDATA",
|
|
"PATH",
|
|
"PROCESSOR_ARCHITECTURE",
|
|
"SYSTEMDRIVE",
|
|
"SYSTEMROOT",
|
|
"TEMP",
|
|
"USERNAME",
|
|
"USERPROFILE",
|
|
]
|
|
: /* list inspired by the default env inheritance of sudo */
|
|
["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"];
|
|
|
|
/**
|
|
* Returns a default environment object including only environment variables deemed safe to inherit.
|
|
*/
|
|
export function getDefaultEnvironment(): Record<string, string> {
|
|
const env: Record<string, string> = {};
|
|
|
|
for (const key of DEFAULT_INHERITED_ENV_VARS) {
|
|
const value = process.env[key];
|
|
if (value === undefined) {
|
|
continue;
|
|
}
|
|
|
|
if (value.startsWith("()")) {
|
|
// Skip functions, which are a security risk.
|
|
continue;
|
|
}
|
|
|
|
env[key] = value;
|
|
}
|
|
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
private _serverParams: StdioServerParameters;
|
|
|
|
onclose?: () => void;
|
|
onerror?: (error: Error) => void;
|
|
onmessage?: (message: JSONRPCMessage) => void;
|
|
|
|
constructor(server: StdioServerParameters) {
|
|
this._serverParams = server;
|
|
}
|
|
|
|
/**
|
|
* Starts the server process and prepares to communicate with it.
|
|
*/
|
|
async start(): Promise<void> {
|
|
if (this._process) {
|
|
throw new Error(
|
|
"StdioClientTransport already started! If using Client class, note that connect() calls start() automatically.",
|
|
);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
this._process = execa(
|
|
this._serverParams.command,
|
|
this._serverParams.args ?? [],
|
|
{
|
|
env: this._serverParams.env ?? getDefaultEnvironment(),
|
|
stderr: "inherit",
|
|
shell: "/bin/sh",
|
|
windowsHide: process.platform === "win32" && isElectron(),
|
|
cwd: this._serverParams.cwd,
|
|
cancelSignal: this._abortController.signal,
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
},
|
|
);
|
|
|
|
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();
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The stderr stream of the child process, if `StdioServerParameters.stderr` was set to "pipe" or "overlapped".
|
|
*
|
|
* This is only available after the process has been started.
|
|
*/
|
|
get stderr(): Stream | null {
|
|
return this._process?.stderr ?? null;
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function isElectron() {
|
|
return "type" in process;
|
|
}
|