src / mcpClient.ts
import { spawn, type ChildProcess } from "child_process";
/**
* A tiny Model Context Protocol client that speaks the stdio transport
* (newline-delimited JSON-RPC 2.0) to a child process. It is intentionally
* dependency-free so the plugin only needs @lmstudio/sdk + zod.
*
* The client keeps a single long-lived server process (OneNote COM startup is
* slow, so we do not want to respawn per call) and multiplexes requests over
* it by JSON-RPC id.
*/
export interface McpClientOptions {
command: string;
args: string[];
env?: Record<string, string>;
requestTimeoutMs?: number;
onLog?: (line: string) => void;
}
interface Pending {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
export interface ToolCallResult {
content?: Array<{ type?: string; text?: string; [k: string]: unknown }>;
structuredContent?: unknown;
isError?: boolean;
[k: string]: unknown;
}
export class McpStdioClient {
private readonly opts: McpClientOptions;
private child: ChildProcess | undefined;
private buffer = "";
private nextId = 1;
private readonly pending = new Map<number, Pending>();
private initialized = false;
private starting: Promise<void> | undefined;
constructor(opts: McpClientOptions) {
this.opts = opts;
}
private log(line: string): void {
this.opts.onLog?.(line);
}
private handleLine(rawLine: string): void {
const line = rawLine.trim();
if (!line) return;
let msg: any;
try {
msg = JSON.parse(line);
} catch {
// The server may print non-JSON diagnostics to stdout in edge cases.
this.log(`[server stdout] ${line}`);
return;
}
if (msg && typeof msg.id !== "undefined" && (msg.result !== undefined || msg.error !== undefined)) {
const pending = this.pending.get(msg.id);
if (!pending) return;
this.pending.delete(msg.id);
clearTimeout(pending.timer);
if (msg.error) {
const detail =
typeof msg.error === "object" && msg.error
? msg.error.message ?? JSON.stringify(msg.error)
: String(msg.error);
pending.reject(new Error(detail));
} else {
pending.resolve(msg.result);
}
}
// Notifications (no id) are ignored.
}
private onData(chunk: Buffer): void {
this.buffer += chunk.toString("utf8");
let idx: number;
while ((idx = this.buffer.indexOf("\n")) >= 0) {
const line = this.buffer.slice(0, idx);
this.buffer = this.buffer.slice(idx + 1);
this.handleLine(line);
}
}
private write(msg: unknown): void {
if (!this.child || this.child.killed || !this.child.stdin) {
throw new Error("OneNote MCP server process is not running.");
}
this.child.stdin.write(JSON.stringify(msg) + "\n");
}
private request(method: string, params: unknown): Promise<any> {
const id = this.nextId++;
const timeoutMs = this.opts.requestTimeoutMs ?? 120000;
return new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`OneNote MCP request '${method}' timed out after ${timeoutMs}ms.`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
try {
this.write({ jsonrpc: "2.0", id, method, params });
} catch (error) {
clearTimeout(timer);
this.pending.delete(id);
reject(error as Error);
}
});
}
private notify(method: string, params: unknown): void {
this.write({ jsonrpc: "2.0", method, params });
}
async ensureStarted(): Promise<void> {
if (this.initialized && this.child && !this.child.killed) return;
if (this.starting) return this.starting;
this.starting = this.start().finally(() => {
this.starting = undefined;
});
return this.starting;
}
private async start(): Promise<void> {
this.buffer = "";
const isWindows = process.platform === "win32";
// A bare command name or a .cmd/.bat shim (e.g. the npm 'local-onenote-mcp'
// launcher) can only be resolved through the shell on Windows. A full
// path to python.exe must NOT use the shell so that spaces in the path are
// preserved via the args array.
const needsShell = isWindows && !/\.exe$/i.test(this.opts.command);
const child = spawn(this.opts.command, this.opts.args, {
env: { ...process.env, ...(this.opts.env ?? {}) },
windowsHide: true,
shell: needsShell,
stdio: ["pipe", "pipe", "pipe"],
});
this.child = child;
child.stdout?.on("data", (chunk: Buffer) => this.onData(chunk));
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8").trimEnd();
if (text) this.log(`[server] ${text}`);
});
child.on("error", (error: Error) => {
this.log(`[spawn error] ${error.message}`);
this.failAllPending(new Error(`Failed to launch OneNote MCP server: ${error.message}`));
this.initialized = false;
this.child = undefined;
});
child.on("exit", (code, signal) => {
this.initialized = false;
this.child = undefined;
this.failAllPending(new Error(`OneNote MCP server exited (code=${code}, signal=${signal}).`));
});
await this.request("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "lmstudio-onenote-retrieval", version: "1.0.0" },
});
this.notify("notifications/initialized", {});
this.initialized = true;
}
private failAllPending(error: Error): void {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
async callTool(name: string, args: Record<string, unknown>): Promise<ToolCallResult> {
await this.ensureStarted();
return (await this.request("tools/call", { name, arguments: args })) as ToolCallResult;
}
stop(): void {
if (this.child && !this.child.killed) {
this.child.kill();
}
this.child = undefined;
this.initialized = false;
}
}
/**
* FastMCP returns a tool's dict result both as `structuredContent` and as a
* JSON string inside a text content block, depending on version. Normalize
* both into the underlying payload object.
*/
export function extractPayload(result: ToolCallResult): any {
if (result && result.structuredContent !== undefined && result.structuredContent !== null) {
return result.structuredContent;
}
const content = result?.content;
if (Array.isArray(content)) {
const textItem = content.find((c) => c && c.type === "text" && typeof c.text === "string");
if (textItem && typeof textItem.text === "string") {
try {
return JSON.parse(textItem.text);
} catch {
return { ok: true, text: textItem.text };
}
}
}
return result;
}