diff --git a/README.md b/README.md index 9ba0a08..7498601 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,28 @@ hooks --help - `hooks doctor` - `hooks run` +### Compact Output + +CLI commands default to compact, agent-friendly output. List and search commands +show essential fields, cap terminal rows, and print hints for deeper inspection. +Use detail flags when you need more context: + +```bash +hooks list # compact, capped list +hooks list --all # show all rows +hooks list --verbose # include descriptions +hooks search git --limit 5 # cap result rows +hooks info gitguard # full metadata for one hook +hooks docs gitguard # README preview +hooks docs gitguard --verbose +hooks list --json # stable machine-readable full data +``` + +MCP tools follow the same gradual disclosure pattern: list/search/log/profile +tools return compact summaries by default, while explicit flags such as +`compact:false`, `verbose:true`, or a detail tool like `hooks_info` return full +records. + ## Cloud Sync This package supports cloud sync via `@hasna/cloud`: diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 2fb5abb..70483db 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -74,12 +74,17 @@ describe("CLI", () => { describe("hooks list", () => { test("lists all hooks", async () => { const { stdout } = await run("list"); - expect(stdout).toContain("Available hooks (39)"); + expect(stdout).toContain("Available hooks (39, showing 20)"); expect(stdout).toContain("Git Safety"); expect(stdout).toContain("Code Quality"); expect(stdout).toContain("Security"); - expect(stdout).toContain("Notifications"); - expect(stdout).toContain("Context Management"); + expect(stdout).toContain("Showing a compact subset"); + expect(stdout).not.toContain("Blocks destructive git operations"); + }); + + test("--verbose includes descriptions", async () => { + const { stdout } = await run("list", "--limit", "1", "--verbose"); + expect(stdout).toContain("Blocks destructive git operations"); }); test("--json returns all hooks grouped by category", async () => { @@ -93,7 +98,7 @@ describe("CLI", () => { test("lists by category", async () => { const { stdout } = await run("list", "-c", "Security"); - expect(stdout).toContain("Security (2)"); + expect(stdout).toContain("Security (2, showing 2)"); expect(stdout).toContain("checksecurity"); expect(stdout).toContain("packageage"); }); @@ -132,6 +137,13 @@ describe("CLI", () => { const { stdout } = await run("search", "git"); expect(stdout).toContain("Found"); expect(stdout).toContain("gitguard"); + expect(stdout).not.toContain("Blocks destructive git operations"); + expect(stdout).not.toContain("--all"); + }); + + test("search --verbose includes descriptions", async () => { + const { stdout } = await run("search", "gitguard", "--verbose"); + expect(stdout).toContain("Blocks destructive git operations"); }); test("shows no results for bad query", async () => { @@ -320,6 +332,14 @@ describe("CLI", () => { expect(stdout).toContain("Git Guard"); expect(stdout).toContain("Configuration"); expect(stdout).toContain("Install"); + expect(stdout).toContain("README Preview"); + expect(stdout).toContain("Claude Code hook that blocks destructive git operations."); + expect(stdout).toContain("--verbose"); + }); + + test("hook-specific docs --verbose includes full README section", async () => { + const { stdout } = await run("docs", "gitguard", "--verbose"); + expect(stdout).toContain("README:"); }); test("--json for specific hook includes readme", async () => { @@ -690,7 +710,7 @@ describe("CLI", () => { } finally { restoreSettings(); } - }); + }, 15_000); }); describe("hooks info --json for every hook", () => { diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 4a100a4..4ea87d9 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -22,6 +22,7 @@ import { getHooksByCategory, searchHooks, getHook, + type HookMeta, } from "../lib/registry.js"; import { installHook, @@ -56,6 +57,46 @@ function resolveTarget(options: { target?: string }): "claude" | "gemini" | "all return "claude"; } +function parseLimit(value: string | undefined, fallback: number, max: number): number { + const parsed = value ? parseInt(value, 10) : fallback; + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, max); +} + +function truncateText(value: string | undefined, max = 96): string { + const text = (value ?? "").replace(/\s+/g, " ").trim(); + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 3))}...`; +} + +function readmePreview(readme: string, max = 280): string | undefined { + const blocks = readme + .split(/\n\s*\n/) + .map((part) => part.trim()) + .filter(Boolean) + .filter((part) => !part.startsWith("#")) + .filter((part) => !part.startsWith("```")) + .filter((part) => !/^\[!\[/.test(part)); + const preview = blocks[0] + ?? readme.split("\n").map((line) => line.trim()).find((line) => line && !line.startsWith("#")); + return preview ? truncateText(preview, max) : undefined; +} + +function hookSummaryLine(hook: HookMeta, options: { verbose?: boolean } = {}): string { + const matcher = hook.matcher ? ` ${hook.matcher}` : ""; + const description = options.verbose ? ` - ${truncateText(hook.description, 110)}` : ""; + return ` ${chalk.cyan(hook.name.padEnd(17))} ${chalk.dim(`[${hook.event}${matcher}]`)} ${chalk.dim(hook.category)}${description}`; +} + +function printDisclosureHint(hidden: number, detailCommand: string, options: { includeAll?: boolean } = {}): void { + const rowControls = options.includeAll ? "--limit, --all, --verbose" : "--limit, --verbose"; + if (hidden > 0) { + console.log(chalk.dim(`\n Showing a compact subset. ${hidden} more hidden; use ${rowControls}, or ${detailCommand}.`)); + } else { + console.log(chalk.dim(`\n Use --verbose or ${detailCommand} for details.`)); + } +} + /** Levenshtein distance for did-you-mean suggestions */ function editDistance(a: string, b: string): number { const m = a.length, n = b.length; @@ -318,10 +359,13 @@ program .option("-g, --global", "Check global settings", false) .option("-p, --project", "Check project settings", false) .option("-t, --target ", "Agent target: claude, gemini (default: claude)", "claude") + .option("-n, --limit ", "Max rows to show in compact output", "20") + .option("--verbose", "Show descriptions and full detail columns", false) .option("-j, --json", "Output as JSON", false) .description("List available or installed hooks") .action((options) => { const scope = resolveScope(options); + const limit = options.all ? Number.MAX_SAFE_INTEGER : parseLimit(options.limit, 20, 200); if (options.registered || options.installed) { const target = (options.target === "gemini" ? "gemini" : "claude") as "claude" | "gemini"; @@ -337,13 +381,14 @@ program console.log(chalk.dim(`No hooks registered (${scope}, ${target})`)); return; } - console.log(chalk.bold(`\nRegistered hooks — ${scope}/${target} (${registered.length}):\n`)); - for (const name of registered) { + const visible = registered.slice(0, limit); + console.log(chalk.bold(`\nRegistered hooks — ${scope}/${target} (${registered.length}, showing ${visible.length}):\n`)); + for (const name of visible) { const meta = getHook(name); - console.log( - ` ${chalk.cyan(name)} ${chalk.dim(`[${meta?.event || "unknown"}]`)} - ${meta?.description || ""}` - ); + if (meta) console.log(hookSummaryLine(meta, { verbose: options.verbose })); + else console.log(` ${chalk.cyan(name)} ${chalk.dim("[unknown]")}`); } + printDisclosureHint(registered.length - visible.length, "hooks info ", { includeAll: true }); return; } @@ -365,12 +410,10 @@ program console.log(JSON.stringify(hooks)); return; } - console.log(chalk.bold(`\n${category} (${hooks.length}):\n`)); - for (const h of hooks) { - console.log( - ` ${chalk.cyan(h.name)} ${chalk.dim(`[${h.event}]`)} - ${h.description}` - ); - } + const visible = hooks.slice(0, limit); + console.log(chalk.bold(`\n${category} (${hooks.length}, showing ${visible.length}):\n`)); + for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose })); + printDisclosureHint(hooks.length - visible.length, "hooks info ", { includeAll: true }); return; } @@ -384,26 +427,21 @@ program return; } - console.log(chalk.bold(`\nAvailable hooks (${HOOKS.length}):\n`)); - for (const category of CATEGORIES) { - const hooks = getHooksByCategory(category); - console.log(chalk.bold(`${category} (${hooks.length}):`)); - for (const h of hooks) { - console.log( - ` ${chalk.cyan(h.name)} ${chalk.dim(`[${h.event}]`)} - ${h.description}` - ); - } - console.log(); - } + const visible = HOOKS.slice(0, limit); + console.log(chalk.bold(`\nAvailable hooks (${HOOKS.length}, showing ${visible.length}):\n`)); + for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose })); + printDisclosureHint(HOOKS.length - visible.length, "hooks info ", { includeAll: true }); }); // Search command program .command("search") .argument("", "Search term") + .option("-n, --limit ", "Max rows to show in compact output", "10") + .option("--verbose", "Show descriptions for search results", false) .option("-j, --json", "Output as JSON", false) .description("Search for hooks") - .action((query: string, options: { json: boolean }) => { + .action((query: string, options: { limit: string; verbose: boolean; json: boolean }) => { const results = searchHooks(query); if (options.json) { console.log(JSON.stringify(results)); @@ -413,13 +451,11 @@ program console.log(chalk.dim(`No hooks found for "${query}"`)); return; } - console.log(chalk.bold(`\nFound ${results.length} hook(s):\n`)); - for (const h of results) { - console.log( - ` ${chalk.cyan(h.name)} ${chalk.dim(`[${h.event}] [${h.category}]`)}` - ); - console.log(` ${h.description}`); - } + const limit = parseLimit(options.limit, 10, 100); + const visible = results.slice(0, limit); + console.log(chalk.bold(`\nFound ${results.length} hook(s), showing ${visible.length}:\n`)); + for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose })); + printDisclosureHint(results.length - visible.length, "hooks info "); }); // Remove command @@ -683,9 +719,10 @@ program program .command("docs") .argument("[hook]", "Hook name (shows general docs if omitted)") + .option("--verbose", "Print full hook README content", false) .option("-j, --json", "Output as JSON", false) .description("Show documentation for hooks") - .action((hook: string | undefined, options: { json: boolean }) => { + .action((hook: string | undefined, options: { verbose: boolean; json: boolean }) => { if (hook) { const meta = getHook(hook); if (!meta) { @@ -721,11 +758,18 @@ program console.log(` hooks install ${meta.name} --project # project only`); console.log(); - if (readme) { + if (readme && options.verbose) { console.log(chalk.bold(" README:\n")); for (const line of readme.split("\n")) { console.log(` ${line}`); } + } else if (readme) { + const preview = readmePreview(readme); + if (preview) { + console.log(chalk.bold(" README Preview:\n")); + console.log(` ${preview}\n`); + } + console.log(chalk.dim(` README has ${readme.split("\n").length} lines. Use hooks docs ${meta.name} --verbose for the full README, or --json for machine-readable output.`)); } return; } @@ -795,8 +839,9 @@ program } console.log(chalk.bold("\n Hook-Specific Docs\n")); - console.log(` hooks docs View README for a specific hook`); - console.log(` hooks docs --json Machine-readable documentation`); + console.log(` hooks docs Compact hook docs`); + console.log(` hooks docs --verbose Full hook README`); + console.log(` hooks docs --json Machine-readable documentation`); console.log(); }); @@ -977,11 +1022,11 @@ logCmd console.log(chalk.bold(`\n Hook Events (${rows.length})\n`)); for (const row of rows) { const ts = row.timestamp.slice(0, 19).replace("T", " "); - const err = row.error ? chalk.red(` ERR: ${row.error.slice(0, 60)}`) : ""; + const err = row.error ? chalk.red(` ERR: ${truncateText(row.error, 60)}`) : ""; const tool = row.tool_name ? chalk.dim(` [${row.tool_name}]`) : ""; console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))}${tool}${err}`); } - console.log(); + console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit to change row count.")); }); logCmd @@ -1004,10 +1049,10 @@ logCmd console.log(chalk.bold(`\n Search results for "${text}" (${rows.length})\n`)); for (const row of rows) { const ts = row.timestamp.slice(0, 19).replace("T", " "); - const snippet = (row.tool_input || row.error || "").slice(0, 80); + const snippet = truncateText(row.tool_input || row.error || "", 80); console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))} ${chalk.dim(snippet)}`); } - console.log(); + console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit to change row count.")); }); logCmd @@ -1029,11 +1074,11 @@ logCmd console.log(chalk.bold(`\n Last ${rows.length} events\n`)); for (const row of rows) { const ts = row.timestamp.slice(0, 19).replace("T", " "); - const err = row.error ? chalk.red(` ✗ ${row.error.slice(0, 60)}`) : ""; + const err = row.error ? chalk.red(` ✗ ${truncateText(row.error, 60)}`) : ""; const tool = row.tool_name ? chalk.dim(` [${row.tool_name}]`) : ""; console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))}${tool}${err}`); } - console.log(); + console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or -n to change row count.")); }); logCmd @@ -1072,9 +1117,9 @@ logCmd console.log(chalk.bold(`\n Errors (last ${options.since}, ${rows.length} found)\n`)); for (const row of rows) { const ts = row.timestamp.slice(0, 19).replace("T", " "); - console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))} ${chalk.red(row.error.slice(0, 100))}`); + console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))} ${chalk.red(truncateText(row.error, 100))}`); } - console.log(); + console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit to change row count.")); }); logCmd diff --git a/src/db/index.ts b/src/db/index.ts index a73880e..895c279 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -5,7 +5,7 @@ * Supports HASNA_HOOKS_DATA_DIR / HOOKS_DATA_DIR and HASNA_HOOKS_DB_PATH / HOOKS_DB_PATH env overrides. */ -import { SqliteAdapter as Database } from "@hasna/cloud"; +import { Database } from "bun:sqlite"; import { existsSync, mkdirSync, cpSync } from "fs"; import { join } from "path"; import { homedir } from "os"; diff --git a/src/lib/installer.test.ts b/src/lib/installer.test.ts index c3fc542..aaec83c 100644 --- a/src/lib/installer.test.ts +++ b/src/lib/installer.test.ts @@ -417,6 +417,7 @@ describe("installer", () => { projectBackup = null; } // Start each test with clean project settings + mkdirSync(join(process.cwd(), ".claude"), { recursive: true }); writeFileSync(projectSettings, "{}\n"); }); diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 0d1ddee..ede91c8 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -1,10 +1,11 @@ import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "bun:test"; -import { existsSync, readFileSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; -import { homedir } from "os"; +import { homedir, tmpdir } from "os"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createHooksServer, MCP_PORT } from "./server.js"; +import { closeDb, getDb } from "../db/index.js"; const SETTINGS_PATH = join(homedir(), ".claude", "settings.json"); const TEST_PORT = 39428; @@ -44,6 +45,57 @@ function parseResult(result: any): any { return JSON.parse((result.content as any)[0].text); } +function listItems(data: any): any[] { + return Array.isArray(data) ? data : data.hooks ?? data.results ?? []; +} + +function seedLogDb(rowCount: number, options: { withErrors?: boolean } = {}): () => void { + closeDb(); + const previousHasnaPath = process.env.HASNA_HOOKS_DB_PATH; + const previousHooksPath = process.env.HOOKS_DB_PATH; + const tmpDir = join(tmpdir(), `hooks-mcp-log-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(tmpDir, { recursive: true }); + process.env.HASNA_HOOKS_DB_PATH = join(tmpDir, "hooks.db"); + delete process.env.HOOKS_DB_PATH; + + const db = getDb(); + db.run("DELETE FROM hook_events"); + const insert = db.query(` + INSERT INTO hook_events + (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const now = Date.now(); + const longText = "x".repeat(240); + for (let i = 0; i < rowCount; i++) { + insert.run( + `event-${i}`, + new Date(now - i * 1000).toISOString(), + `session-${Math.floor(i / 10)}`, + "gitguard", + "PostToolUse", + "Bash", + JSON.stringify({ command: `echo ${i} ${longText}` }), + "continue", + options.withErrors ? `failure ${i} ${longText}` : null, + 10 + i, + "/tmp/open-hooks-test", + JSON.stringify({ index: i }), + ); + } + + return () => { + closeDb(); + if (previousHasnaPath === undefined) delete process.env.HASNA_HOOKS_DB_PATH; + else process.env.HASNA_HOOKS_DB_PATH = previousHasnaPath; + if (previousHooksPath === undefined) delete process.env.HOOKS_DB_PATH; + else process.env.HOOKS_DB_PATH = previousHooksPath; + rmSync(tmpDir, { recursive: true, force: true }); + }; +} + describe("MCP server", () => { describe("constants", () => { test("MCP_PORT is 39427", () => { @@ -120,6 +172,16 @@ describe("MCP server", () => { test("hooks_list returns all hooks by category", async () => { const data = parseResult(await client.callTool({ name: "hooks_list", arguments: {} })); + expect(data.total).toBe(39); + expect(data.count).toBe(25); + expect(data.omitted).toBe(14); + expect(data.hooks[0]).toHaveProperty("name"); + expect(data.hooks[0]).not.toHaveProperty("description"); + expect(data.hint).toContain("compact:false"); + }); + + test("hooks_list compact false returns full grouped hooks", async () => { + const data = parseResult(await client.callTool({ name: "hooks_list", arguments: { compact: false } })); expect(data["Git Safety"]).toHaveLength(4); expect(data["Code Quality"]).toHaveLength(9); expect(data["Security"]).toHaveLength(2); @@ -129,9 +191,10 @@ describe("MCP server", () => { test("hooks_list with category filter", async () => { const data = parseResult(await client.callTool({ name: "hooks_list", arguments: { category: "Security" } })); - expect(data).toHaveLength(2); - expect(data[0].name).toBe("checksecurity"); - expect(data[1].name).toBe("packageage"); + const hooks = listItems(data); + expect(hooks).toHaveLength(2); + expect(hooks[0].name).toBe("checksecurity"); + expect(hooks[1].name).toBe("packageage"); }); test("hooks_list with unknown category", async () => { @@ -142,30 +205,32 @@ describe("MCP server", () => { test("hooks_list category is case-insensitive", async () => { const data = parseResult(await client.callTool({ name: "hooks_list", arguments: { category: "git safety" } })); - expect(data).toHaveLength(4); + expect(listItems(data)).toHaveLength(4); }); // --- hooks_search --- test("hooks_search finds hooks by name", async () => { const data = parseResult(await client.callTool({ name: "hooks_search", arguments: { query: "gitguard" } })); - expect(data).toHaveLength(1); - expect(data[0].name).toBe("gitguard"); + const results = listItems(data); + expect(results).toHaveLength(1); + expect(results[0].name).toBe("gitguard"); }); test("hooks_search finds hooks by tag", async () => { const data = parseResult(await client.callTool({ name: "hooks_search", arguments: { query: "typosquatting" } })); - expect(data).toHaveLength(1); - expect(data[0].name).toBe("packageage"); + const results = listItems(data); + expect(results).toHaveLength(1); + expect(results[0].name).toBe("packageage"); }); test("hooks_search returns empty for no match", async () => { const data = parseResult(await client.callTool({ name: "hooks_search", arguments: { query: "zzzzzzz" } })); - expect(data).toEqual([]); + expect(listItems(data)).toEqual([]); }); - test("hooks_search result has all fields", async () => { - const data = parseResult(await client.callTool({ name: "hooks_search", arguments: { query: "git" } })); + test("hooks_search compact false result has all fields", async () => { + const data = parseResult(await client.callTool({ name: "hooks_search", arguments: { query: "git", compact: false } })); for (const hook of data) { expect(hook).toHaveProperty("name"); expect(hook).toHaveProperty("displayName"); @@ -344,6 +409,14 @@ describe("MCP server", () => { test("hooks_docs specific hook", async () => { const data = parseResult(await client.callTool({ name: "hooks_docs", arguments: { name: "gitguard" } })); expect(data.name).toBe("gitguard"); + expect(data).toHaveProperty("readme_preview"); + expect(data).toHaveProperty("readme_lines"); + expect(data).not.toHaveProperty("readme"); + }); + + test("hooks_docs verbose returns full readme", async () => { + const data = parseResult(await client.callTool({ name: "hooks_docs", arguments: { name: "gitguard", verbose: true } })); + expect(data.name).toBe("gitguard"); expect(typeof data.readme).toBe("string"); expect(data.readme.length).toBeGreaterThan(0); }); @@ -357,15 +430,17 @@ describe("MCP server", () => { test("hooks_registered empty when none installed", async () => { const data = parseResult(await client.callTool({ name: "hooks_registered", arguments: {} })); - // May contain hooks from other tests, just check it's an array - expect(Array.isArray(data)).toBe(true); + // May contain hooks from other tests, just check compact shape. + expect(Array.isArray(data.hooks)).toBe(true); + expect(data).toHaveProperty("total"); }); test("hooks_registered shows installed hooks", async () => { await client.callTool({ name: "hooks_install", arguments: { hooks: ["gitguard"] } }); const data = parseResult(await client.callTool({ name: "hooks_registered", arguments: {} })); - expect(data.some((h: any) => h.name === "gitguard")).toBe(true); - expect(data.find((h: any) => h.name === "gitguard").event).toBe("PreToolUse"); + const hooks = listItems(data); + expect(hooks.some((h: any) => h.name === "gitguard")).toBe(true); + expect(hooks.find((h: any) => h.name === "gitguard").event).toBe("PreToolUse"); }); // --- project scope --- @@ -386,7 +461,7 @@ describe("MCP server", () => { test("hooks_registered with project scope", async () => { await client.callTool({ name: "hooks_install", arguments: { hooks: ["packageage"], scope: "project", overwrite: true } }); const data = parseResult(await client.callTool({ name: "hooks_registered", arguments: { scope: "project" } })); - expect(data.some((h: any) => h.name === "packageage")).toBe(true); + expect(listItems(data).some((h: any) => h.name === "packageage")).toBe(true); }); test("hooks_doctor with project scope", async () => { @@ -440,7 +515,7 @@ describe("MCP server", () => { // --- docs for every hook --- - test("hooks_docs returns readme for every hook", async () => { + test("hooks_docs verbose returns readme for every hook", async () => { const allHooks = [ "gitguard", "branchprotect", "checkpoint", "checktests", "checklint", "checkfiles", @@ -451,7 +526,7 @@ describe("MCP server", () => { "contextrefresh", "precompact", ]; for (const name of allHooks) { - const data = parseResult(await client.callTool({ name: "hooks_docs", arguments: { name } })); + const data = parseResult(await client.callTool({ name: "hooks_docs", arguments: { name, verbose: true } })); expect(data.name).toBe(name); expect(typeof data.readme).toBe("string"); expect(data.version).toMatch(/^\d+\.\d+\.\d+$/); @@ -460,9 +535,9 @@ describe("MCP server", () => { // --- registered field validation --- - test("hooks_registered result has all metadata fields", async () => { + test("hooks_registered compact false result has all metadata fields", async () => { await client.callTool({ name: "hooks_install", arguments: { hooks: ["gitguard"] } }); - const data = parseResult(await client.callTool({ name: "hooks_registered", arguments: {} })); + const data = parseResult(await client.callTool({ name: "hooks_registered", arguments: { compact: false } })); const hook = data.find((h: any) => h.name === "gitguard"); expect(hook).toBeDefined(); expect(hook.event).toBe("PreToolUse"); @@ -512,8 +587,9 @@ describe("MCP server", () => { } const after = parseResult(await client.callTool({ name: "hooks_registered", arguments: {} })); + const afterHooks = listItems(after); for (const name of allHooks) { - expect(after.some((h: any) => h.name === name)).toBe(false); + expect(afterHooks.some((h: any) => h.name === name)).toBe(false); } }); @@ -521,8 +597,9 @@ describe("MCP server", () => { test("hooks_search by description keyword", async () => { const data = parseResult(await client.callTool({ name: "hooks_search", arguments: { query: "destructive" } })); - expect(data.length).toBeGreaterThanOrEqual(1); - expect(data[0].name).toBe("gitguard"); + const results = listItems(data); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].name).toBe("gitguard"); }); test("hooks_search case insensitive", async () => { @@ -533,29 +610,32 @@ describe("MCP server", () => { test("hooks_search by category-related term", async () => { const data = parseResult(await client.callTool({ name: "hooks_search", arguments: { query: "security" } })); - expect(data.some((h: any) => h.name === "checksecurity")).toBe(true); + expect(listItems(data).some((h: any) => h.name === "checksecurity")).toBe(true); }); // --- hooks_list every category individually --- test("hooks_list Notifications category", async () => { const data = parseResult(await client.callTool({ name: "hooks_list", arguments: { category: "Notifications" } })); - expect(data).toHaveLength(5); - expect(data[0].event).toBe("Stop"); + const hooks = listItems(data); + expect(hooks).toHaveLength(5); + expect(hooks[0].event).toBe("Stop"); }); test("hooks_list Context Management category", async () => { const data = parseResult(await client.callTool({ name: "hooks_list", arguments: { category: "Context Management" } })); - expect(data).toHaveLength(2); - expect(data[0].event).toBe("Notification"); + const hooks = listItems(data); + expect(hooks).toHaveLength(2); + expect(hooks[0].event).toBe("Notification"); }); test("hooks_list Code Quality category", async () => { const data = parseResult(await client.callTool({ name: "hooks_list", arguments: { category: "Code Quality" } })); - expect(data).toHaveLength(9); + const hooks = listItems(data); + expect(hooks).toHaveLength(9); // stylescheck is PreToolUse, others are PostToolUse - expect(data.some((h: any) => h.event === "PreToolUse")).toBe(true); - expect(data.some((h: any) => h.event === "PostToolUse")).toBe(true); + expect(hooks.some((h: any) => h.event === "PreToolUse")).toBe(true); + expect(hooks.some((h: any) => h.event === "PostToolUse")).toBe(true); }); // --- full lifecycle --- @@ -573,8 +653,9 @@ describe("MCP server", () => { // Registered — both present const reg = parseResult(await client.callTool({ name: "hooks_registered", arguments: {} })); - expect(reg.some((h: any) => h.name === "gitguard")).toBe(true); - expect(reg.some((h: any) => h.name === "packageage")).toBe(true); + const regHooks = listItems(reg); + expect(regHooks.some((h: any) => h.name === "gitguard")).toBe(true); + expect(regHooks.some((h: any) => h.name === "packageage")).toBe(true); // Info — shows global=true const info = parseResult(await client.callTool({ name: "hooks_info", arguments: { name: "gitguard" } })); @@ -586,8 +667,9 @@ describe("MCP server", () => { // Verify gitguard gone, packageage still there const afterReg = parseResult(await client.callTool({ name: "hooks_registered", arguments: {} })); - expect(afterReg.some((h: any) => h.name === "gitguard")).toBe(false); - expect(afterReg.some((h: any) => h.name === "packageage")).toBe(true); + const afterRegHooks = listItems(afterReg); + expect(afterRegHooks.some((h: any) => h.name === "gitguard")).toBe(false); + expect(afterRegHooks.some((h: any) => h.name === "packageage")).toBe(true); // Info now shows global=false const afterInfo = parseResult(await client.callTool({ name: "hooks_info", arguments: { name: "gitguard" } })); @@ -724,24 +806,64 @@ describe("MCP server", () => { expect(enabled.disabled).toBe(false); }); + // --- hooks_log_* compact/full defaults --- + + test("hooks_log_list compact false preserves legacy 50-row default", async () => { + const cleanup = seedLogDb(55); + try { + const compact = parseResult(await client.callTool({ name: "hooks_log_list", arguments: {} })); + expect(compact.count).toBe(20); + expect(compact.compact).toBe(true); + expect(compact.events[0]).toHaveProperty("tool_input_preview"); + expect(compact.events[0]).not.toHaveProperty("tool_input"); + + const full = parseResult(await client.callTool({ name: "hooks_log_list", arguments: { compact: false } })); + expect(full.count).toBe(50); + expect(full.compact).toBe(false); + expect(full.events).toHaveLength(50); + expect(full.events[0]).toHaveProperty("tool_input"); + expect(full.events[0]).not.toHaveProperty("tool_input_preview"); + } finally { + cleanup(); + } + }); + + test("hooks_log_errors compact false preserves legacy 50-row default", async () => { + const cleanup = seedLogDb(55, { withErrors: true }); + try { + const compact = parseResult(await client.callTool({ name: "hooks_log_errors", arguments: {} })); + expect(compact.count).toBe(20); + expect(compact.compact).toBe(true); + expect(compact.events[0].error.length).toBeLessThanOrEqual(180); + + const full = parseResult(await client.callTool({ name: "hooks_log_errors", arguments: { compact: false } })); + expect(full.count).toBe(50); + expect(full.compact).toBe(false); + expect(full.events).toHaveLength(50); + expect(full.events[0].error.length).toBeGreaterThan(180); + } finally { + cleanup(); + } + }); + // --- compact mode --- test("hooks_list compact returns minimal fields", async () => { const data = parseResult(await client.callTool({ name: "hooks_list", arguments: { compact: true } })); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(39); - expect(data[0]).toHaveProperty("name"); - expect(data[0]).toHaveProperty("event"); - expect(data[0]).toHaveProperty("matcher"); - expect(data[0]).not.toHaveProperty("description"); + expect(data.total).toBe(39); + expect(data.count).toBe(25); + expect(data.hooks[0]).toHaveProperty("name"); + expect(data.hooks[0]).toHaveProperty("event"); + expect(data.hooks[0]).toHaveProperty("matcher"); + expect(data.hooks[0]).not.toHaveProperty("description"); }); test("hooks_search compact returns minimal fields", async () => { const data = parseResult(await client.callTool({ name: "hooks_search", arguments: { query: "git", compact: true } })); - expect(Array.isArray(data)).toBe(true); - expect(data[0]).toHaveProperty("name"); - expect(data[0]).toHaveProperty("event"); - expect(data[0]).not.toHaveProperty("tags"); + expect(Array.isArray(data.hooks)).toBe(true); + expect(data.hooks[0]).toHaveProperty("name"); + expect(data.hooks[0]).toHaveProperty("event"); + expect(data.hooks[0]).not.toHaveProperty("tags"); }); // --- matcher in hooks_registered --- @@ -750,7 +872,7 @@ describe("MCP server", () => { backupSettings(); await client.callTool({ name: "hooks_install", arguments: { hooks: ["gitguard"] } }); const data = parseResult(await client.callTool({ name: "hooks_registered", arguments: {} })); - const hook = data.find((h: any) => h.name === "gitguard"); + const hook = listItems(data).find((h: any) => h.name === "gitguard"); expect(hook).toHaveProperty("matcher"); restoreSettings(); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index de9b457..90993f7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -31,6 +31,7 @@ import { searchHooks, getHook, type Category, + type HookMeta, } from "../lib/registry.js"; import { installHook, @@ -64,6 +65,52 @@ function formatInstallResults(results: InstallResult[], extra?: Record 0 + ? `Use limit, compact:false, or ${detail} for more detail.` + : `Use compact:false or ${detail} for full details.`, + }; +} + +function compactEvent(row: any) { + return { + id: row.id, + timestamp: row.timestamp, + hook_name: row.hook_name, + tool_name: row.tool_name ?? undefined, + session_id: row.session_id ? String(row.session_id).slice(0, 12) : undefined, + error: row.error ? truncateText(row.error, 180) : undefined, + tool_input_preview: row.tool_input ? truncateText(row.tool_input, 180) : undefined, + }; +} + // --- in-memory agent registry --- interface _HooksAgent { id: string; name: string; session_id?: string; last_seen_at: string; project_id?: string; } const _hooksAgents = new Map(); @@ -73,51 +120,57 @@ export function createHooksServer(): McpServer { name: "@hasna/hooks", version: pkg.version, }); + const defineTool = ( + name: string, + description: string, + schema: Record, + handler: (params: any) => any, + ) => (server.tool as any)(name, description, schema, handler); // --- Tools --- - server.tool( + defineTool( "hooks_list", - "List all available hooks, optionally filtered by category. Use compact:true to get minimal output (name+event+matcher only) — saves tokens.", + "List available hooks. Compact by default; pass compact:false for full HookMeta objects.", { category: z.string().optional().describe("Filter by category name (e.g. 'Git Safety', 'Code Quality', 'Security')"), - compact: z.boolean().default(false).describe("Return minimal fields only: name, event, matcher. Reduces token usage."), + compact: z.boolean().default(true).describe("Return compact summaries by default. Set false for full fields."), + limit: z.number().default(25).describe("Max compact rows to return"), }, - async ({ category, compact }) => { - const slim = (hooks: typeof HOOKS) => compact ? hooks.map((h) => ({ name: h.name, event: h.event, matcher: h.matcher })) : hooks; + async ({ category, compact, limit }) => { + const maxRows = boundedLimit(limit, 25, 100); if (category) { const cat = CATEGORIES.find((c) => c.toLowerCase() === category.toLowerCase()); if (!cat) { return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown category: ${category}`, available: [...CATEGORIES] }) }] }; } - return { content: [{ type: "text", text: JSON.stringify(slim(getHooksByCategory(cat))) }] }; - } - if (compact) { - return { content: [{ type: "text", text: JSON.stringify(slim(HOOKS)) }] }; + const hooks = getHooksByCategory(cat); + return { content: [{ type: "text", text: JSON.stringify(compact ? compactHookResult(hooks, maxRows, "hooks_info") : hooks) }] }; } const result: Record = {}; for (const cat of CATEGORIES) { result[cat] = getHooksByCategory(cat); } - return { content: [{ type: "text", text: JSON.stringify(result) }] }; + return { content: [{ type: "text", text: JSON.stringify(compact ? compactHookResult(HOOKS, maxRows, "hooks_info") : result) }] }; } ); - server.tool( + defineTool( "hooks_search", - "Search for hooks by name, description, or tags. Use compact:true for minimal output to save tokens.", + "Search hooks. Compact by default; pass compact:false for full HookMeta objects.", { query: z.string().describe("Search query"), - compact: z.boolean().default(false).describe("Return minimal fields only: name, event, matcher."), + compact: z.boolean().default(true).describe("Return compact summaries by default. Set false for full fields."), + limit: z.number().default(10).describe("Max compact rows to return"), }, - async ({ query, compact }) => { + async ({ query, compact, limit }) => { const results = searchHooks(query); - const out = compact ? results.map((h) => ({ name: h.name, event: h.event, matcher: h.matcher })) : results; + const out = compact ? compactHookResult(results, boundedLimit(limit, 10, 100), "hooks_info") : results; return { content: [{ type: "text", text: JSON.stringify(out) }] }; } ); - server.tool( + defineTool( "hooks_info", "Get detailed information about a specific hook including install status", { name: z.string().describe("Hook name (e.g. 'gitguard', 'checkpoint')") }, @@ -132,7 +185,7 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_install", "Install one or more hooks by registering them in agent settings", { @@ -141,13 +194,13 @@ export function createHooksServer(): McpServer { overwrite: z.boolean().default(false).describe("Overwrite if already installed"), profile: z.string().optional().describe("Agent profile ID to scope hooks to"), }, - async ({ hooks, scope, overwrite, profile }) => { + async ({ hooks, scope, overwrite, profile }: { hooks: string[]; scope: Scope; overwrite: boolean; profile?: string }) => { const results = hooks.map((name) => installHook(name, { scope, overwrite, profile })); return formatInstallResults(results, { scope, profile }); } ); - server.tool( + defineTool( "hooks_install_category", "Install all hooks in a category", { @@ -166,7 +219,7 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_install_all", "Install all available hooks", { @@ -179,7 +232,7 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_remove", "Remove (unregister) a hook from agent settings", { @@ -192,7 +245,7 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_doctor", "Check health of installed hooks — verifies hook source exists, settings are correct", { @@ -248,7 +301,7 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_categories", "List all hook categories with counts", {}, @@ -261,11 +314,14 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_docs", - "Get documentation — general overview or README for a specific hook", - { name: z.string().optional().describe("Hook name for specific docs, omit for general docs") }, - async ({ name }) => { + "Get documentation. Hook README content is summarized by default; pass verbose:true for the full README.", + { + name: z.string().optional().describe("Hook name for specific docs, omit for general docs"), + verbose: z.boolean().default(false).describe("Return full hook README when true"), + }, + async ({ name, verbose }) => { if (name) { const meta = getHook(name); if (!meta) { @@ -277,7 +333,23 @@ export function createHooksServer(): McpServer { if (existsSync(readmePath)) { readme = readFileSync(readmePath, "utf-8"); } - return { content: [{ type: "text", text: JSON.stringify({ ...meta, readme }) }] }; + if (verbose) { + return { content: [{ type: "text", text: JSON.stringify({ ...meta, readme }) }] }; + } + return { + content: [{ + type: "text", + text: JSON.stringify({ + ...compactHook(meta), + displayName: meta.displayName, + version: meta.version, + description: truncateText(meta.description, 220), + readme_preview: truncateText(readme, 500), + readme_lines: readme ? readme.split("\n").length : 0, + hint: "Call hooks_docs with verbose:true for the full README or hooks_info for metadata.", + }), + }], + }; } return { @@ -306,23 +378,44 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_registered", - "Get list of currently registered hooks for a scope", + "Get currently registered hooks for a scope. Compact by default.", { scope: z.enum(["global", "project"]).default("global").describe("Scope to check"), + compact: z.boolean().default(true).describe("Return compact summaries by default. Set false for descriptions."), + limit: z.number().default(25).describe("Max compact rows to return"), }, - async ({ scope }) => { + async ({ scope, compact, limit }) => { const registered = getRegisteredHooks(scope); const result = registered.map((name) => { const meta = getHook(name); return { name, event: meta?.event, matcher: meta?.matcher ?? "", version: meta?.version, description: meta?.description }; }); - return { content: [{ type: "text", text: JSON.stringify(result) }] }; + if (!compact) return { content: [{ type: "text", text: JSON.stringify(result) }] }; + const maxRows = boundedLimit(limit, 25, 100); + const visible = result.slice(0, maxRows).map((hook) => ({ + name: hook.name, + event: hook.event, + matcher: hook.matcher, + version: hook.version, + })); + return { + content: [{ + type: "text", + text: JSON.stringify({ + hooks: visible, + count: visible.length, + total: result.length, + omitted: Math.max(0, result.length - visible.length), + hint: "Use compact:false for descriptions or hooks_info for one hook.", + }), + }], + }; } ); - server.tool( + defineTool( "hooks_run", "Execute a hook programmatically with the given input and return its output", { @@ -392,14 +485,14 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_update", "Re-register installed hooks to pick up new package version (reinstalls with overwrite)", { hooks: z.array(z.string()).optional().describe("Hook names to update (omit to update all installed hooks)"), scope: z.enum(["global", "project"]).default("global").describe("Scope to update"), }, - async ({ hooks, scope }) => { + async ({ hooks, scope }: { hooks?: string[]; scope: Scope }) => { const installed = getRegisteredHooks(scope); const toUpdate = hooks && hooks.length > 0 ? hooks : installed; @@ -420,14 +513,15 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_context", - "Get full agent context in one call: installed hooks (with event+matcher), active profile, settings path, and doctor status. Call this once at session start instead of making 4 separate calls.", + "Get compact agent context in one call: installed hooks, active profile summary, settings path, and doctor status.", { scope: z.enum(["global", "project"]).default("global").describe("Scope to inspect"), profile: z.string().optional().describe("Agent profile ID to include in context"), + verbose: z.boolean().default(false).describe("Include full profile preferences/details when true"), }, - async ({ scope, profile }) => { + async ({ scope, profile, verbose }) => { const settingsPath = getSettingsPath(scope); const registered = getRegisteredHooks(scope); const hooks = registered.map((name) => { @@ -457,14 +551,17 @@ export function createHooksServer(): McpServer { if (profile) { const p = getProfile(profile); - ctx.profile = p ?? null; + ctx.profile = p && !verbose + ? { agent_id: p.agent_id, agent_type: p.agent_type, name: p.name } + : p ?? null; + if (p && !verbose) ctx.profile_hint = "Use verbose:true for profile preferences."; } return { content: [{ type: "text", text: JSON.stringify(ctx) }] }; } ); - server.tool( + defineTool( "hooks_preview", "Simulate which installed PreToolUse hooks would fire for a given tool call and what decision each returns. Use this to understand your hook environment before taking an action.", { @@ -526,7 +623,7 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_setup", "Single-shot agent onboarding: create an agent profile + install recommended hooks in one call. Ideal for agents setting up hooks at session start.", { @@ -535,7 +632,7 @@ export function createHooksServer(): McpServer { hooks: z.array(z.string()).optional().describe("Hook names to install (omit for sensible defaults: gitguard, checkpoint, checktests, protectfiles)"), scope: z.enum(["global", "project"]).default("global").describe("Install scope"), }, - async ({ agent_type, name, hooks, scope }) => { + async ({ agent_type, name, hooks, scope }: { agent_type: "claude" | "gemini" | "custom"; name?: string; hooks?: string[]; scope: Scope }) => { const profile = createProfile({ agent_type, name }); const toInstall = hooks && hooks.length > 0 ? hooks @@ -552,7 +649,7 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_batch_run", "Run multiple hooks in parallel in a single call. Returns all results at once — more efficient than N separate hooks_run calls.", { @@ -562,7 +659,7 @@ export function createHooksServer(): McpServer { })).describe("List of hooks to run with their inputs"), timeout_ms: z.number().default(10000).describe("Per-hook timeout in milliseconds"), }, - async ({ hooks, timeout_ms }) => { + async ({ hooks, timeout_ms }: { hooks: Array<{ name: string; input: Record }>; timeout_ms: number }) => { const results = await Promise.all(hooks.map(async ({ name, input }) => { const meta = getHook(name); if (!meta) return { name, error: `Hook '${name}' not found` }; @@ -589,7 +686,7 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_disable", "Temporarily disable a registered hook without removing it. Stores disabled list in settings under hooks.__disabled.", { @@ -612,7 +709,7 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_enable", "Re-enable a previously disabled hook.", { @@ -633,7 +730,7 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_init", "Register a new agent profile — returns a unique agent_id for use with hook installation and execution", { @@ -646,30 +743,54 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "hooks_profiles", - "List all registered agent profiles", - {}, - async () => { + "List registered agent profiles. Compact by default.", + { + compact: z.boolean().default(true).describe("Return compact summaries by default. Set false for full profiles."), + limit: z.number().default(25).describe("Max compact rows to return"), + }, + async ({ compact, limit }) => { const profiles = listProfiles(); - return { content: [{ type: "text", text: JSON.stringify(profiles) }] }; + if (!compact) return { content: [{ type: "text", text: JSON.stringify(profiles) }] }; + const maxRows = boundedLimit(limit, 25, 100); + const visible = profiles.slice(0, maxRows).map((profile) => ({ + agent_id: profile.agent_id, + agent_type: profile.agent_type, + name: profile.name, + last_seen_at: profile.last_seen_at, + })); + return { + content: [{ + type: "text", + text: JSON.stringify({ + profiles: visible, + count: visible.length, + total: profiles.length, + omitted: Math.max(0, profiles.length - visible.length), + hint: "Use compact:false for full profile preferences.", + }), + }], + }; } ); // --- Log query tools --- - server.tool( + defineTool( "hooks_log_list", - "List hook events from SQLite (~/.hasna/hooks/hooks.db). Filter by hook name, session ID, or time range.", + "List hook events from SQLite. Compact summaries by default; set compact:false for full event rows.", { hook_name: z.string().optional().describe("Filter by hook name (e.g. 'sessionlog', 'costwatch')"), session_id: z.string().optional().describe("Filter by session ID prefix"), - limit: z.number().default(50).describe("Max number of events to return"), + limit: z.number().optional().describe("Max number of events to return. Defaults to 20 compact rows or 50 full rows."), since: z.string().optional().describe("ISO timestamp or duration string (e.g. '1h', '30m', '7d') to filter from"), + compact: z.boolean().default(true).describe("Return compact event summaries by default. Set false for full rows."), }, - async ({ hook_name, session_id, limit, since }) => { + async ({ hook_name, session_id, limit, since, compact }) => { const { getDb } = await import("../db/index.js"); const db = getDb(); + const maxRows = boundedLimit(limit, compact ? 20 : 50, compact ? 100 : 500); function parseDuration(s: string): string | null { const m = s.match(/^(\d+)(s|m|h|d)$/); @@ -689,37 +810,61 @@ export function createHooksServer(): McpServer { if (ts) { sql += " AND timestamp >= ?"; params.push(ts); } } sql += " ORDER BY timestamp DESC LIMIT ?"; - params.push(limit); + params.push(maxRows); - const rows = db.query(sql).all(...params); - return { content: [{ type: "text", text: JSON.stringify({ events: rows, count: (rows as any[]).length }) }] }; + const rows = db.query(sql).all(...params) as any[]; + return { + content: [{ + type: "text", + text: JSON.stringify({ + events: compact ? rows.map(compactEvent) : rows, + count: rows.length, + compact, + hint: compact ? "Use compact:false for full tool_input/output fields." : undefined, + }), + }], + }; } ); - server.tool( + defineTool( "hooks_log_tail", - "Show the most recent hook events from SQLite.", + "Show recent hook events from SQLite. Compact summaries by default.", { n: z.number().default(20).describe("Number of most recent events to return"), + compact: z.boolean().default(true).describe("Return compact event summaries by default. Set false for full rows."), }, - async ({ n }) => { + async ({ n, compact }) => { const { getDb } = await import("../db/index.js"); const db = getDb(); - const rows = db.query("SELECT * FROM hook_events ORDER BY timestamp DESC LIMIT ?").all(n); - return { content: [{ type: "text", text: JSON.stringify({ events: rows, count: (rows as any[]).length }) }] }; + const maxRows = boundedLimit(n, 20, compact ? 100 : 500); + const rows = db.query("SELECT * FROM hook_events ORDER BY timestamp DESC LIMIT ?").all(maxRows) as any[]; + return { + content: [{ + type: "text", + text: JSON.stringify({ + events: compact ? rows.map(compactEvent) : rows, + count: rows.length, + compact, + hint: compact ? "Use compact:false for full tool_input/output fields." : undefined, + }), + }], + }; } ); - server.tool( + defineTool( "hooks_log_errors", - "Show hook events that contain errors, optionally filtered by time range.", + "Show hook events that contain errors. Compact summaries by default.", { since: z.string().default("24h").describe("Duration string (e.g. '1h', '30m', '7d') or ISO timestamp"), - limit: z.number().default(50).describe("Max number of error events to return"), + limit: z.number().optional().describe("Max number of error events to return. Defaults to 20 compact rows or 50 full rows."), + compact: z.boolean().default(true).describe("Return compact event summaries by default. Set false for full rows."), }, - async ({ since, limit }) => { + async ({ since, limit, compact }) => { const { getDb } = await import("../db/index.js"); const db = getDb(); + const maxRows = boundedLimit(limit, compact ? 20 : 50, compact ? 100 : 500); function parseDuration(s: string): string { const m = s.match(/^(\d+)(s|m|h|d)$/); @@ -732,12 +877,22 @@ export function createHooksServer(): McpServer { const ts = since.match(/^\d{4}/) ? since : parseDuration(since); const rows = db.query( "SELECT * FROM hook_events WHERE error IS NOT NULL AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?" - ).all(ts, limit); - return { content: [{ type: "text", text: JSON.stringify({ events: rows, count: (rows as any[]).length }) }] }; + ).all(ts, maxRows) as any[]; + return { + content: [{ + type: "text", + text: JSON.stringify({ + events: compact ? rows.map(compactEvent) : rows, + count: rows.length, + compact, + hint: compact ? "Use compact:false for full tool_input/output fields." : undefined, + }), + }], + }; } ); - server.tool( + defineTool( "hooks_log_summary", "Summarize hook execution: counts per hook, error rates, and recent activity.", { @@ -784,7 +939,7 @@ export function createHooksServer(): McpServer { } ); - server.tool( + defineTool( "send_feedback", "Send feedback about this service", { @@ -808,7 +963,7 @@ export function createHooksServer(): McpServer { // --- Standard Agent Tools --- - server.tool("register_agent", "Register an agent session. Returns agent_id. Auto-triggers a heartbeat.", { + defineTool("register_agent", "Register an agent session. Returns agent_id. Auto-triggers a heartbeat.", { name: z.string(), session_id: z.string().optional(), }, async (params) => { @@ -820,7 +975,7 @@ export function createHooksServer(): McpServer { return { content: [{ type: "text" as const, text: JSON.stringify(ag) }] }; }); - server.tool("heartbeat", "Update last_seen_at to signal agent is active.", { + defineTool("heartbeat", "Update last_seen_at to signal agent is active.", { agent_id: z.string(), }, async (params) => { const ag = _hooksAgents.get(params.agent_id); @@ -829,7 +984,7 @@ export function createHooksServer(): McpServer { return { content: [{ type: "text" as const, text: JSON.stringify({ agent_id: ag.id, last_seen_at: ag.last_seen_at }) }] }; }); - server.tool("set_focus", "Set active project context for this agent session.", { + defineTool("set_focus", "Set active project context for this agent session.", { agent_id: z.string(), project_id: z.string().optional(), }, async (params) => { @@ -839,8 +994,31 @@ export function createHooksServer(): McpServer { return { content: [{ type: "text" as const, text: JSON.stringify({ agent_id: ag.id, project_id: ag.project_id ?? null }) }] }; }); - server.tool("list_agents", "List all registered agents.", {}, async () => { - return { content: [{ type: "text" as const, text: JSON.stringify([..._hooksAgents.values()]) }] }; + defineTool("list_agents", "List registered agents. Compact by default.", { + compact: z.boolean().default(true).describe("Return compact agent summaries by default. Set false for full records."), + limit: z.number().default(25).describe("Max compact rows to return"), + }, async ({ compact, limit }) => { + const agents = [..._hooksAgents.values()]; + if (!compact) return { content: [{ type: "text" as const, text: JSON.stringify(agents) }] }; + const maxRows = boundedLimit(limit, 25, 100); + const visible = agents.slice(0, maxRows).map((agent) => ({ + id: agent.id, + name: agent.name, + project_id: agent.project_id, + last_seen_at: agent.last_seen_at, + })); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + agents: visible, + count: visible.length, + total: agents.length, + omitted: Math.max(0, agents.length - visible.length), + hint: "Use compact:false for session_id and full records.", + }), + }], + }; }); return server;