BIG pi update with claude chat
This commit is contained in:
195
pi/.pi/agent/extensions/pi-ask-mcp/server.js
Executable file
195
pi/.pi/agent/extensions/pi-ask-mcp/server.js
Executable file
@@ -0,0 +1,195 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user