repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/klaus/src/tool-bridge.ts
Rawimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
CallToolRequestSchema,
type CallToolResult,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { dbg } from "./debug.js";
import type { KlausTool } from "./protocol.js";
interface PermissionCall {
id: string;
name: string;
input: Record<string, unknown>;
}
interface PendingCall extends PermissionCall {
resolve(result: CallToolResult): void;
reject(error: Error): void;
}
export interface PiToolResult {
id: string;
content: Array<
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string }
>;
isError: boolean;
}
export const TOOL_ID_FIELD = "__klausToolUseID";
/** Claude caps MCP tool results at 100000 characters by default and replaces
* anything larger with a `<persisted-output>` pointer into its own temp
* directory, which Klaus deletes when the query ends. Declaring the per-tool
* limit raises that threshold to Claude's ceiling so Pi results reach the model
* intact; Pi already truncates its own tool output. */
const MAX_RESULT_SIZE_CHARS = 500_000;
const TRUNCATION_NOTE =
"\n[Klaus truncated this tool result at Claude's 500000-character result limit.]";
/** Beyond the declared ceiling Claude persists the result to a file inside the
* per-query config directory and shows the model a pointer, and Klaus deletes
* that directory when the query ends. Truncating here keeps the loss visible to
* the model instead of handing it a dead path. */
function boundedContent(
content: PiToolResult["content"],
): PiToolResult["content"] {
let remaining = MAX_RESULT_SIZE_CHARS;
let truncated = false;
const bounded: PiToolResult["content"] = [];
for (const item of content) {
if (item.type !== "text") {
bounded.push({ ...item });
continue;
}
if (truncated) continue;
if (item.text.length <= remaining) {
remaining -= item.text.length;
bounded.push({ ...item });
continue;
}
const kept = item.text.slice(
0,
Math.max(0, remaining - TRUNCATION_NOTE.length),
);
dbg?.("bridge.settle.truncated", { originalLength: item.text.length });
bounded.push({ type: "text", text: `${kept}${TRUNCATION_NOTE}` });
truncated = true;
remaining = 0;
}
return bounded;
}
const originalName = (name: string): string =>
name.startsWith("mcp__klaus__") ? name.slice("mcp__klaus__".length) : name;
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (typeof value === "object" && value !== null) {
return `{${Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? "undefined";
}
const sameInput = (
left: Record<string, unknown>,
right: Record<string, unknown>,
): boolean => canonical(left) === canonical(right);
export class ToolBridge {
readonly server: McpServer;
readonly pending = new Map<string, PendingCall>();
private readonly settled = new Set<string>();
private permissions: PermissionCall[] = [];
private tools = new Map<string, KlausTool>();
private closed = false;
private waiters: Array<() => void> = [];
constructor(
tools: KlausTool[],
private readonly onActivity: () => void = () => undefined,
) {
dbg?.("bridge.create");
this.tools = new Map(tools.map((tool) => [tool.name, tool]));
this.server = new McpServer(
{ name: "klaus", version: "1" },
{ capabilities: { tools: {} } },
);
this.server.server.setRequestHandler(ListToolsRequestSchema, async () => {
dbg?.("bridge.listTools");
this.onActivity();
return {
tools: [...this.tools.values()].map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
_meta: {
"anthropic/maxResultSizeChars": MAX_RESULT_SIZE_CHARS,
},
})),
};
});
this.server.server.setRequestHandler(
CallToolRequestSchema,
async (request) => {
dbg?.("bridge.callTool.start", { closed: this.closed });
this.onActivity();
if (this.closed) return this.errorResult("Klaus query is closed.");
const name = originalName(request.params.name);
if (!this.tools.has(name))
return this.errorResult(`Tool ${name} is no longer active.`);
const input = { ...(request.params.arguments ?? {}) };
const embeddedId = input[TOOL_ID_FIELD];
delete input[TOOL_ID_FIELD];
const permissionIndex = this.permissions.findIndex((call) =>
typeof embeddedId === "string"
? call.id === embeddedId && call.name === name
: call.name === name && sameInput(call.input, input),
);
const permission =
permissionIndex < 0
? undefined
: this.permissions.splice(permissionIndex, 1)[0];
if (!permission) {
return this.errorResult(`No Klaus tool identity for ${name}.`);
}
if (this.pending.has(permission.id)) {
return this.errorResult(
`Duplicate Klaus tool call ${permission.id}.`,
);
}
dbg?.("bridge.callTool.park");
return await new Promise<CallToolResult>((resolve, reject) => {
const pending: PendingCall = { ...permission, resolve, reject };
this.pending.set(permission.id, pending);
for (const notify of this.waiters.splice(0)) notify();
});
},
);
}
/** Claude reaches this through both the `PreToolUse` hook and `canUseTool`,
* and only the hook is guaranteed, so the first identity for a tool use id
* wins and later duplicates are ignored. */
allow(name: string, input: Record<string, unknown>, id: string): void {
const duplicate = this.permissions.some((call) => call.id === id);
dbg?.("bridge.allow");
this.onActivity();
if (duplicate) return;
this.permissions.push({ id, name: originalName(name), input });
}
/** The tool list in the exact order Claude receives it, which is the order the
* API reports a rejected tool schema by position. */
activeTools(): KlausTool[] {
return [...this.tools.values()];
}
toolNames(): string[] {
const names = [...this.tools.keys()];
dbg?.("bridge.toolNames");
return names;
}
/** Pi owns the active tool list, so a live child adopts the new list instead
* of forcing a canonical replay. */
replaceTools(tools: KlausTool[]): void {
dbg?.("bridge.replaceTools");
this.tools = new Map(tools.map((tool) => [tool.name, tool]));
}
hasSchemaChange(tools: KlausTool[]): boolean {
const changed =
JSON.stringify([...this.tools.values()]) !== JSON.stringify(tools);
dbg?.("bridge.hasSchemaChange", { changed });
return changed;
}
pendingIds(): string[] {
const ids = [...this.pending.keys()];
dbg?.("bridge.pendingIds");
return ids;
}
async waitForPending(ids: string[], signal?: AbortSignal): Promise<void> {
dbg?.("bridge.waitForPending.start");
while (ids.some((id) => !this.pending.has(id))) {
if (this.closed)
throw new Error("Klaus query closed before tools parked.");
if (signal?.aborted) throw new Error("Klaus tool wait aborted.");
await new Promise<void>((resolve, reject) => {
const abort = () => reject(new Error("Klaus tool wait aborted."));
signal?.addEventListener("abort", abort, { once: true });
this.waiters.push(() => {
signal?.removeEventListener("abort", abort);
resolve();
});
});
}
dbg?.("bridge.waitForPending.end");
}
settle(result: PiToolResult): boolean {
dbg?.("bridge.settle.start");
this.onActivity();
if (this.settled.has(result.id)) return false;
const pending = this.pending.get(result.id);
if (!pending) return false;
this.pending.delete(result.id);
this.settled.add(result.id);
pending.resolve({
content: boundedContent(result.content),
isError: result.isError,
});
dbg?.("bridge.settle.end");
return true;
}
async close(reason: string): Promise<void> {
dbg?.("bridge.close.start");
if (this.closed) return;
this.closed = true;
const error = new Error(reason);
for (const pending of this.pending.values()) pending.reject(error);
this.pending.clear();
for (const notify of this.waiters.splice(0)) notify();
await this.server.close();
dbg?.("bridge.close.end");
}
private errorResult(message: string): CallToolResult {
dbg?.("bridge.errorResult");
return { content: [{ type: "text", text: message }], isError: true };
}
}