#!/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 `. 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 << { 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);