/** * Tool registry — the agent's narrow, project-scoped tool surface. * * Unlike a general "Claude Code", the Hub agent operates on a curriculum * engineering-file tree (ADR-0007) and reads Feishu context on demand * (ADR-0003). Its tools are therefore bounded: * * - file-tree read/write against the project workspace dir, * - `cph check` / `cph build` subprocess calls (the Courseware-side checker, * coupled only via the stable CLI contract ADR-0016), * - Feishu context reads, which must honor ADR-0003's authorization invariant. * * That last invariant is encoded here: a Feishu context tool handler rejects any * chat id that is not the run's project's bound chat (ADR-0001 binding). This is * `McpReadRequest.Authorized` from `spec/System/Memory.lean` landing in code. */ import type { ToolSpec } from "./provider.js"; /** Per-run execution context handed to every tool handler. */ export interface ToolContext { readonly runId: string; readonly projectId: string; /** ADR-0001: the chat this project is bound to. Feishu reads must stay in it. */ readonly boundChatId: string; /** Absolute path to the project's curriculum engineering-file workspace. */ readonly workspaceDir: string; } /** A tool handler: receives parsed args + run context, returns content for the model. */ export type ToolHandler = (args: unknown, ctx: ToolContext) => Promise; export interface RegisteredTool { readonly spec: ToolSpec; readonly execute: ToolHandler; } export class ToolRegistry { private readonly byName = new Map(); register(tool: RegisteredTool): void { if (this.byName.has(tool.spec.name)) { throw new Error(`duplicate tool: ${tool.spec.name}`); } this.byName.set(tool.spec.name, tool); } specs(): readonly ToolSpec[] { return [...this.byName.values()].map((t) => t.spec); } has(name: string): boolean { return this.byName.has(name); } /** Execute a tool by name. Throws on unknown tool or authorization failure. */ async execute(name: string, args: unknown, ctx: ToolContext): Promise { const tool = this.byName.get(name); if (tool === undefined) { return JSON.stringify({ error: `unknown tool: ${name}` }); } return tool.execute(args, ctx); } } /** * Thrown when a Feishu context read targets a chat other than the run's project's * bound chat. This is the runtime enforcement of ADR-0003's * `McpReadRequest.Authorized`: "Claude cannot pass arbitrary chat ids". */ export class UnauthorizedChatRead extends Error { constructor( readonly requestedChatId: string, readonly boundChatId: string, ) { super( `refused Feishu context read: requested chat ${requestedChatId} is not the run's project's bound chat ${boundChatId} (ADR-0003)`, ); this.name = "UnauthorizedChatRead"; } } /** Schema for the Feishu context read tool's arguments. */ export interface FeishuContextArgs { readonly chat_id: string; readonly anchor: "trigger_message" | "status_card" | "reply" | "thread"; readonly id: string; } /** * Build the Feishu context-read tool. The handler enforces ADR-0003's invariant * before any API call: the requested `chat_id` must equal `ctx.boundChatId`. * * The actual Feishu API call (read message / reply chain / thread / recent * group messages) is injected as `read`, so the invariant check is separable * from the transport. In tests `read` can be a stub; in production it wraps * `@larksuiteoapi/node-sdk`. */ export function feishuContextTool( read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise, rt: unknown, ): RegisteredTool { return { spec: { name: "feishu_read_context", description: "Read on-demand Feishu context (a trigger message, status card, reply, or thread) for the current project's bound chat. The chat id must match the project binding.", parameters: { type: "object", properties: { chat_id: { type: "string", description: "The Feishu chat id to read from." }, anchor: { type: "string", enum: ["trigger_message", "status_card", "reply", "thread"], description: "Which kind of anchor to read.", }, id: { type: "string", description: "The anchor id (message id or run id)." }, }, required: ["chat_id", "anchor", "id"], }, }, async execute(args, ctx): Promise { const a = args as FeishuContextArgs; if (a.chat_id !== ctx.boundChatId) { throw new UnauthorizedChatRead(a.chat_id, ctx.boundChatId); } return read(a, ctx, rt); }, }; }