UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

326 lines (325 loc) • 14.2 kB
import { toolOwner, disabledHint } from "../modules/registry.js"; /** * MCP tool profiles. * * The server used to hand every client 59 tools, about 11k tokens of schema * before a word was said, and 59 similar verbs to choose the wrong one from. * The `core` profile (the default) exposes the handful an agent reaches for * during normal work plus one `phren_admin` tool that reaches everything * else by name. `full` is the old surface for anyone who scripted against * it, and it also gains the composites. * * Nothing here knows how a tool works. Modules register tools exactly as * before; the server's registerTool wrapper records each one in a catalog and * only exposes the core ones, and the composites built here dispatch into * that catalog, validating against the target tool's own schema first. */ import { z } from "zod"; import { readInstallPreferences } from "../init/preferences.js"; export const MCP_PROFILES = ["core", "full"]; /** What an agent gets in the core profile. Order is the order clients list them. */ export const CORE_TOOLS = [ "search_knowledge", "get_memory_detail", "get_project_summary", "add_finding", "revise_finding", "get_tasks", "add_task", "manage_task", "session", "phren_admin", ]; /** Env wins, then the install preference, then core. */ export function resolveMcpProfile(phrenPath, env = process.env) { const fromEnv = env.PHREN_MCP_PROFILE?.trim().toLowerCase(); if (fromEnv === "core" || fromEnv === "full") return fromEnv; try { const prefs = readInstallPreferences(phrenPath); if (prefs.mcpProfile === "core" || prefs.mcpProfile === "full") return prefs.mcpProfile; } catch { // No preferences yet: the default applies. } return "core"; } const COMPOSITES = { revise_finding: { title: "◆ phren · revise finding", summary: "Change an existing finding: supersede, retract, edit, remove, link, resolve a contradiction, pin, or give feedback on it. Pass `action` plus that action's own parameters.", actions: { supersede: "supersede_finding", retract: "retract_finding", edit: "edit_finding", remove: "remove_finding", link: "link_findings", resolve_contradiction: "resolve_contradiction", pin: "pin_memory", feedback: "memory_feedback", }, }, manage_task: { title: "◆ phren · manage task", summary: "Change an existing task: complete, update, remove, pin, claim for this computer, or tidy the done list. Pass `action` plus that action's own parameters.", actions: { complete: "complete_task", claim: "claim_task", update: "update_task", remove: "remove_task", pin: "pin_task", tidy: "tidy_done_tasks", }, }, session: { title: "◆ phren · session", summary: "Session lifecycle: start, end, current context, or history. Pass `action` plus that action's own parameters.", actions: { start: "session_start", end: "session_end", context: "session_context", history: "session_history", }, }, }; /** The tools a composite stands for, so the admin tool does not list them twice. */ function foldedNames() { const out = new Set(["add_note"]); // reachable as add_finding with kind: "note" for (const c of Object.values(COMPOSITES)) for (const target of Object.values(c.actions)) out.add(target); return out; } /** * Notes fold into add_finding: `kind: "note"` saves a lightweight daily note * instead of a durable finding. The note tool keeps its own schema and * handler; this only adds the switch and routes through the catalog, so the * note module can register before or after the finding module. */ export function decorateAddFinding(entry, catalog) { const schema = schemaOf(entry); if (!schema) return entry; const config = { ...entry.config, description: `${entry.config.description ?? ""} Pass kind: "note" to save a lightweight daily note instead (goes to the project's notes, not FINDINGS.md).`, inputSchema: schema.extend({ kind: z.enum(["finding", "note"]).optional().describe("'finding' (default) is durable, curated knowledge; 'note' is a lightweight daily note."), date: z.string().optional().describe("Notes only: YYYY-MM-DD, defaults to today."), }), }; const handler = (args) => { const { kind, date, ...rest } = args; if (kind === "note") { const finding = rest.finding; const text = Array.isArray(finding) ? finding.map(String).join("\n\n") : String(finding ?? ""); return dispatch(catalog, "add_note", { project: rest.project, text, ...(date ? { date } : {}) }); } return entry.handler(rest); }; return { name: entry.name, config, handler }; } /** * Sits between the tool modules and the real server. Modules keep calling * registerTool exactly as before; the gate keeps the catalog, exposes core * tools (or everything, in `full`), marks core tools always-loaded for Claude * Code, and registers the composites when `finish` is called. */ export function createToolGate(opts) { const allowed = opts.modules && new Set(opts.modules.flatMap(module => module.tools.map(tool => tool.name))); const core = opts.modules ? CORE_TOOLS.filter(name => opts.modules.some(module => module.tools.some(tool => tool.name === name && tool.profiles.includes("core")))) : CORE_TOOLS; const alwaysLoad = new Set([...core, ...(opts.alwaysLoad ?? [])]); const catalog = new Map(); const exposed = new Set(); const expose = (name, config, handler) => { let finalConfig = config; if (alwaysLoad.has(name)) { const meta = config._meta ?? {}; finalConfig = { ...config, _meta: { ...meta, "anthropic/alwaysLoad": true } }; } opts.register(name, finalConfig, handler); exposed.add(name); }; const registerTool = (name, config, handler) => { if (allowed && !allowed.has(name)) return; if (catalog.has(name)) throw new Error(`Duplicate MCP tool registration: "${name}"`); const wrapped = opts.wrap ? opts.wrap(name, handler) : handler; let entry = { name, config, handler: wrapped }; if (name === "add_finding") entry = decorateAddFinding(entry, catalog); catalog.set(name, entry); if (opts.profile === "core" && !core.includes(name)) return; expose(name, entry.config, entry.handler); }; const finish = () => { for (const tool of buildCompositeTools(catalog)) { if (catalog.has(tool.name) || (allowed && !allowed.has(tool.name))) continue; catalog.set(tool.name, { name: tool.name, config: tool.config, handler: tool.handler }); expose(tool.name, tool.config, tool.handler); } }; return { registerTool, finish, catalog, exposed, profile: opts.profile }; } function schemaOf(entry) { const raw = entry.config.inputSchema; if (!raw) return null; if (raw instanceof z.ZodObject) return raw; if (typeof raw === "object") return z.object(raw); return null; } function paramDocs(entry) { const schema = schemaOf(entry); if (!schema) return []; return Object.entries(schema.shape).map(([name, type]) => { const t = type; return { name, required: !t.isOptional(), description: t.description ?? "" }; }); } function firstSentence(text) { const flat = (text ?? "").replace(/\s+/g, " ").trim(); const end = flat.search(/\.\s|\.$/); const s = end === -1 ? flat : flat.slice(0, end + 1); return s.length > 140 ? `${s.slice(0, 139)}…` : s; } function actionLine(action, entry) { const required = paramDocs(entry).filter((p) => p.required).map((p) => p.name); const req = required.length ? ` (required: ${required.join(", ")})` : ""; return `${action} — ${firstSentence(entry.config.description)}${req}`; } function errorResponse(error, extra = {}) { return { content: [{ type: "text", text: JSON.stringify({ ok: false, error, ...extra }, null, 2) }] }; } /** * A composite's schema is `passthrough`, so the host serializes whatever it * does not recognize; Claude Code hands nested objects (`updates`, `settings`) * over as JSON strings, sometimes wrapped twice. A JSON-looking string whose * decode validates replaces the raw string; so does a decode that does not * validate when the raw string never would, so the miss reports its real inner * path instead of "expected object, received string". A string the field * accepts as-is (JSON that is genuinely text) and a string that is not JSON * are left to the schema's own error. The same untyped schema turns a number * or a boolean into its text ("5", "true"); that text is decoded only when the * field rejects it and accepts the decoded value. */ function decodeJsonArguments(schema, args) { const out = { ...args }; for (const [key, value] of Object.entries(args)) { if (typeof value !== "string") continue; const field = schema.shape[key]; if (!field) continue; const text = value.trim(); if (/^(?:true|false|-?\d+(?:\.\d+)?)$/.test(text)) { const primitive = text === "true" ? true : text === "false" ? false : Number(text); if (!field.safeParse(value).success && field.safeParse(primitive).success) out[key] = primitive; continue; } if (!/^\s*[[{"]/.test(value)) continue; let decoded = value; for (let hop = 0; hop < 3; hop++) { if (typeof decoded !== "string" || !/^\s*[[{"]/.test(decoded)) break; try { decoded = JSON.parse(decoded); } catch { decoded = undefined; break; } } if (decoded === undefined || decoded === null || typeof decoded !== "object") continue; if (field.safeParse(decoded).success || !field.safeParse(value).success) out[key] = decoded; } return out; } /** Run a catalog tool by name after validating the arguments against its own schema. */ export async function dispatch(catalog, target, args) { const entry = catalog.get(target); if (!entry) { const owner = toolOwner(target); return errorResponse(`Unknown tool "${target}"${owner ? `: ${disabledHint(owner.name)}` : ""}`, { errorCode: "UNAVAILABLE" }); } const schema = schemaOf(entry); if (schema) { const parsed = schema.safeParse(decodeJsonArguments(schema, args)); if (!parsed.success) { return errorResponse(`Invalid arguments for ${target}`, { issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })), params: paramDocs(entry), }); } return entry.handler(parsed.data); } return entry.handler(args); } /** * The composite tools for a catalog. An action that has no registered target * (a module not loaded, say) is left out rather than advertised. */ export function buildCompositeTools(catalog) { const out = []; for (const [name, composite] of Object.entries(COMPOSITES)) { const actions = Object.entries(composite.actions).filter(([, target]) => catalog.has(target)); if (!actions.length) continue; const lines = actions.map(([action, target]) => actionLine(action, catalog.get(target))); const actionNames = actions.map(([action]) => action); const map = Object.fromEntries(actions); out.push({ name, config: { title: composite.title, description: `${composite.summary}\nActions:\n${lines.map((l) => `- ${l}`).join("\n")}`, inputSchema: z.object({ action: z.string().describe(`Which change to make: ${actionNames.join(", ")}.`) }).passthrough(), }, handler: (args) => { const { action, ...rest } = args; return dispatch(catalog, map[action] ?? composite.actions[action] ?? action, rest); }, }); } const folded = foldedNames(); const adminTargets = [...catalog.keys()] .filter((n) => !CORE_TOOLS.includes(n) && !folded.has(n) && !(n in COMPOSITES)) .sort(); if (adminTargets.length) { const lines = adminTargets.map((n) => actionLine(n, catalog.get(n))); const actionNames = ["list_actions", ...adminTargets]; out.push({ name: "phren_admin", config: { title: "◆ phren · admin", description: "Everything else phren can do — skills, hooks, config, notes, review queue, export/import, doctor, the fragment graph — behind one tool. " + "Pass `action` plus that action's parameters; `list_actions` returns every action with its full parameter list.\nActions:\n" + lines.map((l) => `- ${l}`).join("\n"), inputSchema: z.object({ action: z.string().describe(`Which admin action to run: ${actionNames.join(", ")}.`) }).passthrough(), }, handler: (args) => { const { action, ...rest } = args; if (action === "list_actions") { const actions = adminTargets.map((n) => { const entry = catalog.get(n); return { name: n, description: entry.config.description ?? "", params: paramDocs(entry) }; }); return { content: [{ type: "text", text: JSON.stringify({ ok: true, actions }, null, 2) }] }; } return dispatch(catalog, action, rest); }, }); } return out; }