BIG pi update with claude chat

This commit is contained in:
Jonas H
2026-04-24 14:22:59 +02:00
parent fbb00a49ba
commit 248667468c
24 changed files with 4225 additions and 1112 deletions

View File

@@ -0,0 +1,56 @@
# pi-ask-mcp
A minimal MCP stdio server that gives Claude **one** tool — `ask` — which routes
structured questions back to pi's native ask UI instead of using Claude's
built-in `AskUserQuestion`.
This is **not** a regular pi extension. It is a subprocess of `claude`, which is
itself a subprocess of the `chat-claude` extension. The pi-side counterpart is
[`shared/pi-ask-bridge.ts`](../../shared/pi-ask-bridge.ts), which:
1. Opens a Unix-domain socket per chat session.
2. Generates an `--mcp-config` JSON pointing here, with `PI_ASK_SOCKET=<sock>`.
3. Translates `ask` requests off the socket into
`askSingleQuestionWithInlineNote` / `askQuestionsWithTabs` calls and writes
the result back.
## Architecture
```
pi
└── chat-claude
├── pi-ask-bridge (UDS server, owns ui.custom)
└── claude -p ... --mcp-config <generated.json> --disallowed-tools AskUserQuestion
└── pi-ask-mcp/server.js (this file)
↳ on tools/call ask → connect $PI_ASK_SOCKET → ask → reply
```
## Why a hand-written MCP server
No `@modelcontextprotocol/sdk` dependency, no transpile step, no
`node_modules`. The MCP stdio protocol is small enough (~6 method handlers)
that writing it directly keeps the file self-contained and trivially
portable. Claude CLI spawns it via `node server.js`.
## Wire format
Stdio (with Claude): JSON-RPC 2.0 over newline-delimited JSON.
Socket (with pi-ask-bridge): NDJSON, one request → one response, then close.
```jsonc
// → pi
{ "id": "uuid", "type": "ask",
"questions": [
{ "id": "auth", "question": "Auth method?",
"options": [{"label": "OAuth"}, {"label": "API key"}],
"multi": false, "recommended": 0 }
] }
// ← pi (success)
{ "id": "uuid", "type": "result",
"results": [{ "id": "auth", "selectedOptions": ["OAuth"] }] }
// ← pi (cancel / error)
{ "id": "uuid", "type": "error", "message": "cancelled" }
```

View File

@@ -0,0 +1,7 @@
{
"name": "pi-ask-mcp",
"private": true,
"type": "module",
"main": "server.js",
"description": "Minimal MCP stdio server bridging Claude → pi-ask-bridge."
}

View 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);