@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
326 lines (325 loc) • 14.2 kB
JavaScript
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;
}