196 lines
7.3 KiB
JavaScript
Executable File
196 lines
7.3 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// pi-ask-mcp/server.js
|
|
//
|
|
// Minimal MCP stdio server that exposes ONE tool: `ask`.
|
|
// Bridges Claude → pi via a Unix-domain socket: when Claude calls the tool,
|
|
// this server forwards the question(s) to pi over $PI_ASK_SOCKET, awaits
|
|
// the user's answer, and returns it as the tool result.
|
|
//
|
|
// Wire format with Claude (stdin/stdout): JSON-RPC 2.0 over NDJSON.
|
|
// Wire format with pi (PI_ASK_SOCKET): NDJSON request/response, see
|
|
// ../../shared/pi-ask-bridge.ts.
|
|
//
|
|
// This file is INTENTIONALLY plain JavaScript (no transpile step, no
|
|
// node_modules) — Claude CLI spawns it via `node <path>`. Keep it small,
|
|
// dependency-free, and self-contained.
|
|
|
|
import { connect } from "node:net";
|
|
import { randomUUID } from "node:crypto";
|
|
import { createInterface } from "node:readline";
|
|
|
|
// ── Configuration ──────────────────────────────────────────────────────────
|
|
const SOCKET = process.env.PI_ASK_SOCKET;
|
|
if (!SOCKET) {
|
|
process.stderr.write("[pi-ask-mcp] PI_ASK_SOCKET env var is required\n");
|
|
process.exit(2);
|
|
}
|
|
|
|
const SERVER_INFO = { name: "pi", version: "0.1.0" };
|
|
const PROTOCOL_VERSION = "2024-11-05";
|
|
const SOCKET_TIMEOUT_MS = 15 * 60 * 1000; // matches runClaude's default
|
|
|
|
// ── Tool schema (kept in sync with pi-ask-tool/index.ts AskParamsSchema) ──
|
|
const ASK_INPUT_SCHEMA = {
|
|
type: "object",
|
|
required: ["questions"],
|
|
properties: {
|
|
questions: {
|
|
type: "array",
|
|
minItems: 1,
|
|
description: "One or more questions to ask the user.",
|
|
items: {
|
|
type: "object",
|
|
required: ["id", "question", "options"],
|
|
properties: {
|
|
id: { type: "string", description: "Stable id (e.g. 'auth', 'cache')." },
|
|
question: { type: "string", description: "Question text shown to the user." },
|
|
options: {
|
|
type: "array",
|
|
minItems: 1,
|
|
description: "2-5 concise options. Do NOT include 'Other' (UI adds it).",
|
|
items: {
|
|
type: "object",
|
|
required: ["label"],
|
|
properties: {
|
|
label: { type: "string", description: "Option display label." },
|
|
},
|
|
},
|
|
},
|
|
multi: { type: "boolean", description: "Allow multi-select. Defaults to false." },
|
|
recommended: { type: "number", description: "0-indexed recommended option (default highlight)." },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const ASK_DESCRIPTION = [
|
|
"Ask the user one or more structured questions through pi's native TUI.",
|
|
"Use this whenever a choice materially affects the outcome — instead of",
|
|
"guessing or the built-in AskUserQuestion. Provide 2-5 concise options.",
|
|
"Set multi=true when multiple answers are valid. Do NOT include an 'Other'",
|
|
"option (UI adds it automatically). The result is a JSON array of",
|
|
"{id, selectedOptions[], customInput?} per question — empty selectedOptions",
|
|
"means the user cancelled.",
|
|
].join(" ");
|
|
|
|
// ── stdio framing: NDJSON ──────────────────────────────────────────────────
|
|
const rl = createInterface({ input: process.stdin });
|
|
const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
|
|
const log = (msg) => process.stderr.write(`[pi-ask-mcp] ${msg}\n`);
|
|
|
|
// ── socket round-trip to pi-ask-bridge ─────────────────────────────────────
|
|
function askPi(args) {
|
|
return new Promise((resolve, reject) => {
|
|
const sock = connect(SOCKET);
|
|
const id = randomUUID();
|
|
let buf = "";
|
|
let settled = false;
|
|
const finish = (fn, val) => { if (settled) return; settled = true; clearTimeout(t); fn(val); try { sock.end(); } catch {} };
|
|
const t = setTimeout(
|
|
() => finish(reject, new Error(`pi-ask bridge timeout after ${SOCKET_TIMEOUT_MS / 1000}s`)),
|
|
SOCKET_TIMEOUT_MS,
|
|
);
|
|
|
|
sock.on("connect", () => sock.write(JSON.stringify({ id, type: "ask", ...args }) + "\n"));
|
|
sock.on("data", (d) => {
|
|
buf += d.toString();
|
|
const nl = buf.indexOf("\n");
|
|
if (nl < 0) return;
|
|
try { finish(resolve, JSON.parse(buf.slice(0, nl))); }
|
|
catch (err) { finish(reject, err); }
|
|
});
|
|
sock.on("error", (err) => finish(reject, err));
|
|
sock.on("close", () => {
|
|
if (!settled) finish(reject, new Error("pi-ask bridge closed connection without reply"));
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── JSON-RPC method handlers ───────────────────────────────────────────────
|
|
async function handleRequest(req) {
|
|
const { id, method, params } = req;
|
|
try {
|
|
switch (method) {
|
|
case "initialize":
|
|
return ok(id, {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
capabilities: { tools: {} },
|
|
serverInfo: SERVER_INFO,
|
|
});
|
|
case "tools/list":
|
|
return ok(id, {
|
|
tools: [{ name: "ask", description: ASK_DESCRIPTION, inputSchema: ASK_INPUT_SCHEMA }],
|
|
});
|
|
case "tools/call": {
|
|
const name = params?.name;
|
|
const args = params?.arguments ?? {};
|
|
if (name !== "ask") return err(id, -32602, `unknown tool: ${name}`);
|
|
const reply = await askPi(args);
|
|
if (reply.type === "error") {
|
|
return ok(id, {
|
|
isError: true,
|
|
content: [{ type: "text", text: `(user did not answer: ${reply.message})` }],
|
|
});
|
|
}
|
|
return ok(id, {
|
|
content: [{ type: "text", text: JSON.stringify(reply.results, null, 2) }],
|
|
});
|
|
}
|
|
case "ping": return ok(id, {});
|
|
case "resources/list": return ok(id, { resources: [] });
|
|
case "prompts/list": return ok(id, { prompts: [] });
|
|
default: return err(id, -32601, `method not found: ${method}`);
|
|
}
|
|
} catch (e) {
|
|
return err(id, -32603, e instanceof Error ? e.message : String(e));
|
|
}
|
|
}
|
|
|
|
const ok = (id, result) => ({ jsonrpc: "2.0", id, result });
|
|
const err = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
|
|
|
|
// ── main loop ──────────────────────────────────────────────────────────────
|
|
//
|
|
// Track in-flight handlers so we don't exit before they finish. Without this,
|
|
// `node server.js <<<input` (or any case where stdin closes mid-request) would
|
|
// race the async tools/call handler against rl 'close' → process.exit, and
|
|
// the reply would silently disappear.
|
|
let inflight = 0;
|
|
let stdinClosed = false;
|
|
function drainAndExit(code = 0) {
|
|
if (inflight === 0) process.exit(code);
|
|
}
|
|
|
|
async function handleOne(msg) {
|
|
// Notifications carry no id and expect no response.
|
|
if (msg.id === undefined || msg.id === null) {
|
|
if (msg.method === "exit") drainAndExit(0);
|
|
return; // notifications/initialized, notifications/cancelled, etc.
|
|
}
|
|
inflight += 1;
|
|
try {
|
|
const reply = await handleRequest(msg);
|
|
if (reply) send(reply);
|
|
} finally {
|
|
inflight -= 1;
|
|
if (stdinClosed) drainAndExit(0);
|
|
}
|
|
}
|
|
|
|
rl.on("line", (line) => {
|
|
if (!line.trim()) return;
|
|
let msg;
|
|
try { msg = JSON.parse(line); } catch { return; }
|
|
if (Array.isArray(msg)) {
|
|
for (const m of msg) void handleOne(m);
|
|
} else {
|
|
void handleOne(msg);
|
|
}
|
|
});
|
|
|
|
rl.on("close", () => { stdinClosed = true; drainAndExit(0); });
|
|
process.on("SIGTERM", () => process.exit(0));
|
|
process.on("SIGINT", () => process.exit(0));
|
|
log("ready, socket=" + SOCKET);
|