Initial release: iai-mcp v0.1.0
Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
This commit is contained in:
commit
f6b876fbe7
332 changed files with 97258 additions and 0 deletions
463
mcp-wrapper/src/bridge.ts
Normal file
463
mcp-wrapper/src/bridge.ts
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
// Phase 7.1 — pure-connector bridge. NO spawn capability.
|
||||
// The daemon is launchd-managed (see scripts/install.sh).
|
||||
// Wrapper connects to ~/.iai-mcp/.daemon.sock with 5s timeout.
|
||||
// On connect failure, throws DaemonUnreachableError — does NOT
|
||||
// attempt to spawn a daemon (eliminating Phase 7's TOCTOU race).
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import * as net from "node:net";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
// HIGH-4 LOCKED (Plan 07-04 Task 1 Step A): env override is mandatory so
|
||||
// tests can isolate via tmp socket paths. The daemon-side honors the same
|
||||
// env (Plan 07-02 added it to socket_server.py:serve()).
|
||||
const DAEMON_SOCKET_PATH =
|
||||
process.env.IAI_DAEMON_SOCKET_PATH
|
||||
?? path.join(os.homedir(), ".iai-mcp", ".daemon.sock");
|
||||
const SOCKET_CONNECT_TIMEOUT_MS = 5000;
|
||||
// 5s — covers launchd socket-activation cold-start (~3s embedder load
|
||||
// + ~1s LanceDB open + buffer). launchd accepts the connection
|
||||
// immediately and queues the read until the daemon is ready, so a
|
||||
// single 5s timeout is sufficient even on a true cold start.
|
||||
// JSON-RPC 2.0 custom server-error code (-32099..-32000 reserved by spec for
|
||||
// implementation-defined server errors per jsonrpc.org/specification).
|
||||
const ERR_DAEMON_UNREACHABLE = -32002;
|
||||
|
||||
/**
|
||||
* Phase 7.1 — clean error class thrown when the daemon socket is not
|
||||
* reachable at start(). Replaces the pre-7.1 `daemon_spawn_failed`
|
||||
* generic Error. The error message points the user at the launchd
|
||||
* recovery commands. `code` matches the existing
|
||||
* `ERR_DAEMON_UNREACHABLE` JSON-RPC server-error constant so downstream
|
||||
* consumers (handleSocketDeath in-flight rejects, `iai-mcp doctor`)
|
||||
* can pattern-match on a single numeric code.
|
||||
*/
|
||||
export class DaemonUnreachableError extends Error {
|
||||
public code: number;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "DaemonUnreachableError";
|
||||
this.code = ERR_DAEMON_UNREACHABLE;
|
||||
}
|
||||
}
|
||||
|
||||
interface RpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
id: number;
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface RpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (v: unknown) => void;
|
||||
reject: (e: Error) => void;
|
||||
}
|
||||
|
||||
export class PythonCoreBridge {
|
||||
private sock: net.Socket | null = null;
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, Pending>();
|
||||
private buffer = "";
|
||||
private reconnectAttempted = false;
|
||||
// V3-05 fix: serializes the at-most-one async reconnect from
|
||||
// handleSocketDeath. Concurrent call() awaits this promise BEFORE
|
||||
// checking !this.sock so a request landing in the gap between socket
|
||||
// close and reconnect-completion does NOT reject daemon_unreachable
|
||||
// when the daemon is actually healthy.
|
||||
private reconnectPromise: Promise<void> | null = null;
|
||||
// mcp-tools-list-empty-cache fix (2026-05-02): serializes concurrent
|
||||
// start() calls. Without this, the deferred-bridge-start ordering in
|
||||
// index.ts (multiple paths can trigger start: oninitialized,
|
||||
// CallToolRequest handler, top-level fire-and-forget) would each
|
||||
// observe `this.sock === null` and race independent connectWithTimeout
|
||||
// attempts. With it, the first caller drives the connect, every other
|
||||
// caller awaits the same promise. On reject the latch clears so the
|
||||
// next start() can retry (e.g. daemon came up later).
|
||||
private startPromise: Promise<void> | null = null;
|
||||
/** V3-06: consecutive JSON.parse failures on the NDJSON stream. */
|
||||
private parseErrorStreak = 0;
|
||||
private static readonly PARSE_ERROR_REJECT_THRESHOLD = 4;
|
||||
|
||||
// Allow overriding the Python interpreter via IAI_MCP_PYTHON for tests
|
||||
// that need to run the daemon against the project venv (see
|
||||
// test_mcp_tools.py).
|
||||
constructor(
|
||||
private readonly pythonCmd: string = process.env.IAI_MCP_PYTHON ?? "python3",
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Phase 7.1 — pure-connector start(). Socket-only; NO spawn capability.
|
||||
* Idempotent: a second call while a socket is alive is a no-op.
|
||||
*
|
||||
* Tries to connect to ~/.iai-mcp/.daemon.sock with a 5s timeout
|
||||
* (covers launchd socket-activation cold-start). On failure, throws
|
||||
* DaemonUnreachableError pointing the user at scripts/install.sh.
|
||||
*
|
||||
* The daemon's lifecycle is owned by launchd (see
|
||||
* scripts/com.iai-mcp.daemon.plist.template); the wrapper does not
|
||||
* spawn it under any condition (eliminates Phase 7's TOCTOU race when
|
||||
* N≥3 wrappers cold-start concurrently).
|
||||
*
|
||||
* mcp-tools-list-empty-cache fix (2026-05-02): start() is now safe to
|
||||
* call concurrently from multiple async paths (top-level boot fire,
|
||||
* server.oninitialized chain, CallToolRequest lazy-await). The first
|
||||
* caller drives the actual socket connect; the rest await the shared
|
||||
* `startPromise` and observe the same outcome. On reject the latch
|
||||
* is cleared so a future call() can retry once the daemon is up.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.sock) return; // already connected; idempotent
|
||||
if (this.startPromise) return this.startPromise;
|
||||
this.startPromise = this._doStart();
|
||||
try {
|
||||
await this.startPromise;
|
||||
} catch (err) {
|
||||
// Allow a future caller to retry — the daemon may simply have been
|
||||
// slow to come up. Without clearing the latch, every subsequent
|
||||
// start() would short-circuit on the rejected memoised promise.
|
||||
this.startPromise = null;
|
||||
throw err;
|
||||
}
|
||||
// On success, leave startPromise set; further calls short-circuit on
|
||||
// `this.sock` truthiness (set inside _doStart before resolution).
|
||||
}
|
||||
|
||||
private async _doStart(): Promise<void> {
|
||||
// Reset reconnect-once latch so a fresh start() (e.g. after explicit
|
||||
// disconnect) is treated as a new session by handleSocketDeath.
|
||||
this.reconnectAttempted = false;
|
||||
|
||||
let sock: net.Socket;
|
||||
try {
|
||||
sock = await this.connectWithTimeout(
|
||||
DAEMON_SOCKET_PATH,
|
||||
SOCKET_CONNECT_TIMEOUT_MS,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new DaemonUnreachableError(
|
||||
"iai-mcp daemon not running. "
|
||||
+ "Run: launchctl load -w ~/Library/LaunchAgents/com.iai-mcp.daemon.plist "
|
||||
+ "or run scripts/install.sh"
|
||||
);
|
||||
}
|
||||
this.sock = sock;
|
||||
this.attachSocketHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise wrapper around net.createConnection with a hard timeout.
|
||||
* Adapted from emitSessionOpen (lines below) — same silent-fail safety
|
||||
* pattern, but resolves with the live socket on success so the caller
|
||||
* can retain it for long-lived JSON-RPC traffic.
|
||||
*/
|
||||
private connectWithTimeout(
|
||||
socketPath: string,
|
||||
timeoutMs: number,
|
||||
): Promise<net.Socket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = net.createConnection(socketPath);
|
||||
const t = setTimeout(() => {
|
||||
try { sock.destroy(); } catch { /* ignore */ }
|
||||
reject(new Error("connect_timeout"));
|
||||
}, timeoutMs);
|
||||
sock.once("connect", () => {
|
||||
clearTimeout(t);
|
||||
resolve(sock);
|
||||
});
|
||||
sock.once("error", (e) => {
|
||||
clearTimeout(t);
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private attachSocketHandlers(): void {
|
||||
if (!this.sock) return;
|
||||
this.sock.on("data", (chunk: Buffer) => this.handleData(chunk));
|
||||
this.sock.on("close", () => this.handleSocketDeath("closed"));
|
||||
this.sock.on("error", (e: Error) => this.handleSocketDeath(`error: ${e.message}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* NDJSON read buffer: socket data arrives in arbitrary chunks; we buffer
|
||||
* + split on `\n` manually. Each complete line is one JSON-RPC response
|
||||
* envelope.
|
||||
*/
|
||||
private handleData(chunk: Buffer): void {
|
||||
this.buffer += chunk.toString("utf-8");
|
||||
let nl: number;
|
||||
while ((nl = this.buffer.indexOf("\n")) >= 0) {
|
||||
const line = this.buffer.slice(0, nl).trim();
|
||||
this.buffer = this.buffer.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
this.handleLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
let msg: RpcResponse;
|
||||
try {
|
||||
msg = JSON.parse(line) as RpcResponse;
|
||||
} catch {
|
||||
this.parseErrorStreak += 1;
|
||||
if (
|
||||
this.parseErrorStreak >= PythonCoreBridge.PARSE_ERROR_REJECT_THRESHOLD
|
||||
&& this.pending.size > 0
|
||||
) {
|
||||
const oldestId = Math.min(...this.pending.keys());
|
||||
const handler = this.pending.get(oldestId);
|
||||
if (handler) {
|
||||
this.pending.delete(oldestId);
|
||||
handler.reject(
|
||||
new Error(
|
||||
`parse_error: ${PythonCoreBridge.PARSE_ERROR_REJECT_THRESHOLD} consecutive non-JSON lines on daemon socket; rejecting stale RPC id=${oldestId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
event: "bridge_ndjson_parse_error_streak",
|
||||
threshold: PythonCoreBridge.PARSE_ERROR_REJECT_THRESHOLD,
|
||||
rejected_rpc_id: oldestId,
|
||||
})}\n`,
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
this.parseErrorStreak = 0;
|
||||
}
|
||||
return; // non-JSON line -- ignore (e.g., stray prints from daemon libs)
|
||||
}
|
||||
this.parseErrorStreak = 0;
|
||||
const handler = this.pending.get(msg.id);
|
||||
if (!handler) return;
|
||||
this.pending.delete(msg.id);
|
||||
if (msg.error) {
|
||||
handler.reject(new Error(msg.error.message));
|
||||
} else {
|
||||
handler.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* R5 fail-loud: socket close/error rejects ALL pending Promises with
|
||||
* `daemon_unreachable` (-32002). D7-04 / SPEC R5: ONE reconnect attempt
|
||||
* (catches launchd KeepAlive respawn windows). After that attempt the
|
||||
* bridge stays degraded — every subsequent call returns
|
||||
* `daemon_unreachable` until the wrapper itself restarts.
|
||||
*/
|
||||
private handleSocketDeath(why: string): void {
|
||||
// Synchronous: every pending request fails LOUD immediately so callers
|
||||
// see daemon_unreachable instead of hanging forever (D7-04 / SPEC R5).
|
||||
const err = new Error(`daemon_unreachable: socket ${why} (code ${ERR_DAEMON_UNREACHABLE})`);
|
||||
for (const [, p] of this.pending) p.reject(err);
|
||||
this.pending.clear();
|
||||
this.sock = null;
|
||||
// Clear the start-latch so a future call() can retry start() (e.g.
|
||||
// after launchd respawn). reconnectPromise (below) handles the
|
||||
// immediate one-shot reconnect; startPromise reset enables
|
||||
// long-tail retry from any new caller after that.
|
||||
this.startPromise = null;
|
||||
|
||||
if (this.reconnectAttempted) return;
|
||||
this.reconnectAttempted = true;
|
||||
|
||||
// Async reconnect-once. Concurrent call() awaits this promise BEFORE
|
||||
// checking !this.sock, eliminating the V3-05 race.
|
||||
this.reconnectPromise = (async () => {
|
||||
try {
|
||||
// Test-only deterministic widener for the V3-05 race window.
|
||||
// In production this env var is unset → 0 ms → no-op. The
|
||||
// V3-05 regression test (tests/test_socket_disconnect_reconnect.py)
|
||||
// sets IAI_MCP_RECONNECT_TEST_DELAY_MS=1000 so the racing
|
||||
// call() can land deterministically inside the gap between
|
||||
// socket close and reconnect-completion. Without this delay the
|
||||
// race window is sub-millisecond and the regression test cannot
|
||||
// distinguish pre-fix (rejects daemon_unreachable) from post-fix
|
||||
// (awaits reconnectPromise, succeeds).
|
||||
const testDelayMs = Number(
|
||||
process.env.IAI_MCP_RECONNECT_TEST_DELAY_MS ?? "0",
|
||||
);
|
||||
if (testDelayMs > 0) {
|
||||
await new Promise<void>((r) => setTimeout(r, testDelayMs));
|
||||
}
|
||||
// Manually do socket-first connect (without resetting the latch
|
||||
// that start() does) so a SECOND mid-call death stays degraded.
|
||||
this.sock = await this.connectWithTimeout(
|
||||
DAEMON_SOCKET_PATH,
|
||||
SOCKET_CONNECT_TIMEOUT_MS,
|
||||
);
|
||||
this.attachSocketHandlers();
|
||||
} catch {
|
||||
// stay degraded — every subsequent call sees this.sock === null
|
||||
// and rejects with daemon_unreachable.
|
||||
} finally {
|
||||
this.reconnectPromise = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON-RPC 2.0 request over the socket; resolves with `result`
|
||||
* or rejects with the daemon-side `error.message`.
|
||||
*
|
||||
* R5 fail-loud: when this.sock is null (post-death, post-disconnect,
|
||||
* pre-start) the call rejects synchronously with `daemon_unreachable`.
|
||||
* NO silent fallback to a local Python core spawn.
|
||||
*/
|
||||
async call<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
// V3-05 fix: if a reconnect is in flight, wait for it before deciding
|
||||
// whether the socket is alive. Without this await, a call() landing in
|
||||
// the gap between socket close and reconnect-completion would reject
|
||||
// with daemon_unreachable even though the daemon is healthy.
|
||||
if (this.reconnectPromise) {
|
||||
await this.reconnectPromise;
|
||||
}
|
||||
if (!this.sock) {
|
||||
throw new Error(`daemon_unreachable: bridge not connected (code ${ERR_DAEMON_UNREACHABLE})`);
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const req: RpcRequest = { jsonrpc: "2.0", id, method, params };
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, {
|
||||
resolve: resolve as (v: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
try {
|
||||
this.sock!.write(JSON.stringify(req) + "\n");
|
||||
} catch (e) {
|
||||
this.pending.delete(id);
|
||||
reject(e as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API: close the socket but leave the daemon running.
|
||||
* Used by index.ts SIGTERM/SIGINT handlers.
|
||||
*
|
||||
* After Phase 7 the wrapper does NOT own the daemon's lifecycle —
|
||||
* disconnecting a wrapper must NOT kill the singleton, otherwise other
|
||||
* wrappers (other MCP hosts, sub-agents) would lose their
|
||||
* shared brain.
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.sock) {
|
||||
try { this.sock.end(); } catch { /* ignore */ }
|
||||
try { this.sock.destroy(); } catch { /* ignore */ }
|
||||
this.sock = null;
|
||||
}
|
||||
// Clear the start-latch so a fresh start() (e.g. test re-use of the
|
||||
// bridge instance) is treated as a brand new connection.
|
||||
this.startPromise = null;
|
||||
// Reject any in-flight calls with a clean message (NOT
|
||||
// daemon_unreachable — the daemon is fine; we just chose to close).
|
||||
for (const [, p] of this.pending) {
|
||||
p.reject(new Error("bridge_disconnected"));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
// Visible for tests: smoke endpoint replacing the pre-Phase-7
|
||||
// isRunning() that checked for a child process.
|
||||
isConnected(): boolean {
|
||||
return this.sock !== null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 05-04 TOK-14 / D5-05 — session_open emit over the daemon unix socket.
|
||||
// UNCHANGED by Phase 7 (Plan 07-04). Same socket path; brief separate
|
||||
// connection that fires a one-shot HIPPEA pre-warm hint then closes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
/**
|
||||
* Path to the Python daemon's unix control socket.
|
||||
* Mirror of `concurrency.SOCKET_PATH` in the Python core (`~/.iai-mcp/.daemon.sock`).
|
||||
*
|
||||
* Honors `IAI_DAEMON_SOCKET_PATH` so tests can isolate via tmp socket paths
|
||||
* (matches the same env override the main bridge socket connect uses).
|
||||
*/
|
||||
export function sessionOpenSocketPath(): string {
|
||||
const env = process.env.IAI_DAEMON_SOCKET_PATH;
|
||||
if (env) return env;
|
||||
return path.join(os.homedir(), ".iai-mcp", ".daemon.sock");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate a fresh session identifier for the boot event.
|
||||
* Node stdlib since 14.17 — no dependency added.
|
||||
*/
|
||||
export function newSessionId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fire-and-forget NDJSON `session_open` message to the daemon socket.
|
||||
*
|
||||
* Contract:
|
||||
* - Writes one line: `{"type":"session_open","session_id":"...","ts":"..."}\n`
|
||||
* - One-shot semantics: does **not** read the daemon's response bytes before
|
||||
* `end()` — intentional (HIPPEA hint only). If the daemon wrote backpressure
|
||||
* or error bytes, they are left unread; the separate long-lived `PythonCoreBridge`
|
||||
* connection owns JSON-RPC traffic.
|
||||
* - Silent-fail on any network, socket-not-found, or timeout error. The
|
||||
* Python core's `_first_turn_recall_hook` falls back to the cold recall
|
||||
* path when the cascade LRU is empty (expected when daemon is down).
|
||||
* - Hard timeout at 2s so a hung socket cannot delay wrapper boot.
|
||||
*
|
||||
* Returns a Promise<void> that ALWAYS resolves (never rejects) so callers
|
||||
* can use `void emitSessionOpen(...)` in a sync bootstrap block without
|
||||
* an explicit `.catch`.
|
||||
*/
|
||||
export function emitSessionOpen(sessionId: string): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
try {
|
||||
const socketPath = sessionOpenSocketPath();
|
||||
const sock = net.createConnection(socketPath, () => {
|
||||
const msg =
|
||||
JSON.stringify({
|
||||
type: "session_open",
|
||||
session_id: sessionId,
|
||||
ts: new Date().toISOString(),
|
||||
}) + "\n";
|
||||
sock.write(msg, () => {
|
||||
sock.end();
|
||||
});
|
||||
});
|
||||
sock.on("error", () => finish());
|
||||
sock.on("close", () => finish());
|
||||
sock.setTimeout(2000, () => {
|
||||
try {
|
||||
sock.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
finish();
|
||||
});
|
||||
} catch {
|
||||
// Any sync setup failure -> silent fallback.
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
85
mcp-wrapper/src/caching.ts
Normal file
85
mcp-wrapper/src/caching.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Anthropic 1h-TTL prompt caching (TOK-01, D-10).
|
||||
//
|
||||
// Single breakpoint at the stable/volatile boundary. The Python core's
|
||||
// `session_start_payload` returns the 4-segment cached prefix; this module
|
||||
// wraps it in Anthropic `content` blocks and stamps `cache_control` on the
|
||||
// last stable block so Anthropic's cache sees one hashable suffix.
|
||||
//
|
||||
// cache_control TTL="1h" is the Anthropic prompt-caching extended-TTL option
|
||||
// released in Oct 2024 (enabled per-org; falls back to "5m" default when
|
||||
// unsupported). Rationale per D-10: session-start prefix rarely changes
|
||||
// within an hour, so 1h TTL hits Anthropic's cache on every turn after the
|
||||
// first fresh-session write (OPS-02 8000-token premium absorbed once).
|
||||
|
||||
export interface CacheControl {
|
||||
readonly type: "ephemeral";
|
||||
readonly ttl: "1h" | "5m";
|
||||
}
|
||||
|
||||
export interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
cache_control?: CacheControl;
|
||||
}
|
||||
|
||||
export interface SessionPayloadRaw {
|
||||
l0: string;
|
||||
l1: string;
|
||||
l2: string[];
|
||||
rich_club: string;
|
||||
total_cached_tokens: number;
|
||||
total_dynamic_tokens: number;
|
||||
breakpoint_marker?: string;
|
||||
}
|
||||
|
||||
/** Attach a single `cache_control` breakpoint at the stable/volatile boundary.
|
||||
*
|
||||
* Per TOK-01 we emit exactly one breakpoint: on the LAST block of `stable`.
|
||||
* If `stable` is empty the function returns the volatile blocks unchanged --
|
||||
* there is no sensible place to hang a breakpoint on an empty prefix and
|
||||
* Anthropic's API would reject the request.
|
||||
*
|
||||
* Returns a new array; inputs are not mutated. */
|
||||
export function applyCacheBreakpoint(
|
||||
stable: ContentBlock[],
|
||||
volatile: ContentBlock[],
|
||||
): ContentBlock[] {
|
||||
if (stable.length === 0) {
|
||||
return [...volatile];
|
||||
}
|
||||
const cloned = stable.map((b) => ({ ...b }));
|
||||
cloned[cloned.length - 1] = {
|
||||
...cloned[cloned.length - 1],
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
};
|
||||
return [...cloned, ...volatile];
|
||||
}
|
||||
|
||||
/** Build the cached system prompt from the Python session_start_payload.
|
||||
*
|
||||
* Segments in order: L0 identity, L1 critical facts, L2 community summaries
|
||||
* (one block per community), rich-club prefetch. Empty segments are skipped
|
||||
* so the cache-key is stable across sessions where, say, L1 is empty.
|
||||
*
|
||||
* Returned blocks already have the cache_control breakpoint applied. */
|
||||
export function buildCachedSystemPrompt(
|
||||
payload: SessionPayloadRaw,
|
||||
): ContentBlock[] {
|
||||
const stable: ContentBlock[] = [];
|
||||
if (payload.l0) {
|
||||
stable.push({ type: "text", text: `# L0 identity\n${payload.l0}` });
|
||||
}
|
||||
if (payload.l1) {
|
||||
stable.push({ type: "text", text: `# L1 critical facts\n${payload.l1}` });
|
||||
}
|
||||
for (const segment of payload.l2) {
|
||||
stable.push({ type: "text", text: `# L2 community\n${segment}` });
|
||||
}
|
||||
if (payload.rich_club) {
|
||||
stable.push({
|
||||
type: "text",
|
||||
text: `# Global rich-club\n${payload.rich_club}`,
|
||||
});
|
||||
}
|
||||
return applyCacheBreakpoint(stable, []);
|
||||
}
|
||||
226
mcp-wrapper/src/index.ts
Normal file
226
mcp-wrapper/src/index.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
#!/usr/bin/env node
|
||||
// IAI-MCP TypeScript wrapper entry point (Plan 03 wave).
|
||||
//
|
||||
// - Spawns the Python core over stdio JSON-RPC (see bridge.ts)
|
||||
// - Advertises the 12 hot tools via HOT_TOOLS registry (TOK-02)
|
||||
// - Attaches Anthropic 1h-TTL cache_control at the stable/volatile boundary
|
||||
// (TOK-01) via caching.ts helpers
|
||||
// - Advertises `clear_tool_uses_20250919` context editing with 30k trigger
|
||||
// (TOK-05) via registry.ts CONTEXT_EDITING_CONFIG
|
||||
// - On MCP `initialize`, warms the Python session_start payload so the first
|
||||
// real user turn doesn't pay the fresh-session cost synchronously.
|
||||
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import {
|
||||
emitSessionOpen,
|
||||
newSessionId,
|
||||
PythonCoreBridge,
|
||||
} from "./bridge.js";
|
||||
import {
|
||||
applyCacheBreakpoint,
|
||||
buildCachedSystemPrompt,
|
||||
type ContentBlock,
|
||||
type SessionPayloadRaw,
|
||||
} from "./caching.js";
|
||||
import { WrapperLifecycle } from "./lifecycle.js";
|
||||
import {
|
||||
CONTEXT_EDITING_CONFIG,
|
||||
HOT_TOOLS,
|
||||
listHotTools,
|
||||
} from "./registry.js";
|
||||
import { invokeTool, type ToolName } from "./tools.js";
|
||||
|
||||
// Re-export so consumers of the module (and tests) can touch the helpers
|
||||
// without dynamic imports.
|
||||
export {
|
||||
applyCacheBreakpoint,
|
||||
buildCachedSystemPrompt,
|
||||
CONTEXT_EDITING_CONFIG,
|
||||
HOT_TOOLS,
|
||||
};
|
||||
export type { ContentBlock, SessionPayloadRaw };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mcp-tools-list-empty-cache fix (2026-05-02):
|
||||
//
|
||||
// Pre-fix order was:
|
||||
// 1. await bridge.start() ← could block 5s on slow daemon
|
||||
// 2. construct Server + handlers
|
||||
// 3. await server.connect(transport)
|
||||
//
|
||||
// On a slow daemon (cold launchd hand-off, multi-second LanceDB open, RSS
|
||||
// watchdog respawn) the top-level await in step 1 delayed step 3 past the
|
||||
// MCP client's tools/list timeout. The client cached an empty tool list
|
||||
// for the rest of the session — symptom: "Connected" but zero
|
||||
// `mcp__iai-mcp__*` tools in the registry.
|
||||
//
|
||||
// Fixed order is:
|
||||
// 1. construct Server + register both request handlers + assign
|
||||
// oninitialized (must be set before connect — the initialized
|
||||
// notification fires immediately after handshake and an unset
|
||||
// handler would discard the HIPPEA pre-warm trigger).
|
||||
// 2. await server.connect(transport) ← tools/list is responsive HERE,
|
||||
// independent of daemon state (handler returns from static
|
||||
// registry.listHotTools()).
|
||||
// 3. fire-and-forget bridge.start() chained with emitSessionOpen — the
|
||||
// D5-05 invariant "emitSessionOpen fires AFTER daemon socket
|
||||
// reachable" is preserved by the .then() chain.
|
||||
// 4. CallToolRequest handler lazy-awaits bridge.start() before
|
||||
// delegating to invokeTool — first tools/call may pay daemon
|
||||
// cold-start cost ONCE; tools/list never blocks.
|
||||
//
|
||||
// Invariants preserved:
|
||||
// - Phase 7.1: wrapper does NOT spawn daemon (bridge.ts unchanged on
|
||||
// this point — it's still socket-only).
|
||||
// - Plan 05-04 D5-05 (HIPPEA pre-warm): emitSessionOpen still chained
|
||||
// off bridge.start() readiness.
|
||||
// - Plan 07-04 Task 2: SIGTERM/SIGINT closes socket only; daemon
|
||||
// survives. Unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const bridge = new PythonCoreBridge();
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: "iai-mcp",
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
capabilities: { tools: {} },
|
||||
// Expose TOK-05 context-editing config so MCP hosts that honour
|
||||
// Anthropic's context management can pick it up at discovery time.
|
||||
instructions: JSON.stringify({
|
||||
context_editing: CONTEXT_EDITING_CONFIG,
|
||||
hot_tools: HOT_TOOLS,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// tools/list MUST return from the static registry without touching the
|
||||
// bridge — see file-top comment block. This is what makes the wrapper
|
||||
// safe to advertise to the MCP client before the daemon socket is
|
||||
// reachable.
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: listHotTools(),
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||||
const name = req.params.name as ToolName;
|
||||
if (!HOT_TOOLS.includes(name)) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `unknown tool ${name}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
try {
|
||||
// Lazy bridge connect: the first tools/call after wrapper boot drives
|
||||
// the daemon socket connect. Subsequent calls short-circuit on the
|
||||
// alive socket. start() is concurrency-safe (startPromise serialises
|
||||
// multiple concurrent first-callers — see bridge.ts).
|
||||
await bridge.start();
|
||||
const result = await invokeTool(bridge, name, req.params.arguments ?? {});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `error: ${(e as Error).message}` },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Boot-time session id for Plan 05-04 session_open + downstream bookkeeping.
|
||||
const bootSessionId = newSessionId();
|
||||
|
||||
// MCP initialize hook -- warm the Python session-start payload so the first
|
||||
// real turn doesn't pay the fresh-session cost synchronously. OPS-05 continuity
|
||||
// is surfaced earlier this way: by the time Claude issues tools/call, the L0
|
||||
// pinned record is already resident in the Python core's warm cache.
|
||||
//
|
||||
// Must be assigned BEFORE server.connect() — the initialized notification
|
||||
// fires immediately after the handshake and an unset handler would silently
|
||||
// discard the pre-warm trigger.
|
||||
server.oninitialized = () => {
|
||||
// Chain on bridge readiness so the session_start_payload call doesn't
|
||||
// race the socket connect. start() is idempotent and serialised; if
|
||||
// the lazy CallToolRequest path already drove start, this awaits the
|
||||
// same in-flight promise.
|
||||
bridge
|
||||
.start()
|
||||
.then(() =>
|
||||
bridge.call<SessionPayloadRaw>("session_start_payload", {
|
||||
session_id: bootSessionId,
|
||||
}),
|
||||
)
|
||||
.catch(() => null);
|
||||
};
|
||||
|
||||
// Phase 10.5 L5 + L4: proactive wake + heartbeat refresh.
|
||||
//
|
||||
// Run BEFORE server.connect so the heartbeat is registered before any
|
||||
// tools/list or tools/call request can land. ensureDaemonAlive is
|
||||
// independent of the bridge.start() call below — it only probes the
|
||||
// socket and (on darwin) invokes `launchctl kickstart` via execFile;
|
||||
// it never connects. The 045999b decoupling is preserved: tools/list
|
||||
// still responds from the static registry whether the daemon is up
|
||||
// or not, and ensureDaemonAlive's failure path (wake.signal write)
|
||||
// is silent and non-fatal.
|
||||
const lifecycle = new WrapperLifecycle();
|
||||
await lifecycle.ensureDaemonAlive();
|
||||
await lifecycle.registerHeartbeat();
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
// Fire-and-forget daemon connect AFTER the MCP transport is live.
|
||||
// - bridge.start(): socket-only connect to the singleton daemon (Phase 7.1
|
||||
// invariant — never spawns).
|
||||
// - emitSessionOpen: D5-05 HIPPEA pre-warm hint; chained off start() so
|
||||
// the cascade-LRU activation happens AFTER the daemon is known
|
||||
// reachable. If the daemon is unreachable, start() rejects with
|
||||
// DaemonUnreachableError and the .catch() suppresses the unhandled
|
||||
// rejection — the wrapper continues serving tools/list and falls back
|
||||
// to per-call lazy retry in the CallToolRequest handler.
|
||||
void bridge
|
||||
.start()
|
||||
.then(() => emitSessionOpen(bootSessionId))
|
||||
.catch(() => {
|
||||
// Silent: tools/call will surface the daemon_unreachable error
|
||||
// synchronously when the user actually invokes a tool.
|
||||
});
|
||||
|
||||
// Phase 7 (Plan 07-04 Task 2): wrapper closing must NOT kill the shared
|
||||
// daemon. disconnect() closes the socket only; the singleton survives so
|
||||
// other wrappers (other MCP hosts, sub-agents) and future boots
|
||||
// can join. This is the load-bearing semantic of the Phase 7 singleton
|
||||
// model — the pre-Phase-7 wrapper-side child-kill API has been removed.
|
||||
//
|
||||
// Phase 10.5 L4 addition: cleanupHeartbeat clears the refresh timer
|
||||
// AND deletes ~/.iai-mcp/wrappers/heartbeat-<pid>-<uuid>.json so the
|
||||
// daemon-side scanner doesn't have to rely on STALE-detection for a
|
||||
// gracefully-exiting wrapper. Cleanup is idempotent and never throws.
|
||||
const shutdown = async (): Promise<void> => {
|
||||
try {
|
||||
await lifecycle.cleanupHeartbeat();
|
||||
} catch {
|
||||
// Cleanup is best-effort; the daemon's HeartbeatScanner reaps
|
||||
// STALE / ORPHAN entries on its next tick.
|
||||
}
|
||||
bridge.disconnect();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown();
|
||||
});
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown();
|
||||
});
|
||||
339
mcp-wrapper/src/lifecycle.ts
Normal file
339
mcp-wrapper/src/lifecycle.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
// Phase 10.5 L5 + L4 — wrapper-side proactive wake + heartbeat refresh.
|
||||
//
|
||||
// Two responsibilities, both lazy and idle-CPU-near-zero:
|
||||
//
|
||||
// L5 ensureDaemonAlive:
|
||||
// Probe the daemon UNIX socket (~/.iai-mcp/.daemon.sock) at boot.
|
||||
// If reachable, return immediately — no kickstart cost, no signal.
|
||||
// If unreachable AND platform is darwin, spawn `launchctl kickstart
|
||||
// -k gui/<uid>/com.iai-mcp.daemon` via Node's `execFile` API
|
||||
// (array args, hard-coded binary path, NEVER `shell: true`).
|
||||
// If the kickstart command fails or the platform is not darwin,
|
||||
// atomic-write ~/.iai-mcp/wake.signal so the next daemon cold-
|
||||
// start consumes it via `iai_mcp.wake_handler.WakeHandler`. The
|
||||
// wrapper itself NEVER spawns the daemon Python process — that
|
||||
// remains a launchd / external-init concern (Phase 7.1 invariant).
|
||||
//
|
||||
// L4 registerHeartbeat:
|
||||
// Atomically write ~/.iai-mcp/wrappers/heartbeat-<pid>-<uuid>.json
|
||||
// (temp + rename) and start a 30-second interval timer that
|
||||
// refreshes the `last_refresh` field. The timer is `unref()`d so
|
||||
// it does NOT block Node.js shutdown — the wrapper exits cleanly
|
||||
// even if `cleanupHeartbeat` is not called (the daemon's
|
||||
// HeartbeatScanner from Phase 10.4 will eventually classify the
|
||||
// file as STALE / ORPHAN and reap it).
|
||||
//
|
||||
// Hard rules carried from CONTEXT 10.5:
|
||||
//
|
||||
// - All `child_process` calls go through `execFile` (array args).
|
||||
// NEVER the shell-interpreting `exec` variant. NEVER `shell: true`.
|
||||
// Hard-coded binary path (/bin/launchctl); only the GUI uid is
|
||||
// process-derived (`process.getuid()`).
|
||||
// - The 30-sec refresh is a single `setInterval` with `unref()`, not
|
||||
// a busy loop or per-tick spawn.
|
||||
// - macOS-first; Linux / unknown platforms write `wake.signal`
|
||||
// directly without attempting kickstart.
|
||||
// - 045999b decoupling preserved — this module is independent of the
|
||||
// bridge / tools/list path. `ensureDaemonAlive` is a probe + spawn,
|
||||
// not a connect; tools/list MUST keep responding from the static
|
||||
// wrapper registry whether the daemon is up or not.
|
||||
// - `src/utils/execFileNoThrow.ts` is referenced in CONTEXT 10.5 as a
|
||||
// pattern reference but does NOT exist in this repo. We inline the
|
||||
// pattern here: `promisify(execFile)` + try/catch. Keeps the LOC
|
||||
// budget tight and makes the security guarantee local.
|
||||
//
|
||||
// File schema (matches `iai_mcp.heartbeat_scanner._parse_heartbeat_file`):
|
||||
//
|
||||
// {
|
||||
// "pid": 12345,
|
||||
// "uuid": "01HZQ...", // crypto.randomUUID()
|
||||
// "started_at": "2026-05-02T15:00:00Z",
|
||||
// "last_refresh": "2026-05-02T15:14:30Z",
|
||||
// "wrapper_version": "1.0.0",
|
||||
// "schema_version": 1
|
||||
// }
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ---------------------------------------------------------------- constants
|
||||
|
||||
/** Refresh cadence (ms). 30 s is the LOCKED contract from CONTEXT 10.4 / 10.5
|
||||
* — three missed refreshes (~90 s) trip the heartbeat scanner's STALE
|
||||
* threshold (`DEFAULT_STALE_THRESHOLD_SEC` in `heartbeat_scanner.py`). */
|
||||
export const HEARTBEAT_REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
/** Wrapper schema version. Bump only on a breaking change to the heartbeat
|
||||
* file shape. Phase 10.4 reader currently treats `schema_version` as
|
||||
* informational; future versions may gate field-presence checks on it. */
|
||||
export const HEARTBEAT_SCHEMA_VERSION = 1;
|
||||
|
||||
/** Wrapper version string written into each heartbeat file. Tracks the
|
||||
* `mcp-wrapper/package.json` version semantically; not auto-derived to
|
||||
* keep this module dependency-free at runtime. */
|
||||
export const WRAPPER_VERSION = "1.0.0";
|
||||
|
||||
/** Hard-coded launchctl binary path. Argv-only invocation — no shell
|
||||
* interpretation, no PATH lookup, no user-input interpolation. */
|
||||
const LAUNCHCTL_BIN = "/bin/launchctl";
|
||||
|
||||
/** Hard-coded launchd label for the IAI-MCP daemon. Matches the
|
||||
* `com.iai-mcp.daemon` LaunchAgent shipped by the project. */
|
||||
const LAUNCHD_LABEL = "com.iai-mcp.daemon";
|
||||
|
||||
/** Subprocess timeout (ms) for the kickstart call. Covers the worst-case
|
||||
* `launchctl kickstart` round-trip on a heavily loaded box; well under
|
||||
* the wrapper's MCP `tools/list` budget (server.connect already happens
|
||||
* before this in the boot flow). */
|
||||
const KICKSTART_TIMEOUT_MS = 5_000;
|
||||
|
||||
// ---------------------------------------------------------------- types
|
||||
|
||||
interface HeartbeatPayload {
|
||||
pid: number;
|
||||
uuid: string;
|
||||
started_at: string;
|
||||
last_refresh: string;
|
||||
wrapper_version: string;
|
||||
schema_version: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- paths
|
||||
|
||||
/** Compute `~/.iai-mcp/.daemon.sock`. Mirrors the daemon-side socket
|
||||
* path constant in `iai_mcp.concurrency`. */
|
||||
export function defaultSocketPath(): string {
|
||||
return join(homedir(), ".iai-mcp", ".daemon.sock");
|
||||
}
|
||||
|
||||
/** Compute `~/.iai-mcp/wake.signal`. Mirrors the path the daemon-side
|
||||
* `WakeHandler` consumes on cold-start. */
|
||||
export function defaultWakeSignalPath(): string {
|
||||
return join(homedir(), ".iai-mcp", "wake.signal");
|
||||
}
|
||||
|
||||
/** Compute `~/.iai-mcp/wrappers/heartbeat-<pid>-<uuid>.json`. Matches
|
||||
* the filename glob in `iai_mcp.heartbeat_scanner`. */
|
||||
export function defaultHeartbeatPath(pid: number, uuid: string): string {
|
||||
return join(homedir(), ".iai-mcp", "wrappers", `heartbeat-${pid}-${uuid}.json`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lifecycle
|
||||
|
||||
/** Constructor options. All fields optional; defaults derive from
|
||||
* `process` and `os.homedir()`. Dependency injection is here so tests
|
||||
* can supply a tmp dir without monkey-patching `homedir`. */
|
||||
export interface WrapperLifecycleOptions {
|
||||
pid?: number;
|
||||
uuid?: string;
|
||||
socketPath?: string;
|
||||
wakeSignalPath?: string;
|
||||
heartbeatPath?: string;
|
||||
/** Override the platform string. Defaults to `process.platform`. */
|
||||
platform?: NodeJS.Platform;
|
||||
/** Probe the daemon socket. Defaults to a real `net.createConnection`
|
||||
* attempt with a short timeout. Tests inject a mock. */
|
||||
socketReachable?: () => Promise<boolean>;
|
||||
/** Spawn `launchctl kickstart`. Defaults to the real `execFile` call.
|
||||
* Tests inject a mock that resolves or rejects deterministically. */
|
||||
spawnKickstart?: () => Promise<void>;
|
||||
/** Heartbeat refresh interval (ms). Defaults to
|
||||
* `HEARTBEAT_REFRESH_INTERVAL_MS`. Tests pass a smaller value. */
|
||||
refreshIntervalMs?: number;
|
||||
}
|
||||
|
||||
export class WrapperLifecycle {
|
||||
private readonly pid: number;
|
||||
private readonly uuid: string;
|
||||
private readonly socketPath: string;
|
||||
private readonly wakeSignalPath: string;
|
||||
private readonly heartbeatPath: string;
|
||||
private readonly platform: NodeJS.Platform;
|
||||
private readonly socketReachable: () => Promise<boolean>;
|
||||
private readonly spawnKickstart: () => Promise<void>;
|
||||
private readonly refreshIntervalMs: number;
|
||||
|
||||
private readonly startedAt: string;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(opts: WrapperLifecycleOptions = {}) {
|
||||
this.pid = opts.pid ?? process.pid;
|
||||
this.uuid = opts.uuid ?? randomUUID();
|
||||
this.socketPath = opts.socketPath ?? defaultSocketPath();
|
||||
this.wakeSignalPath = opts.wakeSignalPath ?? defaultWakeSignalPath();
|
||||
this.heartbeatPath =
|
||||
opts.heartbeatPath ?? defaultHeartbeatPath(this.pid, this.uuid);
|
||||
this.platform = opts.platform ?? process.platform;
|
||||
this.socketReachable = opts.socketReachable ?? defaultSocketReachable(this.socketPath);
|
||||
this.spawnKickstart = opts.spawnKickstart ?? defaultSpawnKickstart();
|
||||
this.refreshIntervalMs = opts.refreshIntervalMs ?? HEARTBEAT_REFRESH_INTERVAL_MS;
|
||||
this.startedAt = isoNow();
|
||||
}
|
||||
|
||||
/** L5: probe daemon socket; if unreachable, kickstart on darwin or
|
||||
* write `wake.signal` elsewhere. Never throws — the worst case is a
|
||||
* silent fallback to the signal file, which the daemon will pick up
|
||||
* on its next cold start. */
|
||||
async ensureDaemonAlive(): Promise<void> {
|
||||
let alive = false;
|
||||
try {
|
||||
alive = await this.socketReachable();
|
||||
} catch {
|
||||
alive = false;
|
||||
}
|
||||
if (alive) {
|
||||
return;
|
||||
}
|
||||
if (this.platform === "darwin") {
|
||||
try {
|
||||
await this.spawnKickstart();
|
||||
return;
|
||||
} catch {
|
||||
// Kickstart failed (launchd label missing, permission error,
|
||||
// timeout). Fall through to the wake.signal fallback so the
|
||||
// daemon's next cold-start path still consumes the request.
|
||||
}
|
||||
}
|
||||
// Non-darwin OR darwin-with-failed-kickstart: write the cross-
|
||||
// platform marker so a future daemon boot picks it up.
|
||||
try {
|
||||
await this.writeWakeSignal();
|
||||
} catch {
|
||||
// Even the wake.signal write failed (FS full, permission). Nothing
|
||||
// we can do safely here; do NOT escalate — the wrapper still has
|
||||
// useful work to do (tools/list responds from the static registry).
|
||||
}
|
||||
}
|
||||
|
||||
/** L4: write the heartbeat file and start the 30-sec refresh timer.
|
||||
* Called once at wrapper boot. Idempotent on the timer side: a second
|
||||
* call clears any prior timer before installing a new one. */
|
||||
async registerHeartbeat(): Promise<void> {
|
||||
await this.writeHeartbeat();
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
void this.writeHeartbeat().catch(() => {
|
||||
// Refresh failure is non-fatal: the daemon will classify the
|
||||
// stale file as STALE on the next scan and recover. We do NOT
|
||||
// log here to keep the idle-CPU profile near zero.
|
||||
});
|
||||
}, this.refreshIntervalMs);
|
||||
timer.unref();
|
||||
this.timer = timer;
|
||||
}
|
||||
|
||||
/** Graceful exit: stop the refresh timer and delete the heartbeat
|
||||
* file. Safe to call multiple times. Safe to call without prior
|
||||
* `registerHeartbeat` (no-ops). */
|
||||
async cleanupHeartbeat(): Promise<void> {
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
try {
|
||||
await unlink(this.heartbeatPath);
|
||||
} catch {
|
||||
// Already gone (concurrent daemon-side cleanup of a STALE entry,
|
||||
// or never written). Idempotent — swallow.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------- internals (visible-for-test)
|
||||
|
||||
/** Atomically write the heartbeat file: tmp + rename. The tmp
|
||||
* filename includes the wrapper's UUID so concurrent wrappers do
|
||||
* NOT collide on the staging path even if they share a working
|
||||
* directory. */
|
||||
private async writeHeartbeat(): Promise<void> {
|
||||
const payload: HeartbeatPayload = {
|
||||
pid: this.pid,
|
||||
uuid: this.uuid,
|
||||
started_at: this.startedAt,
|
||||
last_refresh: isoNow(),
|
||||
wrapper_version: WRAPPER_VERSION,
|
||||
schema_version: HEARTBEAT_SCHEMA_VERSION,
|
||||
};
|
||||
const dir = dirname(this.heartbeatPath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const tmp = `${this.heartbeatPath}.${this.uuid}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(payload), { encoding: "utf-8" });
|
||||
await rename(tmp, this.heartbeatPath);
|
||||
}
|
||||
|
||||
/** Atomically write `wake.signal`: tmp + rename. Per-uuid tmp suffix
|
||||
* avoids cross-wrapper staging collisions on the same machine. */
|
||||
private async writeWakeSignal(): Promise<void> {
|
||||
const dir = dirname(this.wakeSignalPath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const payload = JSON.stringify({
|
||||
requested_at: isoNow(),
|
||||
wrapper_pid: this.pid,
|
||||
wrapper_uuid: this.uuid,
|
||||
});
|
||||
const tmp = `${this.wakeSignalPath}.${this.uuid}.tmp`;
|
||||
await writeFile(tmp, payload, { encoding: "utf-8" });
|
||||
await rename(tmp, this.wakeSignalPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- defaults
|
||||
|
||||
function isoNow(): string {
|
||||
// ISO-8601 with trailing Z — matches the wire format the daemon-side
|
||||
// `_parse_heartbeat_file` accepts (replaces "Z" with "+00:00" before
|
||||
// `datetime.fromisoformat`).
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/** Default socket-probe: open a UNIX-domain socket connection to the
|
||||
* daemon path with a short timeout. Resolves true on `connect`,
|
||||
* false on `error` or timeout. */
|
||||
function defaultSocketReachable(socketPath: string): () => Promise<boolean> {
|
||||
return async () => {
|
||||
const { createConnection } = await import("node:net");
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (v: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// socket already destroyed by the loser of the connect/timeout
|
||||
// race — ignore.
|
||||
}
|
||||
resolve(v);
|
||||
};
|
||||
const socket = createConnection({ path: socketPath });
|
||||
socket.setTimeout(1_000);
|
||||
socket.once("connect", () => settle(true));
|
||||
socket.once("error", () => settle(false));
|
||||
socket.once("timeout", () => settle(false));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Default kickstart spawn: `execFile` with array args, hard-coded
|
||||
* binary path, no shell. The GUI uid is process-derived (`getuid()`)
|
||||
* so the same wrapper works for any signed-in user. */
|
||||
function defaultSpawnKickstart(): () => Promise<void> {
|
||||
return async () => {
|
||||
// `process.getuid()` is undefined on Windows builds; ! asserts
|
||||
// non-null because we only ever call this on darwin (the
|
||||
// ensureDaemonAlive caller gates on platform === "darwin").
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
||||
const args = ["kickstart", "-k", `gui/${uid}/${LAUNCHD_LABEL}`];
|
||||
await execFileAsync(LAUNCHCTL_BIN, args, {
|
||||
timeout: KICKSTART_TIMEOUT_MS,
|
||||
// No `shell` option — argv-only invocation, no shell interpretation.
|
||||
});
|
||||
};
|
||||
}
|
||||
56
mcp-wrapper/src/registry.ts
Normal file
56
mcp-wrapper/src/registry.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Lazy tool registry + context-editing config (TOK-02, TOK-05).
|
||||
//
|
||||
// TOK-02 ToolSearch lazy-load: in Phase 1 all 5 Phase-1 tools are hot (small
|
||||
// enough to always keep resident). The `loadColdTool` hook exists as a Phase-2
|
||||
// extension point -- when Mem-08 / schema_list / curiosity_pending ship (Phase
|
||||
// 2), they'll register here and be looked up
|
||||
// lazily by the MCP host's ToolSearch extension.
|
||||
//
|
||||
// TOK-05 context editing: we advertise `clear_tool_uses_20250919` with a
|
||||
// 30k-token trigger. When Claude's context crosses 30k tokens the Anthropic
|
||||
// API will drop earlier tool_use / tool_result messages, freeing headroom
|
||||
// for continued reasoning without reloading the full session prefix.
|
||||
//
|
||||
// Exact shape per Anthropic's context-management docs -- these strings are
|
||||
// consumed verbatim by the API.
|
||||
|
||||
import { TOOL_NAMES, toolSchemas, type ToolName } from "./tools.js";
|
||||
|
||||
// Phase-1 hot tools: all 5 always-resident (D-12 fixed surface).
|
||||
// Iteration order matches TOOL_NAMES so tools/list is deterministic.
|
||||
export const HOT_TOOLS: readonly ToolName[] = [...TOOL_NAMES] as const;
|
||||
|
||||
/** TOK-05 Anthropic context-editing config -- exact shape consumed by the API.
|
||||
*
|
||||
* `clear_tool_uses_20250919` is the dated context-edit strategy Anthropic
|
||||
* released on 2025-09-19; the trigger pairs `type: "input_tokens"` with a
|
||||
* numeric threshold that fires the edit. D-10 puts the threshold at 30k
|
||||
* tokens -- empirically enough headroom to preserve ~8-10 turns of tool
|
||||
* exchange before trimming. */
|
||||
export const CONTEXT_EDITING_CONFIG = {
|
||||
type: "clear_tool_uses_20250919" as const,
|
||||
trigger: {
|
||||
type: "input_tokens" as const,
|
||||
value: 30_000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Return the full tool-schema objects for the hot tools.
|
||||
*
|
||||
* MCP `tools/list` handler calls this directly. Kept as a function rather
|
||||
* than a const array so future versions can mutate the returned shape
|
||||
* (e.g., swap in per-user personalised descriptions) without changing the
|
||||
* call site. */
|
||||
export function listHotTools() {
|
||||
return HOT_TOOLS.map((n) => toolSchemas[n]);
|
||||
}
|
||||
|
||||
/** Phase-2 hook: lazy-load a tool that isn't in HOT_TOOLS.
|
||||
*
|
||||
* Phase 1 always returns null -- the MCP host's ToolSearch extension will
|
||||
* fall back to HOT_TOOLS when this returns null, which is exactly what we
|
||||
* want. Phase 2 populates this with a dynamic import of the new tool's
|
||||
* schema module. */
|
||||
export async function loadColdTool(_name: string): Promise<unknown | null> {
|
||||
return null;
|
||||
}
|
||||
367
mcp-wrapper/src/tools.ts
Normal file
367
mcp-wrapper/src/tools.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
// Phase-1 (D-12) + Plan 02-04 (MCP-05/07/08) + Plan 03 (CONN-05/07 + AUTIST-13) tools.
|
||||
//
|
||||
// Tool shapes are JSON-schema dicts consumable by the MCP SDK's ListTools
|
||||
// handler. Descriptions are written for Claude's tool-discovery heuristics
|
||||
// (concise, task-oriented, reference the autistic-kernel defaults where they
|
||||
// affect behaviour).
|
||||
//
|
||||
// Plan 02-04 adds 3 user-introspection tools:
|
||||
// - curiosity_pending (MCP-07): list pending curiosity questions
|
||||
// - schema_list (MCP-08): list induced schemas
|
||||
// - events_query (MCP-05): user-visible events audit
|
||||
//
|
||||
// Plan 03 adds 3 scientific-depth tools:
|
||||
// - memory_recall_structural (CONN-05): TEM role->filler structural recall
|
||||
// - topology (CONN-07): Ashby sigma diagnostic snapshot
|
||||
// - camouflaging_status (AUTIST-13): ecological self-regulation status
|
||||
|
||||
import type { PythonCoreBridge } from "./bridge.js";
|
||||
|
||||
export const TOOL_NAMES = [
|
||||
"memory_recall",
|
||||
"memory_recall_structural",
|
||||
"memory_reinforce",
|
||||
"memory_contradict",
|
||||
"memory_capture",
|
||||
"memory_consolidate",
|
||||
"profile_get_set",
|
||||
"curiosity_pending",
|
||||
"schema_list",
|
||||
"events_query",
|
||||
"topology",
|
||||
"camouflaging_status",
|
||||
] as const;
|
||||
|
||||
export type ToolName = (typeof TOOL_NAMES)[number];
|
||||
|
||||
interface ToolSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const toolSchemas: Record<ToolName, ToolSchema> = {
|
||||
memory_recall: {
|
||||
name: "memory_recall",
|
||||
description:
|
||||
"Recall verbatim memories matching cue. Returns hits + anti_hits.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
cue: {
|
||||
type: "string",
|
||||
description: "Natural-language query to match against stored memories.",
|
||||
},
|
||||
budget_tokens: {
|
||||
type: "integer",
|
||||
description: "Soft token budget for response (default 1500).",
|
||||
default: 1500,
|
||||
},
|
||||
session_id: {
|
||||
type: "string",
|
||||
description:
|
||||
"Current session id; gets written into every recalled record's provenance (MEM-05).",
|
||||
},
|
||||
cue_embedding: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
description:
|
||||
"Optional pre-computed embedding vector for the cue " +
|
||||
"(EMBED_DIM=384 floats; bge-small-en-v1.5). " +
|
||||
"When omitted, the daemon embeds the cue server-side. " +
|
||||
"Used by memory_contradict and tests that need byte-stable embeddings.",
|
||||
},
|
||||
language: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional ISO-639-1 language hint for the sleep-suggestion path " +
|
||||
"(8 supported: en/ru/ja/ar/de/fr/es/zh). Defaults to 'en' " +
|
||||
"when omitted. Hot-path retrieval is language-agnostic; this " +
|
||||
"key only affects the sleep-suggestion regex pre-screen.",
|
||||
},
|
||||
},
|
||||
required: ["cue"],
|
||||
},
|
||||
},
|
||||
memory_reinforce: {
|
||||
name: "memory_reinforce",
|
||||
description:
|
||||
"Boost Hebbian edges among co-retrieved record ids.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ids: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "uuid" },
|
||||
description: "Record UUIDs that were co-retrieved in the current context.",
|
||||
},
|
||||
},
|
||||
required: ["ids"],
|
||||
},
|
||||
},
|
||||
memory_contradict: {
|
||||
name: "memory_contradict",
|
||||
description:
|
||||
"Mark a record contradicted; new fact stored as new record.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
format: "uuid",
|
||||
description: "UUID of the record being contradicted.",
|
||||
},
|
||||
new_fact: {
|
||||
type: "string",
|
||||
description: "The updated verbatim fact. Stored as a new record.",
|
||||
},
|
||||
cue_embedding: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
description:
|
||||
"Optional pre-computed embedding vector for the contradicting " +
|
||||
"fact (EMBED_DIM=384 floats; bge-small-en-v1.5). When omitted, " +
|
||||
"the daemon embeds new_fact server-side.",
|
||||
},
|
||||
},
|
||||
required: ["id", "new_fact"],
|
||||
},
|
||||
},
|
||||
memory_capture: {
|
||||
name: "memory_capture",
|
||||
description:
|
||||
"Capture a verbatim turn. Auto-dedups at cos>=0.95 (reinforces). " +
|
||||
"Use for corrections + load-bearing decisions.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: {
|
||||
type: "string",
|
||||
description:
|
||||
"Verbatim text to capture (user utterance, Claude decision, or observation). " +
|
||||
"Min 12 chars, max 8000 (longer is truncated).",
|
||||
},
|
||||
cue: {
|
||||
type: "string",
|
||||
description:
|
||||
"Short natural-language cue used for embedding + dedup lookup. " +
|
||||
"If empty, `text` itself is embedded.",
|
||||
},
|
||||
tier: {
|
||||
type: "string",
|
||||
enum: ["working", "episodic", "semantic", "procedural", "parametric"],
|
||||
default: "episodic",
|
||||
description:
|
||||
"Memory tier. Default 'episodic' (verbatim user utterances). " +
|
||||
"Use 'semantic' for induced summaries, 'procedural' for learned behaviour notes.",
|
||||
},
|
||||
session_id: {
|
||||
type: "string",
|
||||
description: "Current session id for provenance (MEM-05).",
|
||||
},
|
||||
role: {
|
||||
type: "string",
|
||||
enum: ["user", "assistant", "system"],
|
||||
default: "user",
|
||||
description: "Who produced this turn — tags the record for filtering.",
|
||||
},
|
||||
},
|
||||
required: ["text"],
|
||||
},
|
||||
},
|
||||
memory_consolidate: {
|
||||
name: "memory_consolidate",
|
||||
description:
|
||||
"Trigger memory consolidation.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
session_id: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional session id used for provenance tagging on the " +
|
||||
"consolidate event. Defaults to '-' when omitted.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
profile_get_set: {
|
||||
name: "profile_get_set",
|
||||
description:
|
||||
"Read or write a profile knob (11 sealed: 10 AUTIST + wake_depth). operation: get|set.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
operation: {
|
||||
type: "string",
|
||||
enum: ["get", "set"],
|
||||
description: "Whether to read or write a knob.",
|
||||
},
|
||||
knob: {
|
||||
type: "string",
|
||||
description: "Knob name. Omit on 'get' to retrieve all live + deferred knobs.",
|
||||
},
|
||||
value: {
|
||||
description: "New value when operation='set'. Any JSON-serialisable type.",
|
||||
},
|
||||
},
|
||||
required: ["operation"],
|
||||
},
|
||||
},
|
||||
curiosity_pending: {
|
||||
name: "curiosity_pending",
|
||||
description:
|
||||
"List pending curiosity questions. Optional session_id filter.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
session_id: {
|
||||
type: "string",
|
||||
description: "Only return questions from this session.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
schema_list: {
|
||||
name: "schema_list",
|
||||
description:
|
||||
"List induced schemas. Optional domain + confidence_min filters.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
domain: {
|
||||
type: "string",
|
||||
description: "Only return schemas tagged with this domain (e.g. 'coding').",
|
||||
},
|
||||
confidence_min: {
|
||||
type: "number",
|
||||
description: "Minimum parsed confidence (0.0-1.0). Default 0.0.",
|
||||
default: 0.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events_query: {
|
||||
name: "events_query",
|
||||
description:
|
||||
"Query user-visible events by kind, since, severity, limit.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: {
|
||||
type: "string",
|
||||
description:
|
||||
"Event kind. Must be in the whitelist (see tool description).",
|
||||
},
|
||||
since: {
|
||||
type: "string",
|
||||
description: "ISO-8601 timestamp; only events at or after this are returned.",
|
||||
},
|
||||
severity: {
|
||||
type: "string",
|
||||
enum: ["info", "warning", "critical"],
|
||||
description: "Optional severity filter.",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: "Maximum events returned (default 100, capped at 1000).",
|
||||
default: 100,
|
||||
},
|
||||
},
|
||||
required: ["kind"],
|
||||
},
|
||||
},
|
||||
memory_recall_structural: {
|
||||
name: "memory_recall_structural",
|
||||
description:
|
||||
"Structural recall via role-filler bindings (TEM). O(N) scan; max_records caps.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
structure_query: {
|
||||
type: "object",
|
||||
description:
|
||||
"Optional role->filler map, e.g. {\"agent\": \"Alice\"}. Each value is hashed to a filler hypervector. When omitted or empty, query HV is zero-filled and every row with structure_hv is scored (expensive at large N).",
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
budget_tokens: {
|
||||
type: "integer",
|
||||
description: "Soft token budget for response (default 2000).",
|
||||
default: 2000,
|
||||
},
|
||||
max_records: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Hard cap on records scanned after fetch (default 5000, max 50000). Prevents accidental full-corpus scans from `{}`.",
|
||||
default: 5000,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
topology: {
|
||||
name: "topology",
|
||||
description:
|
||||
"Topology snapshot: N, C, L, sigma, community_count, regime.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
camouflaging_status: {
|
||||
name: "camouflaging_status",
|
||||
description:
|
||||
"Camouflaging detection status; window_size weekly points.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
window_size: {
|
||||
type: "integer",
|
||||
description: "Weekly points in the sliding window (default 5).",
|
||||
default: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function invokeTool(
|
||||
bridge: PythonCoreBridge,
|
||||
name: ToolName,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
switch (name) {
|
||||
case "memory_recall":
|
||||
return bridge.call("memory_recall", args);
|
||||
case "memory_reinforce":
|
||||
return bridge.call("memory_reinforce", args);
|
||||
case "memory_contradict":
|
||||
return bridge.call("memory_contradict", args);
|
||||
case "memory_capture":
|
||||
return bridge.call("memory_capture", args);
|
||||
case "memory_consolidate":
|
||||
return bridge.call("memory_consolidate", args);
|
||||
case "profile_get_set": {
|
||||
const op = args.operation as string;
|
||||
if (op === "get") {
|
||||
return bridge.call("profile_get", { knob: args.knob ?? null });
|
||||
}
|
||||
if (op === "set") {
|
||||
return bridge.call("profile_set", {
|
||||
knob: args.knob,
|
||||
value: args.value,
|
||||
});
|
||||
}
|
||||
throw new Error(`unknown operation ${op}`);
|
||||
}
|
||||
case "curiosity_pending":
|
||||
return bridge.call("curiosity_pending", args);
|
||||
case "schema_list":
|
||||
return bridge.call("schema_list", args);
|
||||
case "events_query":
|
||||
return bridge.call("events_query", args);
|
||||
case "memory_recall_structural":
|
||||
return bridge.call("memory_recall_structural", args);
|
||||
case "topology":
|
||||
return bridge.call("topology", args);
|
||||
case "camouflaging_status":
|
||||
return bridge.call("camouflaging_status", args);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue