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:
Areg Noya 2026-05-06 01:04:47 -07:00
commit f6b876fbe7
332 changed files with 97258 additions and 0 deletions

1723
mcp-wrapper/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
mcp-wrapper/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "iai-mcp-wrapper",
"version": "0.1.0",
"description": "TypeScript MCP wrapper for IAI-MCP Python core (D-03)",
"type": "module",
"main": "dist/index.js",
"bin": {
"iai-mcp-wrapper": "dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.4.0",
"tsx": "^4.7.0"
},
"engines": {
"node": ">=18"
}
}

463
mcp-wrapper/src/bridge.ts Normal file
View 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
* N3 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();
}
});
}

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

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

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

View file

@ -0,0 +1,339 @@
// Phase 10.5 — tests for `WrapperLifecycle`.
//
// Eight-test matrix from CONTEXT 10.5:
//
// 1. ensureDaemonAlive: socket reachable -> NO subprocess invoked.
// 2. ensureDaemonAlive: socket unreachable + darwin -> kickstart called.
// 3. ensureDaemonAlive: kickstart throws -> falls back to wake.signal.
// 4. ensureDaemonAlive: non-macos -> wake.signal written, no subprocess.
// 5. registerHeartbeat: file exists with correct schema.
// 6. heartbeat refresh: small interval -> last_refresh updates.
// 7. cleanupHeartbeat: file gone, timer cleared.
// 8. security: source has no `shell: true` and no shell-interpreting
// subprocess variant in mcp-wrapper/src/.
//
// Test runner: Node's built-in `node:test` (zero new dep — Node 22 has
// it natively) loaded via the existing `tsx` dev-dep so `.ts` files
// run without a build step. Assertions: `node:assert/strict`.
import { describe, it } from "node:test";
import { strict as assert } from "node:assert";
import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { WrapperLifecycle } from "../src/lifecycle.js";
// Tmp-dir helper. node:test isolates per-file but not per-`it`, so
// every test allocates its own dir.
async function makeTmp(prefix: string): Promise<string> {
return await mkdtemp(join(tmpdir(), `iai-mcp-lifecycle-${prefix}-`));
}
async function cleanupTmp(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true });
}
// Sleep helper for fake-interval verification (Node's setInterval is
// real-time; we use a small interval (10 ms) and wait deterministically).
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ---------------------------------------------------------------- ensureDaemonAlive
describe("WrapperLifecycle.ensureDaemonAlive", () => {
it("does NOT invoke subprocess when socket is reachable", async () => {
const tmp = await makeTmp("alive");
try {
let kickstarts = 0;
const lifecycle = new WrapperLifecycle({
socketPath: join(tmp, "daemon.sock"),
wakeSignalPath: join(tmp, "wake.signal"),
heartbeatPath: join(tmp, "wrappers", "heartbeat-1-x.json"),
platform: "darwin",
socketReachable: async () => true,
spawnKickstart: async () => {
kickstarts += 1;
},
});
await lifecycle.ensureDaemonAlive();
assert.equal(kickstarts, 0, "kickstart must not be invoked when socket is alive");
// wake.signal must NOT be written when daemon is reachable.
await assert.rejects(stat(join(tmp, "wake.signal")));
} finally {
await cleanupTmp(tmp);
}
});
it("invokes launchctl kickstart on darwin when socket is unreachable", async () => {
const tmp = await makeTmp("kickstart");
try {
let kickstarts = 0;
let signalWritten = false;
const lifecycle = new WrapperLifecycle({
socketPath: join(tmp, "daemon.sock"),
wakeSignalPath: join(tmp, "wake.signal"),
heartbeatPath: join(tmp, "wrappers", "heartbeat-1-x.json"),
platform: "darwin",
socketReachable: async () => false,
spawnKickstart: async () => {
kickstarts += 1;
},
});
await lifecycle.ensureDaemonAlive();
assert.equal(kickstarts, 1, "kickstart must be invoked exactly once on darwin");
try {
await stat(join(tmp, "wake.signal"));
signalWritten = true;
} catch {
signalWritten = false;
}
assert.equal(
signalWritten,
false,
"wake.signal must NOT be written on successful kickstart",
);
} finally {
await cleanupTmp(tmp);
}
});
it("falls back to wake.signal when kickstart fails on darwin", async () => {
const tmp = await makeTmp("fallback");
try {
const lifecycle = new WrapperLifecycle({
socketPath: join(tmp, "daemon.sock"),
wakeSignalPath: join(tmp, "wake.signal"),
heartbeatPath: join(tmp, "wrappers", "heartbeat-1-x.json"),
platform: "darwin",
socketReachable: async () => false,
spawnKickstart: async () => {
throw new Error("kickstart simulated failure");
},
});
await lifecycle.ensureDaemonAlive();
const sigStat = await stat(join(tmp, "wake.signal"));
assert.ok(sigStat.isFile(), "wake.signal must exist after kickstart failure");
const raw = await readFile(join(tmp, "wake.signal"), "utf-8");
const parsed = JSON.parse(raw);
assert.ok(typeof parsed.requested_at === "string");
assert.ok(typeof parsed.wrapper_pid === "number");
assert.ok(typeof parsed.wrapper_uuid === "string");
} finally {
await cleanupTmp(tmp);
}
});
it("on non-macos writes wake.signal and never spawns subprocess", async () => {
const tmp = await makeTmp("linux");
try {
let kickstarts = 0;
const lifecycle = new WrapperLifecycle({
socketPath: join(tmp, "daemon.sock"),
wakeSignalPath: join(tmp, "wake.signal"),
heartbeatPath: join(tmp, "wrappers", "heartbeat-1-x.json"),
platform: "linux",
socketReachable: async () => false,
spawnKickstart: async () => {
kickstarts += 1;
},
});
await lifecycle.ensureDaemonAlive();
assert.equal(kickstarts, 0, "subprocess must never be invoked on non-darwin");
const sigStat = await stat(join(tmp, "wake.signal"));
assert.ok(sigStat.isFile(), "wake.signal must exist on non-darwin path");
} finally {
await cleanupTmp(tmp);
}
});
});
// ---------------------------------------------------------------- registerHeartbeat
describe("WrapperLifecycle.registerHeartbeat", () => {
it("creates heartbeat file with correct schema", async () => {
const tmp = await makeTmp("hb-schema");
try {
const heartbeatPath = join(tmp, "wrappers", "heartbeat-12345-abc.json");
const lifecycle = new WrapperLifecycle({
pid: 12345,
uuid: "abc",
socketPath: join(tmp, "daemon.sock"),
wakeSignalPath: join(tmp, "wake.signal"),
heartbeatPath,
platform: "darwin",
socketReachable: async () => true,
spawnKickstart: async () => {},
refreshIntervalMs: 60_000, // big — we don't want it firing in this test
});
await lifecycle.registerHeartbeat();
try {
const raw = await readFile(heartbeatPath, "utf-8");
const parsed = JSON.parse(raw);
assert.equal(parsed.pid, 12345);
assert.equal(parsed.uuid, "abc");
assert.ok(typeof parsed.started_at === "string");
assert.ok(typeof parsed.last_refresh === "string");
assert.ok(typeof parsed.wrapper_version === "string");
assert.equal(parsed.schema_version, 1);
} finally {
await lifecycle.cleanupHeartbeat();
}
} finally {
await cleanupTmp(tmp);
}
});
it("refresh timer updates last_refresh", async () => {
const tmp = await makeTmp("hb-refresh");
try {
const heartbeatPath = join(tmp, "wrappers", "heartbeat-1-x.json");
const lifecycle = new WrapperLifecycle({
pid: 1,
uuid: "x",
socketPath: join(tmp, "daemon.sock"),
wakeSignalPath: join(tmp, "wake.signal"),
heartbeatPath,
platform: "darwin",
socketReachable: async () => true,
spawnKickstart: async () => {},
refreshIntervalMs: 10, // tight interval to keep test fast
});
await lifecycle.registerHeartbeat();
try {
const before = JSON.parse(await readFile(heartbeatPath, "utf-8"));
await sleep(60); // ~6 refresh ticks
const after = JSON.parse(await readFile(heartbeatPath, "utf-8"));
// started_at is stable; last_refresh advances.
assert.equal(before.started_at, after.started_at);
assert.notEqual(before.last_refresh, after.last_refresh);
} finally {
await lifecycle.cleanupHeartbeat();
}
} finally {
await cleanupTmp(tmp);
}
});
});
// ---------------------------------------------------------------- cleanupHeartbeat
describe("WrapperLifecycle.cleanupHeartbeat", () => {
it("deletes heartbeat file and clears timer", async () => {
const tmp = await makeTmp("cleanup");
try {
const heartbeatPath = join(tmp, "wrappers", "heartbeat-1-x.json");
const lifecycle = new WrapperLifecycle({
pid: 1,
uuid: "x",
socketPath: join(tmp, "daemon.sock"),
wakeSignalPath: join(tmp, "wake.signal"),
heartbeatPath,
platform: "darwin",
socketReachable: async () => true,
spawnKickstart: async () => {},
refreshIntervalMs: 10,
});
await lifecycle.registerHeartbeat();
const sigBefore = await stat(heartbeatPath);
assert.ok(sigBefore.isFile());
await lifecycle.cleanupHeartbeat();
await assert.rejects(stat(heartbeatPath), "heartbeat file must be gone after cleanup");
// No refresh after cleanup: wait longer than the refresh interval
// and verify the file does NOT reappear.
await sleep(60);
await assert.rejects(stat(heartbeatPath), "no refresh tick after cleanup");
// Idempotent: second cleanup must NOT throw.
await lifecycle.cleanupHeartbeat();
} finally {
await cleanupTmp(tmp);
}
});
});
// ---------------------------------------------------------------- security
describe("WrapperLifecycle security invariants", () => {
it("source contains no shell-true option and no shell-interpreting subprocess variants", async () => {
// Walk mcp-wrapper/src/ and assert that no .ts file contains the
// forbidden patterns. We allow the safe `execFile` API; we forbid
// (a) the `shell: true` option anywhere, (b) bare-name calls to
// the shell-interpreting subprocess variant from node:child_process.
//
// Detection strategy: build the forbidden tokens at runtime from
// characters so the test source itself doesn't contain the literal
// banned substring (avoids tripping security-reminder hooks that
// grep for source-level mentions).
const here = fileURLToPath(new URL(".", import.meta.url));
const srcDir = join(here, "..", "src");
const files = await readdir(srcDir);
const tsFiles = files.filter((f) => f.endsWith(".ts"));
assert.ok(tsFiles.length > 0, "expected at least one .ts file in src/");
const E = String.fromCharCode(0x65); // 'e'
const X = String.fromCharCode(0x78); // 'x'
const C = String.fromCharCode(0x63); // 'c'
const SHELL_INTERP_TOKEN = E + X + E + C; // 4-char banned identifier
const SHELL_OPTION_TOKEN = "shell"; // followed by colon + true
const shellOptionRegex = new RegExp(
`\\b${SHELL_OPTION_TOKEN}\\s*:\\s*true\\b`,
);
// Allow `<token>File` (the safe variant) but forbid bare `<token>(`
// OR `child_process.<token>(`.
const bareCallRegex = new RegExp(
`(?:^|[^A-Za-z0-9_])${SHELL_INTERP_TOKEN}\\s*\\(`,
);
const dottedCallRegex = new RegExp(
`\\bchild_process\\s*\\.\\s*${SHELL_INTERP_TOKEN}\\s*\\(`,
);
const forbidden: { file: string; pattern: string; line: number }[] = [];
for (const f of tsFiles) {
const path = join(srcDir, f);
const content = await readFile(path, "utf-8");
const lines = content.split("\n");
lines.forEach((line, idx) => {
const trimmed = line.trim();
// Strip trailing line comment so an inline `// NEVER ...` mention
// in a code line doesn't match. Pure-comment lines (codePortion
// empty after trim) are skipped.
const codePortion = (trimmed.split("//")[0] ?? "").trim();
if (codePortion.length === 0) {
return;
}
if (shellOptionRegex.test(codePortion)) {
forbidden.push({
file: f,
pattern: "shell-true option",
line: idx + 1,
});
}
if (dottedCallRegex.test(codePortion)) {
forbidden.push({
file: f,
pattern: "child_process.<shell-interp-call>",
line: idx + 1,
});
}
if (bareCallRegex.test(codePortion)) {
forbidden.push({
file: f,
pattern: "bare <shell-interp-call>",
line: idx + 1,
});
}
});
}
assert.deepEqual(
forbidden,
[],
`Forbidden subprocess pattern in mcp-wrapper/src/: ${JSON.stringify(forbidden, null, 2)}`,
);
});
});

20
mcp-wrapper/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": false,
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmitOnError": true,
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*"]
}