diff --git a/CHANGELOG.md b/CHANGELOG.md index 0222d8f..658fba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.4] - 2026-06-28 + +### Changed +- `generate-skills` now emits a slim front `SKILL.md` (progressive disclosure): a routing index of tool *groups* linking to `references/*.md`, instead of inlining a full per-tool description table. Keeps the front skill under the 300-token target for large services (Open Brain dropped from ~1100 tokens). Per-tool detail remains in the reference files. +- `parseExistingTools` reads tool names from the new group-index and flat-fallback formats (legacy quick-reference table still parsed for migration), so `--diff` and drift detection keep working. + ## [0.3.1] - 2026-06-09 ### Added diff --git a/src/cli/commands/cache.ts b/src/cli/commands/cache.ts index b0dc22b..0a85004 100644 --- a/src/cli/commands/cache.ts +++ b/src/cli/commands/cache.ts @@ -2,7 +2,14 @@ * Handle `mcp2cli cache ` -- manage schema cache. * Supports: clear [service], status, diff */ -import { clearCache, listCachedServices, readCacheRaw, detectDrift, resolveTtlMs, writeCache } from "../../cache/index.ts"; +import { + clearCache, + listCachedServices, + readCacheRaw, + detectDrift, + resolveTtlMs, + writeCache, +} from "../../cache/index.ts"; import { loadConfig } from "../../config/index.ts"; import { EXIT_CODES } from "../../types/index.ts"; import type { CommandHandler } from "../../types/index.ts"; @@ -36,7 +43,9 @@ export const handleCache: CommandHandler = async (args: string[]) => { " warm [service] Fetch and cache schemas (all or specific service)", ].join("\n"), ); - process.exitCode = subcommand ? EXIT_CODES.VALIDATION : EXIT_CODES.SUCCESS; + process.exitCode = subcommand + ? EXIT_CODES.VALIDATION + : EXIT_CODES.SUCCESS; break; } }; @@ -68,7 +77,9 @@ async function handleCacheDiff(args: string[]): Promise { const cached = await readCacheRaw(serviceName); if (!cached) { - console.log(`No cached schemas for "${serviceName}". Run a command against this service first to populate the cache.`); + console.log( + `No cached schemas for "${serviceName}". Run a command against this service first to populate the cache.`, + ); process.exitCode = EXIT_CODES.SUCCESS; return; } @@ -82,16 +93,28 @@ async function handleCacheDiff(args: string[]): Promise { } try { - const { cachedSchemas: liveSchemas } = await discoverServiceSchemas(serviceName, service, { fresh: true }); - const drift = detectDrift(serviceName, cached.tools, liveSchemas, cached.metadata.cachedAt); + const { cachedSchemas: liveSchemas } = await discoverServiceSchemas( + serviceName, + service, + { fresh: true }, + ); + const drift = detectDrift( + serviceName, + cached.tools, + liveSchemas, + cached.metadata.cachedAt, + ); if (!drift.hasDrift) { - console.log(`No schema drift detected for "${serviceName}". Cache is current.`); + console.log( + `No schema drift detected for "${serviceName}". Cache is current.`, + ); } else { const lines = [`Schema drift detected for "${serviceName}":\n`]; for (const change of drift.changes) { const detail = change.details ? ` (${change.details})` : ""; - const symbol = change.type === "added" ? "+" : change.type === "removed" ? "-" : "~"; + const symbol = + change.type === "added" ? "+" : change.type === "removed" ? "-" : "~"; lines.push(` ${symbol} ${change.tool}${detail}`); } lines.push(`\nCached at: ${drift.cachedAt}`); @@ -132,12 +155,19 @@ async function handleCacheWarm(args: string[]): Promise { try { const result = await Promise.race([ (async () => { - const { cachedSchemas } = await discoverServiceSchemas(serviceName, service, { fresh: true }); + const { cachedSchemas } = await discoverServiceSchemas( + serviceName, + service, + { fresh: true }, + ); await writeCache(serviceName, cachedSchemas, resolveTtlMs()); return cachedSchemas.length; })(), new Promise((_, reject) => - setTimeout(() => reject(new Error(`timed out after ${PER_SERVICE_TIMEOUT}ms`)), PER_SERVICE_TIMEOUT), + setTimeout( + () => reject(new Error(`timed out after ${PER_SERVICE_TIMEOUT}ms`)), + PER_SERVICE_TIMEOUT, + ), ), ]); console.log(` ${serviceName}: ${result} tools cached`); @@ -149,7 +179,9 @@ async function handleCacheWarm(args: string[]): Promise { } } - console.log(`\nWarmed ${warmed} service${warmed === 1 ? "" : "s"}${failed > 0 ? `, ${failed} failed` : ""}`); + console.log( + `\nWarmed ${warmed} service${warmed === 1 ? "" : "s"}${failed > 0 ? `, ${failed} failed` : ""}`, + ); process.exitCode = EXIT_CODES.SUCCESS; } @@ -168,8 +200,9 @@ async function handleCacheStatus(): Promise { const entry = await readCacheRaw(service); if (entry) { const age = Date.now() - new Date(entry.metadata.cachedAt).getTime(); - const ageHours = Math.round(age / (1000 * 60 * 60) * 10) / 10; - const ttlHours = Math.round(entry.metadata.ttlMs / (1000 * 60 * 60) * 10) / 10; + const ageHours = Math.round((age / (1000 * 60 * 60)) * 10) / 10; + const ttlHours = + Math.round((entry.metadata.ttlMs / (1000 * 60 * 60)) * 10) / 10; const expired = age > entry.metadata.ttlMs; const status = expired ? " (expired)" : ""; lines.push( diff --git a/src/cli/commands/credentials.ts b/src/cli/commands/credentials.ts index 4e81e7d..1657a84 100644 --- a/src/cli/commands/credentials.ts +++ b/src/cli/commands/credentials.ts @@ -81,7 +81,9 @@ async function handleSet(args: string[]): Promise { const identity = args[0]; const service = args[1]; if (!identity || !service) { - console.log("Usage: mcp2cli credentials set --header 'Key: Value' [--env 'KEY=VALUE']"); + console.log( + "Usage: mcp2cli credentials set --header 'Key: Value' [--env 'KEY=VALUE']", + ); return; } const credential = parseCredentialFlags(args.slice(2)); @@ -98,7 +100,9 @@ async function handleSet(args: string[]): Promise { async function handleSetDefault(args: string[]): Promise { const service = args[0]; if (!service) { - console.log("Usage: mcp2cli credentials set-default --header 'Key: Value' [--env 'KEY=VALUE']"); + console.log( + "Usage: mcp2cli credentials set-default --header 'Key: Value' [--env 'KEY=VALUE']", + ); return; } const credential = parseCredentialFlags(args.slice(1)); @@ -164,7 +168,9 @@ async function handleGroup(args: string[]): Promise { const name = args[1]; const members = args.slice(2); if (!name || members.length === 0) { - console.log("Usage: mcp2cli credentials group add [member2...]"); + console.log( + "Usage: mcp2cli credentials group add [member2...]", + ); return; } const result = await fetchDaemonApi("POST", "/api/credentials/groups", { @@ -178,7 +184,9 @@ async function handleGroup(args: string[]): Promise { const name = args[1]; const members = args.slice(2); if (!name || members.length === 0) { - console.log("Usage: mcp2cli credentials group add-members [member2...]"); + console.log( + "Usage: mcp2cli credentials group add-members [member2...]", + ); return; } const result = await fetchDaemonApi( @@ -206,7 +214,9 @@ async function handleGroup(args: string[]): Promise { const name = args[1]; const members = args.slice(2); if (!name || members.length === 0) { - console.log("Usage: mcp2cli credentials group remove-members [member2...]"); + console.log( + "Usage: mcp2cli credentials group remove-members [member2...]", + ); return; } const result = await fetchDaemonApi( @@ -247,14 +257,17 @@ async function handleBootstrapOpenBrain(args: string[]): Promise { const desired = buildOpenBrainCredentialsFromVaultwarden(lookup.result, { serviceName: options.service, }); - const existing = await fetchDaemonApi("GET", "/api/credentials") as { + const existing = (await fetchDaemonApi("GET", "/api/credentials")) as { credentials?: Record>; }; const changed: string[] = []; const skipped: string[] = []; for (const entry of desired) { - if (!options.force && existing.credentials?.[entry.identity]?.[entry.service]) { + if ( + !options.force && + existing.credentials?.[entry.identity]?.[entry.service] + ) { skipped.push(entry.identity); continue; } @@ -266,10 +279,16 @@ async function handleBootstrapOpenBrain(args: string[]): Promise { changed.push(entry.identity); } - console.log(JSON.stringify(formatOpenBrainBootstrapSummary(options, desired, changed, skipped))); + console.log( + JSON.stringify( + formatOpenBrainBootstrapSummary(options, desired, changed, skipped), + ), + ); } -function parseBootstrapOpenBrainFlags(args: string[]): { item: string; service: string; force: boolean } | null { +function parseBootstrapOpenBrainFlags( + args: string[], +): { item: string; service: string; force: boolean } | null { const options = { item: "Open Brain - Per-User Tokens", service: "open-brain", @@ -285,7 +304,9 @@ function parseBootstrapOpenBrainFlags(args: string[]): { item: string; service: } else if (arg === "--force") { options.force = true; } else { - console.log("Usage: mcp2cli credentials bootstrap-open-brain [--item 'Open Brain - Per-User Tokens'] [--service open-brain] [--force]"); + console.log( + "Usage: mcp2cli credentials bootstrap-open-brain [--item 'Open Brain - Per-User Tokens'] [--service open-brain] [--force]", + ); return null; } } @@ -352,7 +373,10 @@ function parseCredentialFlags( return null; } - const result: { headers?: Record; env?: Record } = {}; + const result: { + headers?: Record; + env?: Record; + } = {}; if (Object.keys(headers).length > 0) result.headers = headers; if (Object.keys(env).length > 0) result.env = env; return result; diff --git a/src/cli/commands/generate-skills.ts b/src/cli/commands/generate-skills.ts index 8c5c276..6abf110 100644 --- a/src/cli/commands/generate-skills.ts +++ b/src/cli/commands/generate-skills.ts @@ -17,7 +17,10 @@ import { } from "../../generation/index.ts"; import { filterTools, extractPolicy } from "../../access/filter.ts"; import { EXIT_CODES } from "../../types/index.ts"; -import type { ConflictMode, SkillTemplateInput } from "../../generation/types.ts"; +import type { + ConflictMode, + SkillTemplateInput, +} from "../../generation/types.ts"; import type { SchemaOutput } from "../../schema/types.ts"; import type { ToolSummary } from "../../schema/types.ts"; import { join } from "node:path"; @@ -37,11 +40,45 @@ function extractTriggerKeywords( // Common stop words to exclude const stopWords = new Set([ - "the", "and", "for", "with", "from", "that", "this", "will", "have", - "been", "were", "they", "their", "into", "when", "which", "more", - "some", "than", "them", "each", "also", "about", "over", "such", - "after", "most", "only", "other", "given", "returns", "object", - "type", "string", "number", "boolean", "array", "optional", "required", + "the", + "and", + "for", + "with", + "from", + "that", + "this", + "will", + "have", + "been", + "were", + "they", + "their", + "into", + "when", + "which", + "more", + "some", + "than", + "them", + "each", + "also", + "about", + "over", + "such", + "after", + "most", + "only", + "other", + "given", + "returns", + "object", + "type", + "string", + "number", + "boolean", + "array", + "optional", + "required", ]); for (const desc of descriptions) { @@ -132,7 +169,8 @@ export const handleGenerateSkills = async (args: string[]): Promise => { printError({ error: true, code: "INPUT_VALIDATION_ERROR", - message: "Missing required argument: . Usage: mcp2cli generate-skills ", + message: + "Missing required argument: . Usage: mcp2cli generate-skills ", }); process.exitCode = EXIT_CODES.VALIDATION; return; @@ -148,7 +186,9 @@ export const handleGenerateSkills = async (args: string[]): Promise => { if (!conflictMode) { if (!process.stdin.isTTY) { conflictMode = "skip"; - console.error("Warning: non-interactive mode, defaulting to --conflict=skip"); + console.error( + "Warning: non-interactive mode, defaulting to --conflict=skip", + ); } else { conflictMode = "skip"; // safe default even for TTY } @@ -205,7 +245,8 @@ export const handleGenerateSkills = async (args: string[]): Promise => { printError({ error: true, code: "INPUT_VALIDATION_ERROR", - message: "All tools are blocked by access policy. No skills to generate.", + message: + "All tools are blocked by access policy. No skills to generate.", }); process.exitCode = EXIT_CODES.VALIDATION; return; @@ -213,8 +254,12 @@ export const handleGenerateSkills = async (args: string[]): Promise => { // Get full schemas for each tool (already filtered by access control) const allowedToolNames = new Set(tools.map((tool) => tool.name)); - const schemas: SchemaOutput[] = discovery.schemas.filter((schema) => allowedToolNames.has(schema.tool)); - const cachedSchemas = discovery.cachedSchemas.filter((schema) => allowedToolNames.has(schema.name)); + const schemas: SchemaOutput[] = discovery.schemas.filter((schema) => + allowedToolNames.has(schema.tool), + ); + const cachedSchemas = discovery.cachedSchemas.filter((schema) => + allowedToolNames.has(schema.name), + ); // Group tools by prefix const groups = detectPrefixGroups(schemas, serviceName); @@ -232,11 +277,15 @@ export const handleGenerateSkills = async (args: string[]): Promise => { const existingSkillContent = await readExistingSkillFile(outputDir); let generatedAt: string; if (existingSkillContent) { - const existingHashMatch = existingSkillContent.match(/^schema_hash:\s*(\S+)/m); + const existingHashMatch = existingSkillContent.match( + /^schema_hash:\s*(\S+)/m, + ); const existingHash = existingHashMatch?.[1]; if (existingHash === schemaHash) { // Reuse existing timestamp when schema hasn't changed - const existingAtMatch = existingSkillContent.match(/^generated_at:\s*(\S+)/m); + const existingAtMatch = existingSkillContent.match( + /^generated_at:\s*(\S+)/m, + ); generatedAt = existingAtMatch?.[1] ?? new Date().toISOString(); } else { generatedAt = new Date().toISOString(); @@ -250,6 +299,7 @@ export const handleGenerateSkills = async (args: string[]): Promise => { description: `MCP tools for ${serviceName}`, tools, triggerKeywords, + groups, generatedAt, schemaHash, toolCount: tools.length, @@ -282,7 +332,9 @@ export const handleGenerateSkills = async (args: string[]): Promise => { // Token budget check const tokenCount = estimateTokens(skillMd); if (tokenCount > 300) { - console.error(`Warning: SKILL.md estimated at ${tokenCount} tokens (target: <300)`); + console.error( + `Warning: SKILL.md estimated at ${tokenCount} tokens (target: <300)`, + ); } // Generate reference files @@ -299,13 +351,15 @@ export const handleGenerateSkills = async (args: string[]): Promise => { // Dry-run: output plan without writing files if (dryRun) { - console.log(JSON.stringify({ - dryRun: true, - service: serviceName, - outputDir, - files: [...generated.keys()], - tokenCount, - })); + console.log( + JSON.stringify({ + dryRun: true, + service: serviceName, + outputDir, + files: [...generated.keys()], + tokenCount, + }), + ); process.exitCode = EXIT_CODES.DRY_RUN; return; } diff --git a/src/cli/commands/service-discovery.ts b/src/cli/commands/service-discovery.ts index 7922a02..0ff62d2 100644 --- a/src/cli/commands/service-discovery.ts +++ b/src/cli/commands/service-discovery.ts @@ -6,7 +6,10 @@ import { hashToolSchema } from "../../cache/index.ts"; import type { CachedToolSchema } from "../../cache/index.ts"; import { listAllTools } from "../../schema/introspect.ts"; import type { SchemaOutput, ToolSummary } from "../../schema/types.ts"; -import { listToolsViaDaemon, getSchemaViaDaemon } from "./daemon-schema-client.ts"; +import { + listToolsViaDaemon, + getSchemaViaDaemon, +} from "./daemon-schema-client.ts"; import { resolveDirectServiceConfig } from "./direct-service.ts"; export interface ServiceDiscoveryResult { @@ -31,7 +34,10 @@ async function discoverServiceSchemasViaDaemon( serviceName: string, options: { fresh?: boolean }, ): Promise { - const listResult = await listToolsViaDaemon({ service: serviceName, fresh: options.fresh }); + const listResult = await listToolsViaDaemon({ + service: serviceName, + fresh: options.fresh, + }); if (!listResult.success) { throw new Error(listResult.error.message); } @@ -63,11 +69,12 @@ async function discoverServiceSchemasDirect( service: ServiceConfig, ): Promise { const directService = await resolveDirectServiceConfig(serviceName, service); - const connection = directService.backend === "http" - ? await connectToHttpService(directService) - : directService.backend === "websocket" - ? await connectToWebSocketService(directService) - : await connectToService(directService); + const connection = + directService.backend === "http" + ? await connectToHttpService(directService) + : directService.backend === "websocket" + ? await connectToWebSocketService(directService) + : await connectToService(directService); try { const rawTools = await listAllTools(connection.client); @@ -91,7 +98,9 @@ async function discoverServiceSchemasDirect( } } -async function schemaToCachedTool(schema: SchemaOutput): Promise { +async function schemaToCachedTool( + schema: SchemaOutput, +): Promise { return { name: schema.tool, description: schema.description, diff --git a/src/cli/commands/skills.ts b/src/cli/commands/skills.ts index 240d9c9..203f5db 100644 --- a/src/cli/commands/skills.ts +++ b/src/cli/commands/skills.ts @@ -65,14 +65,16 @@ export const handleSkills: CommandHandler = async (args: string[]) => { " generate [options] Generate/regenerate skill files", "", "EXAMPLES:", - ' mcp2cli skills list', - ' mcp2cli skills get open-brain > SKILL.md', - ' mcp2cli skills install open-brain --target ~/.hermes/skills/mcp/open-brain', - ' mcp2cli skills diff n8n', - ' mcp2cli skills generate n8n --conflict=merge', + " mcp2cli skills list", + " mcp2cli skills get open-brain > SKILL.md", + " mcp2cli skills install open-brain --target ~/.hermes/skills/mcp/open-brain", + " mcp2cli skills diff n8n", + " mcp2cli skills generate n8n --conflict=merge", ].join("\n"), ); - process.exitCode = subcommand ? EXIT_CODES.VALIDATION : EXIT_CODES.SUCCESS; + process.exitCode = subcommand + ? EXIT_CODES.VALIDATION + : EXIT_CODES.SUCCESS; break; } }; @@ -103,7 +105,9 @@ async function handleSkillsList(args: string[]): Promise { const exists = await file.exists(); if (!exists) { - const cached = cachedServices.includes(name) ? await readCacheRaw(name) : null; + const cached = cachedServices.includes(name) + ? await readCacheRaw(name) + : null; const visibleCachedTools = cached ? filterTools(cached.tools, extractPolicy(config.services[name]!)) : null; @@ -121,7 +125,9 @@ async function handleSkillsList(args: string[]): Promise { const hashMatch = content.match(/^schema_hash:\s*(\S+)/m); const existingHash = hashMatch?.[1]; - const cached = cachedServices.includes(name) ? await readCacheRaw(name) : null; + const cached = cachedServices.includes(name) + ? await readCacheRaw(name) + : null; const visibleCachedTools = cached ? filterTools(cached.tools, extractPolicy(config.services[name]!)) : null; @@ -161,12 +167,16 @@ async function handleSkillsList(args: string[]): Promise { for (const s of statuses) { const pad = " ".repeat(maxName - s.service.length + 2); const tools = s.toolCount !== undefined ? `${s.toolCount} tools` : ""; - const staleNote = s.status === "stale" && s.cachedToolCount !== undefined - ? ` (cache has ${s.cachedToolCount})` - : ""; - const statusTag = s.status === "generated" ? "ok" - : s.status === "stale" ? "stale" - : "missing"; + const staleNote = + s.status === "stale" && s.cachedToolCount !== undefined + ? ` (cache has ${s.cachedToolCount})` + : ""; + const statusTag = + s.status === "generated" + ? "ok" + : s.status === "stale" + ? "stale" + : "missing"; console.log(` ${s.service}${pad}${statusTag} ${tools}${staleNote}`); } } @@ -210,11 +220,15 @@ async function handleSkillsInstall(args: string[]): Promise { const targetIdx = args.indexOf("--target"); const target = targetArg ? targetArg.split("=").slice(1).join("=") - : targetIdx >= 0 ? args[targetIdx + 1] : undefined; + : targetIdx >= 0 + ? args[targetIdx + 1] + : undefined; const force = args.includes("--force"); if (!serviceName || serviceName.startsWith("--") || !target) { - console.error("Usage: mcp2cli skills install --target [--force]"); + console.error( + "Usage: mcp2cli skills install --target [--force]", + ); process.exitCode = EXIT_CODES.VALIDATION; return; } @@ -250,7 +264,9 @@ async function handleSkillsInstall(args: string[]): Promise { } const home = process.env.HOME ?? homedir(); if (absoluteTarget === home) { - console.error("Target path must not be the home directory root. Specify a subdirectory."); + console.error( + "Target path must not be the home directory root. Specify a subdirectory.", + ); process.exitCode = EXIT_CODES.VALIDATION; return; } @@ -259,7 +275,9 @@ async function handleSkillsInstall(args: string[]): Promise { const existingSkill = Bun.file(join(resolvedTarget, "SKILL.md")); const existingTarget = await existingSkill.exists(); if (existingTarget && !force) { - console.error(`Skill bundle already exists at ${resolvedTarget}. Use --force to overwrite.`); + console.error( + `Skill bundle already exists at ${resolvedTarget}. Use --force to overwrite.`, + ); process.exitCode = EXIT_CODES.VALIDATION; return; } @@ -270,7 +288,10 @@ async function handleSkillsInstall(args: string[]): Promise { await mkdir(resolvedTarget, { recursive: true }); // L16: Dereference symlinks when copying - await cp(resolved.skillDir, resolvedTarget, { recursive: true, dereference: true }); + await cp(resolved.skillDir, resolvedTarget, { + recursive: true, + dereference: true, + }); // L15: Simplified install message without inaccurate file count console.log(`Installed ${serviceName} skill bundle to ${resolvedTarget}`); diff --git a/src/cli/help.ts b/src/cli/help.ts index 2b0fbad..f1d3f0c 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -64,42 +64,83 @@ export function printHelp(args?: string[]): void { }, { name: "search", - description: "Search tool names and descriptions across cached services (alias: grep)", + description: + "Search tool names and descriptions across cached services (alias: grep)", usage: 'mcp2cli search "pattern" [--json]', }, { name: "audit", - description: "View and manage tool call audit logs (tail, search, stats, clear)", + description: + "View and manage tool call audit logs (tail, search, stats, clear)", usage: "mcp2cli audit |stats|clear|path>", }, { name: "skills", - description: "Manage service skill bundles (list, get, install, diff, generate)", + description: + "Manage service skill bundles (list, get, install, diff, generate)", usage: "mcp2cli skills ", }, { name: "batch", description: "Execute multiple tool calls from NDJSON stdin", - usage: "echo '{\"service\":\"n8n\",\"tool\":\"n8n_list_workflows\",\"params\":{}}' | mcp2cli batch [--parallel]", + usage: + 'echo \'{"service":"n8n","tool":"n8n_list_workflows","params":{}}\' | mcp2cli batch [--parallel]', }, { name: "credentials", - description: "Manage per-identity credential mappings for backend services", - usage: "mcp2cli credentials ", + description: + "Manage per-identity credential mappings for backend services", + usage: + "mcp2cli credentials ", subcommands: [ - { name: "list [identity]", description: "List all credentials or filter by identity" }, - { name: "set --header 'K: V' [--env 'K=V']", description: "Set credentials for an identity on a service" }, - { name: "set-default --header 'K: V' [--env 'K=V']", description: "Set default credentials for a service" }, - { name: "remove ", description: "Remove credentials for an identity on a service" }, - { name: "remove-default ", description: "Remove default credentials for a service" }, - { name: "resolve ", description: "Show effective credential for a user on a service" }, + { + name: "list [identity]", + description: "List all credentials or filter by identity", + }, + { + name: "set --header 'K: V' [--env 'K=V']", + description: "Set credentials for an identity on a service", + }, + { + name: "set-default --header 'K: V' [--env 'K=V']", + description: "Set default credentials for a service", + }, + { + name: "remove ", + description: "Remove credentials for an identity on a service", + }, + { + name: "remove-default ", + description: "Remove default credentials for a service", + }, + { + name: "resolve ", + description: + "Show effective credential for a user on a service", + }, { name: "group list", description: "List all credential groups" }, - { name: "group add ", description: "Create a credential group" }, - { name: "group add-members ", description: "Add members to an existing group" }, - { name: "group remove ", description: "Remove a credential group" }, - { name: "group remove-members ", description: "Remove members from a group" }, + { + name: "group add ", + description: "Create a credential group", + }, + { + name: "group add-members ", + description: "Add members to an existing group", + }, + { + name: "group remove ", + description: "Remove a credential group", + }, + { + name: "group remove-members ", + description: "Remove members from a group", + }, { name: "reload", description: "Reload credentials from disk" }, - { name: "bootstrap-open-brain [--item name] [--force]", description: "Populate Open Brain per-identity credentials from a Vaultwarden item" }, + { + name: "bootstrap-open-brain [--item name] [--force]", + description: + "Populate Open Brain per-identity credentials from a Vaultwarden item", + }, ], }, ], @@ -128,7 +169,7 @@ export function printHelp(args?: string[]): void { " bootstrap Auto-configure from claude.json MCP config", " generate-skills Generate skill files from service schemas", " cache Manage schema cache (clear, status)", - ' search Search tool names/descriptions across cached services', + " search Search tool names/descriptions across cached services", " audit View and manage tool call audit logs", " skills Manage service skill bundles (list, get, install)", " batch Execute multiple tool calls from NDJSON stdin", diff --git a/src/config/loader.ts b/src/config/loader.ts index 09a485f..def06da 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -91,8 +91,14 @@ async function maybeImportConfig( if (localConfig.importTtlSeconds !== undefined) { const ttlSeconds = localConfig.importTtlSeconds; const state = await readImportState(localPath); - const ageMs = state ? Date.now() - state.importedAt : Number.POSITIVE_INFINITY; - if (ttlSeconds > 0 && state?.url === localConfig.importUrl && ageMs < ttlSeconds * 1000) { + const ageMs = state + ? Date.now() - state.importedAt + : Number.POSITIVE_INFINITY; + if ( + ttlSeconds > 0 && + state?.url === localConfig.importUrl && + ageMs < ttlSeconds * 1000 + ) { try { await validateImportUrl(localConfig.importUrl); } catch (err) { @@ -114,7 +120,9 @@ async function maybeImportConfig( const importedRaw = await response.json(); const imported = ServicesConfigSchema.safeParse(importedRaw); if (!imported.success) { - const issues = imported.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", "); + const issues = imported.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join(", "); throw new Error(`validation failed: ${issues}`); } const merged = mergeImportedConfig(localConfig, imported.data); @@ -140,7 +148,9 @@ async function fetchImportUrl(rawUrl: string): Promise { let currentUrl = rawUrl; for (let redirectCount = 0; redirectCount <= 5; redirectCount++) { await validateImportUrl(currentUrl); - const headers = buildImportHeaders(new URL(currentUrl).origin === originalUrl.origin); + const headers = buildImportHeaders( + new URL(currentUrl).origin === originalUrl.origin, + ); const response = await fetch(currentUrl, { method: "GET", headers, @@ -159,7 +169,9 @@ async function fetchImportUrl(rawUrl: string): Promise { throw new Error("importUrl exceeded redirect limit"); } -function buildImportHeaders(includeAuthorization: boolean): Record { +function buildImportHeaders( + includeAuthorization: boolean, +): Record { const headers: Record = { Accept: "application/json" }; const token = process.env.MCP2CLI_IMPORT_TOKEN; if (token && includeAuthorization) { @@ -189,13 +201,16 @@ async function validateImportUrl(rawUrl: string): Promise { throw new Error(`importUrl host is not allowed: ${url.hostname}`); } - if (process.env.MCP2CLI_IMPORT_ALLOW_PRIVATE !== "1" && isPrivateImportHost(url.hostname)) { + if ( + process.env.MCP2CLI_IMPORT_ALLOW_PRIVATE !== "1" && + isPrivateImportHost(url.hostname) + ) { throw new Error(`importUrl host is private or local: ${url.hostname}`); } if ( - process.env.MCP2CLI_IMPORT_ALLOW_PRIVATE !== "1" - && process.env.MCP2CLI_IMPORT_ALLOW_DNS !== "1" - && !isIP(normalizeImportHostname(url.hostname)) + process.env.MCP2CLI_IMPORT_ALLOW_PRIVATE !== "1" && + process.env.MCP2CLI_IMPORT_ALLOW_DNS !== "1" && + !isIP(normalizeImportHostname(url.hostname)) ) { throw new Error("DNS importUrl hosts require MCP2CLI_IMPORT_ALLOW_DNS=1"); } @@ -203,7 +218,12 @@ async function validateImportUrl(rawUrl: string): Promise { } function parseCsvEnv(raw: string | undefined): Set { - return new Set((raw ?? "").split(",").map((part) => part.trim()).filter(Boolean)); + return new Set( + (raw ?? "") + .split(",") + .map((part) => part.trim()) + .filter(Boolean), + ); } function isPrivateImportHost(hostname: string): boolean { @@ -238,7 +258,12 @@ function isPrivateImportHost(hostname: string): boolean { } if (family === 6) { if (lower === "::1") return true; - if (lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd")) return true; + if ( + lower.startsWith("fe80:") || + lower.startsWith("fc") || + lower.startsWith("fd") + ) + return true; if (lower.startsWith("ff")) return true; } return false; @@ -258,22 +283,33 @@ async function validateResolvedImportHost(hostname: string): Promise { const addresses = await lookup(hostname, { all: true, verbatim: true }); for (const address of addresses) { if (isPrivateImportHost(address.address)) { - throw new Error(`importUrl host resolves to private or local address: ${hostname}`); + throw new Error( + `importUrl host resolves to private or local address: ${hostname}`, + ); } } } async function readImportState( localPath: string, -): Promise<{ url: string; importedAt: number; importedConfig: ServicesConfig } | null> { +): Promise<{ + url: string; + importedAt: number; + importedConfig: ServicesConfig; +} | null> { try { const raw = await Bun.file(importStatePath(localPath)).json(); if (!raw || typeof raw !== "object") return null; const obj = raw as Record; - if (typeof obj.url !== "string" || typeof obj.importedAt !== "number") return null; + if (typeof obj.url !== "string" || typeof obj.importedAt !== "number") + return null; const imported = ServicesConfigSchema.safeParse(obj.importedConfig); if (!imported.success) return null; - return { url: obj.url, importedAt: obj.importedAt, importedConfig: imported.data }; + return { + url: obj.url, + importedAt: obj.importedAt, + importedConfig: imported.data, + }; } catch { return null; } @@ -287,7 +323,8 @@ async function writeImportState( try { await Bun.write( importStatePath(localPath), - JSON.stringify({ url, importedAt: Date.now(), importedConfig }, null, 2) + "\n", + JSON.stringify({ url, importedAt: Date.now(), importedConfig }, null, 2) + + "\n", ); } catch (err) { log.warn("config_import_state_write_failed", { @@ -317,9 +354,13 @@ export function mergeImportedConfig( localConfig: ServicesConfig, importedConfig: ServicesConfig, ): ServicesConfig { - const services: ServicesConfig["services"] = structuredClone(localConfig.services); + const services: ServicesConfig["services"] = structuredClone( + localConfig.services, + ); - for (const [name, importedService] of Object.entries(importedConfig.services)) { + for (const [name, importedService] of Object.entries( + importedConfig.services, + )) { if (services[name]) continue; services[name] = structuredClone(importedService); services[name].source = importedService.source ?? "remote"; diff --git a/src/config/schema.ts b/src/config/schema.ts index e075813..6523433 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -7,13 +7,19 @@ import { z } from "zod"; * - "remote-local": try remote first, fall back to local if unreachable * When omitted, defaults to "local" (no remote URL) or "remote-local" (remote URL set). */ -export const SourceSchema = z.enum(["local", "remote", "remote-local"]).optional(); +export const SourceSchema = z + .enum(["local", "remote", "remote-local"]) + .optional(); export type ServiceSource = z.infer; -const SecretRefUrlSchema = z.string().refine( - (value) => (/^\$\{secret:[^}]+\}$/.test(value)) || z.string().url().safeParse(value).success, - { message: "Invalid url" }, -); +const SecretRefUrlSchema = z + .string() + .refine( + (value) => + /^\$\{secret:[^}]+\}$/.test(value) || + z.string().url().safeParse(value).success, + { message: "Invalid url" }, + ); /** * Tool access control fields shared by all service backends. diff --git a/src/credentials/index.ts b/src/credentials/index.ts index fcaa8a7..a8110f3 100644 --- a/src/credentials/index.ts +++ b/src/credentials/index.ts @@ -1,12 +1,6 @@ -export { - CredentialsConfigSchema, - ServiceCredentialSchema, -} from "./schema.ts"; +export { CredentialsConfigSchema, ServiceCredentialSchema } from "./schema.ts"; -export type { - CredentialsConfig, - ServiceCredential, -} from "./schema.ts"; +export type { CredentialsConfig, ServiceCredential } from "./schema.ts"; export { CredentialManager, diff --git a/src/credentials/open-brain-bootstrap.ts b/src/credentials/open-brain-bootstrap.ts index 3298db0..c62196f 100644 --- a/src/credentials/open-brain-bootstrap.ts +++ b/src/credentials/open-brain-bootstrap.ts @@ -41,7 +41,10 @@ export function normalizeOpenBrainToken(value: unknown): string | null { if (colonIdx > 0) { const role = trimmed.slice(0, colonIdx); const token = trimmed.slice(colonIdx + 1); - if (["admin", "agent", "discord", "n8n", "readonly"].includes(role) && token) { + if ( + ["admin", "agent", "discord", "n8n", "readonly"].includes(role) && + token + ) { return token; } } diff --git a/src/daemon/pool.ts b/src/daemon/pool.ts index 2945b2d..8d98a41 100644 --- a/src/daemon/pool.ts +++ b/src/daemon/pool.ts @@ -9,9 +9,16 @@ import { connectToHttpService } from "../connection/http-transport.ts"; import { connectToWebSocketService } from "../connection/websocket-transport.ts"; import { ConnectionError } from "../connection/errors.ts"; import type { McpConnection } from "../connection/types.ts"; -import type { ServicesConfig, HttpService, WebSocketService } from "../config/index.ts"; +import type { + ServicesConfig, + HttpService, + WebSocketService, +} from "../config/index.ts"; import { createLogger } from "../logger/index.ts"; -import { resolveServiceSecretRefs, VaultwardenSecretResolver } from "../secrets/index.ts"; +import { + resolveServiceSecretRefs, + VaultwardenSecretResolver, +} from "../secrets/index.ts"; import type { SecretResolver } from "../secrets/index.ts"; import { checkDriftOnConnect } from "./drift-hook.ts"; import { extractPolicy } from "../access/filter.ts"; @@ -52,9 +59,12 @@ export class ConnectionPool { constructor(options?: PoolOptions) { const envMax = parseInt(process.env.MCP2CLI_POOL_MAX ?? "", 10); - this._maxSize = options?.maxSize ?? (Number.isNaN(envMax) ? DEFAULT_POOL_MAX : envMax); - this._healthCheckTimeoutMs = options?.healthCheckTimeoutMs ?? DEFAULT_HEALTH_CHECK_TIMEOUT_MS; - this.secretResolver = options?.secretResolver ?? new VaultwardenSecretResolver(); + this._maxSize = + options?.maxSize ?? (Number.isNaN(envMax) ? DEFAULT_POOL_MAX : envMax); + this._healthCheckTimeoutMs = + options?.healthCheckTimeoutMs ?? DEFAULT_HEALTH_CHECK_TIMEOUT_MS; + this.secretResolver = + options?.secretResolver ?? new VaultwardenSecretResolver(); } /** Maximum number of concurrent connections allowed. */ @@ -103,7 +113,8 @@ export class ConnectionPool { log.warn("pool_nearing_limit", { size: this.connections.size, max: this._maxSize, - message: "Per-user credential connections may be contributing to pool usage", + message: + "Per-user credential connections may be contributing to pool usage", }); } if (this.connections.size >= this._maxSize) { @@ -115,7 +126,8 @@ export class ConnectionPool { } // Use override if provided, otherwise look up by service name - const rawServiceConfig = serviceConfigOverride ?? config.services[baseServiceName]; + const rawServiceConfig = + serviceConfigOverride ?? config.services[baseServiceName]; if (!rawServiceConfig) { throw new ConnectionError( `Service not found in config: ${baseServiceName}`, @@ -132,9 +144,17 @@ export class ConnectionPool { ); let connection: McpConnection; if (serviceConfig.backend === "http") { - connection = await this.connectHttpWithFallback(poolKey, baseServiceName, serviceConfig); + connection = await this.connectHttpWithFallback( + poolKey, + baseServiceName, + serviceConfig, + ); } else if (serviceConfig.backend === "websocket") { - connection = await this.connectWebSocketWithFallback(poolKey, baseServiceName, serviceConfig); + connection = await this.connectWebSocketWithFallback( + poolKey, + baseServiceName, + serviceConfig, + ); } else if (serviceConfig.backend === "stdio") { connection = await connectToService(serviceConfig); } else { @@ -157,7 +177,9 @@ export class ConnectionPool { // ADV-02: Fire-and-forget drift check on new connection // ADV-06: Pass access policy for skill auto-regeneration filtering const policy = extractPolicy(serviceConfig); - checkDriftOnConnect(baseServiceName, connection, policy).catch(() => {}); + checkDriftOnConnect(baseServiceName, connection, policy).catch( + () => {}, + ); return connection; }, (err) => { @@ -234,15 +256,22 @@ export class ConnectionPool { const PRECONNECT_CONCURRENCY = 4; const PRECONNECT_TIMEOUT_MS = 15_000; - const withTimeout = (name: string) => Promise.race([ - this.getConnection(name, config), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`preconnect timeout: ${name}`)), PRECONNECT_TIMEOUT_MS), - ), - ]); + const withTimeout = (name: string) => + Promise.race([ + this.getConnection(name, config), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`preconnect timeout: ${name}`)), + PRECONNECT_TIMEOUT_MS, + ), + ), + ]); const names = Object.entries(config.services) - .filter(([, service]) => service.preconnect !== false && service.requiresCredentials !== true) + .filter( + ([, service]) => + service.preconnect !== false && service.requiresCredentials !== true, + ) .map(([name]) => name); log.info("preconnect_start", { count: names.length }); @@ -259,7 +288,8 @@ export class ConnectionPool { if (failed > 0) { allResults.forEach((r, idx) => { if (r.status === "rejected") { - const msg = r.reason instanceof Error ? r.reason.message : String(r.reason); + const msg = + r.reason instanceof Error ? r.reason.message : String(r.reason); log.warn("preconnect_failed", { service: names[idx], error: msg }); } }); @@ -419,7 +449,10 @@ export class ConnectionPool { await Promise.race([ conn.client.listTools(), new Promise((_, reject) => - setTimeout(() => reject(new Error("health check timeout")), this._healthCheckTimeoutMs), + setTimeout( + () => reject(new Error("health check timeout")), + this._healthCheckTimeoutMs, + ), ), ]); return true; diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 9bfef13..938b411 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -16,7 +16,11 @@ import type { DaemonListenConfig, } from "./types.ts"; import { formatToolResult } from "../invocation/format.ts"; -import { listToolsCached, getToolSchemaCached, resolveToolNameCached } from "../schema/cached.ts"; +import { + listToolsCached, + getToolSchemaCached, + resolveToolNameCached, +} from "../schema/cached.ts"; import { getToolSchema, listToolsForService } from "../schema/introspect.ts"; import { auditToolCall, sanitizeParams } from "../logger/audit.ts"; import { checkToolAccess, extractPolicy } from "../access/index.ts"; @@ -30,7 +34,11 @@ import type { AuthProvider, AuthContext } from "./auth-provider.ts"; import type { MetricsCollector } from "./metrics.ts"; import { ConfigManager, ConfigManagerError } from "./config-manager.ts"; import type { CredentialManager } from "../credentials/index.ts"; -import { mergeCredentials, userPoolKey, applyCallerTemplates } from "./credential-merge.ts"; +import { + mergeCredentials, + userPoolKey, + applyCallerTemplates, +} from "./credential-merge.ts"; import type { CallerContext } from "./credential-merge.ts"; import { handleCredentialRoutes } from "./routes/credentials.ts"; import { renderUI } from "./ui.ts"; @@ -79,10 +87,21 @@ function errorResponse( * Returns the Bun.serve() server instance. */ export function createDaemonServer(opts: DaemonServerOptions) { - const { listenConfig, pool, config, configManager, credentialManager, idleTimer, onShutdown, authProvider, metrics } = opts; + const { + listenConfig, + pool, + config, + configManager, + credentialManager, + idleTimer, + onShutdown, + authProvider, + metrics, + } = opts; // Use configManager's live config for pool lookups when available - const getConfig = (): ServicesConfig => configManager ? configManager.getServices() : config; + const getConfig = (): ServicesConfig => + configManager ? configManager.getServices() : config; /** Resolve per-user credential pool key and optional service config override. */ function resolveCredentialPool( @@ -90,7 +109,10 @@ export function createDaemonServer(opts: DaemonServerOptions) { cm: CredentialManager | undefined, authContext: AuthContext | null, currentConfig: ServicesConfig, - ): { poolKey: string; serviceConfigOverride?: import("../config/index.ts").ServiceConfig } { + ): { + poolKey: string; + serviceConfigOverride?: import("../config/index.ts").ServiceConfig; + } { const caller: CallerContext | undefined = authContext ? { id: authContext.userId, role: authContext.role } : undefined; @@ -99,18 +121,26 @@ export function createDaemonServer(opts: DaemonServerOptions) { if (cm && authContext) { const resolved = cm.resolveWithSource(authContext.userId, service); if (resolved && baseCfg) { - const poolKey = resolved.source === "default" - ? service - : userPoolKey(service, `${resolved.source}:${resolved.identity}`); + const poolKey = + resolved.source === "default" + ? service + : userPoolKey(service, `${resolved.source}:${resolved.identity}`); return { poolKey, - serviceConfigOverride: mergeCredentials(baseCfg, resolved.credential, caller), + serviceConfigOverride: mergeCredentials( + baseCfg, + resolved.credential, + caller, + ), }; } } if (baseCfg?.requiresCredentials) { - throw new MissingServiceCredentialError(service, authContext?.userId ?? "anonymous"); + throw new MissingServiceCredentialError( + service, + authContext?.userId ?? "anonymous", + ); } if (caller && baseCfg && hasCallerTemplates(baseCfg)) { @@ -123,7 +153,9 @@ export function createDaemonServer(opts: DaemonServerOptions) { return { poolKey: service }; } - function hasCallerTemplates(config: import("../config/index.ts").ServiceConfig): boolean { + function hasCallerTemplates( + config: import("../config/index.ts").ServiceConfig, + ): boolean { const check = (val: string) => val.includes("${caller."); if ("headers" in config && config.headers) { if (Object.values(config.headers).some(check)) return true; @@ -135,9 +167,10 @@ export function createDaemonServer(opts: DaemonServerOptions) { } // Build listen options based on mode - const listenOpts = listenConfig.mode === "unix" - ? { unix: listenConfig.socketPath } - : { hostname: listenConfig.hostname, port: listenConfig.port }; + const listenOpts = + listenConfig.mode === "unix" + ? { unix: listenConfig.socketPath } + : { hostname: listenConfig.hostname, port: listenConfig.port }; return Bun.serve({ ...listenOpts, @@ -158,7 +191,12 @@ export function createDaemonServer(opts: DaemonServerOptions) { // RBAC permission check const denied = checkPermission(req, authCtx); if (denied) { - return errorResponse("AUTH_ERROR", `Permission denied: ${denied} requires higher role`, undefined, 403); + return errorResponse( + "AUTH_ERROR", + `Permission denied: ${denied} requires higher role`, + undefined, + 403, + ); } } @@ -193,15 +231,31 @@ export function createDaemonServer(opts: DaemonServerOptions) { }); // Resolve per-user credentials and determine pool key - const { poolKey, serviceConfigOverride } = resolveCredentialPool(body.service, credentialManager, authCtx, getConfig()); + const { poolKey, serviceConfigOverride } = resolveCredentialPool( + body.service, + credentialManager, + authCtx, + getConfig(), + ); - const conn = await pool.getConnection(poolKey, getConfig(), serviceConfigOverride, body.service); + const conn = await pool.getConnection( + poolKey, + getConfig(), + serviceConfigOverride, + body.service, + ); // MEM-02: AbortSignal timeout on tool calls // Priority: per-service config > MCP2CLI_TOOL_TIMEOUT env > 30s default - const serviceConfig = serviceConfigOverride ?? getConfig().services[body.service]; - const perServiceTimeout = serviceConfig && "timeout" in serviceConfig ? serviceConfig.timeout : undefined; - const timeout = perServiceTimeout ?? parseInt(process.env.MCP2CLI_TOOL_TIMEOUT ?? "30000", 10); + const serviceConfig = + serviceConfigOverride ?? getConfig().services[body.service]; + const perServiceTimeout = + serviceConfig && "timeout" in serviceConfig + ? serviceConfig.timeout + : undefined; + const timeout = + perServiceTimeout ?? + parseInt(process.env.MCP2CLI_TOOL_TIMEOUT ?? "30000", 10); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); @@ -212,10 +266,17 @@ export function createDaemonServer(opts: DaemonServerOptions) { let resolvedTool = body.tool; try { - const { resolvedName } = await resolveToolNameCached(conn.client, body.tool, poolKey); + const { resolvedName } = await resolveToolNameCached( + conn.client, + body.tool, + poolKey, + ); resolvedTool = resolvedName; - } catch { /* cache/listTools unavailable, use original name */ } - callResolvedTool = resolvedTool !== body.tool ? resolvedTool : undefined; + } catch { + /* cache/listTools unavailable, use original name */ + } + callResolvedTool = + resolvedTool !== body.tool ? resolvedTool : undefined; // Access control on resolved tool name (M7) if (serviceConfig) { @@ -223,7 +284,12 @@ export function createDaemonServer(opts: DaemonServerOptions) { const accessResult = checkToolAccess(resolvedTool, policy); if (!accessResult.allowed) { callError = "Tool blocked by access policy"; - return errorResponse("TOOL_BLOCKED", `Tool '${resolvedTool}' is blocked by access policy`, undefined, 403); + return errorResponse( + "TOOL_BLOCKED", + `Tool '${resolvedTool}' is blocked by access policy`, + undefined, + 403, + ); } } @@ -246,10 +312,12 @@ export function createDaemonServer(opts: DaemonServerOptions) { }), new Promise((_, reject) => { controller.signal.addEventListener("abort", () => - reject(new ToolError( - `Tool call timed out after ${timeout}ms`, - body.tool, - )), + reject( + new ToolError( + `Tool call timed out after ${timeout}ms`, + body.tool, + ), + ), ); }), ]); @@ -308,7 +376,13 @@ export function createDaemonServer(opts: DaemonServerOptions) { ...(callError ? { error: callError } : {}), }); - metrics.onRequestEnd(callService, callTool, success, duration, caller?.userId); + metrics.onRequestEnd( + callService, + callTool, + success, + duration, + caller?.userId, + ); auditToolCall({ path: "daemon", userId: caller?.userId, @@ -332,8 +406,21 @@ export function createDaemonServer(opts: DaemonServerOptions) { idleTimer.onRequestStart(); try { const body = (await req.json()) as DaemonListToolsRequest; - const { poolKey: listPoolKey, serviceConfigOverride: listServiceOverride } = resolveCredentialPool(body.service, credentialManager, authCtx, getConfig()); - const conn = await pool.getConnection(listPoolKey, getConfig(), listServiceOverride, body.service); + const { + poolKey: listPoolKey, + serviceConfigOverride: listServiceOverride, + } = resolveCredentialPool( + body.service, + credentialManager, + authCtx, + getConfig(), + ); + const conn = await pool.getConnection( + listPoolKey, + getConfig(), + listServiceOverride, + body.service, + ); const tools = body.fresh ? await listToolsForService(conn.client) : await listToolsCached(conn.client, listPoolKey); @@ -350,15 +437,24 @@ export function createDaemonServer(opts: DaemonServerOptions) { idleTimer.onRequestStart(); try { const body = (await req.json()) as DaemonSchemaRequest; - const { poolKey: schemaPoolKey, serviceConfigOverride: schemaServiceOverride } = resolveCredentialPool(body.service, credentialManager, authCtx, getConfig()); - const conn = await pool.getConnection(schemaPoolKey, getConfig(), schemaServiceOverride, body.service); + const { + poolKey: schemaPoolKey, + serviceConfigOverride: schemaServiceOverride, + } = resolveCredentialPool( + body.service, + credentialManager, + authCtx, + getConfig(), + ); + const conn = await pool.getConnection( + schemaPoolKey, + getConfig(), + schemaServiceOverride, + body.service, + ); const result = body.fresh ? await getToolSchema(conn.client, body.tool, schemaPoolKey) - : await getToolSchemaCached( - conn.client, - body.tool, - schemaPoolKey, - ); + : await getToolSchemaCached(conn.client, body.tool, schemaPoolKey); if (result === null) { return errorResponse( "UNKNOWN_COMMAND", @@ -405,10 +501,13 @@ export function createDaemonServer(opts: DaemonServerOptions) { // GET /metrics -- Prometheus metrics (auth-exempt) if (path === "/metrics" && req.method === "GET") { const body = metrics.render(pool.size, pool.baseServiceNames, { - includeCallerMetrics: process.env.MCP2CLI_METRICS_INCLUDE_CALLER === "1", + includeCallerMetrics: + process.env.MCP2CLI_METRICS_INCLUDE_CALLER === "1", }); return new Response(body, { - headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" }, + headers: { + "Content-Type": "text/plain; version=0.0.4; charset=utf-8", + }, }); } @@ -416,9 +515,17 @@ export function createDaemonServer(opts: DaemonServerOptions) { if (userMetricsMatch && req.method === "GET") { const userId = decodeURIComponent(userMetricsMatch[1]!); if (authCtx && authCtx.role !== "admin" && authCtx.userId !== userId) { - return errorResponse("AUTH_ERROR", "Permission denied: cannot read metrics for another user", undefined, 403); + return errorResponse( + "AUTH_ERROR", + "Permission denied: cannot read metrics for another user", + undefined, + 403, + ); } - return Response.json({ success: true, ...metrics.getUserBreakdown(userId) }); + return Response.json({ + success: true, + ...metrics.getUserBreakdown(userId), + }); } // POST /shutdown -- graceful shutdown @@ -431,17 +538,36 @@ export function createDaemonServer(opts: DaemonServerOptions) { // POST /api/auth/login -- exchange username+password for bearer token (auth-exempt) if (path === "/api/auth/login" && req.method === "POST") { try { - const body = await req.json() as { username?: string; password?: string }; + const body = (await req.json()) as { + username?: string; + password?: string; + }; if (!body.username || !body.password) { - return errorResponse("INPUT_VALIDATION_ERROR", "Missing username or password", undefined, 400); + return errorResponse( + "INPUT_VALIDATION_ERROR", + "Missing username or password", + undefined, + 400, + ); } if (!(authProvider instanceof TokenAuthProvider)) { - return errorResponse("AUTH_ERROR", "Login not supported with current auth provider", undefined, 501); + return errorResponse( + "AUTH_ERROR", + "Login not supported with current auth provider", + undefined, + 501, + ); } - const result = authProvider.authenticateBasic(body.username, body.password); + const result = authProvider.authenticateBasic( + body.username, + body.password, + ); if (!result) { metrics.onAuthFailure(); - return Response.json({ success: false, error: "Invalid username or password" }, { status: 401 }); + return Response.json( + { success: false, error: "Invalid username or password" }, + { status: 401 }, + ); } return Response.json({ success: true, @@ -450,22 +576,42 @@ export function createDaemonServer(opts: DaemonServerOptions) { role: result.ctx.role, }); } catch { - return errorResponse("INPUT_VALIDATION_ERROR", "Invalid request body", undefined, 400); + return errorResponse( + "INPUT_VALIDATION_ERROR", + "Invalid request body", + undefined, + 400, + ); } } // POST /api/auth/refresh -- rotate a valid near-expiry token from tokens.json if (path === "/api/auth/refresh" && req.method === "POST") { if (!(authProvider instanceof TokenAuthProvider)) { - return errorResponse("AUTH_ERROR", "Token refresh not supported with current auth provider", undefined, 501); + return errorResponse( + "AUTH_ERROR", + "Token refresh not supported with current auth provider", + undefined, + 501, + ); } const token = extractBearerToken(req); if (!token) { - return errorResponse("AUTH_ERROR", "Missing bearer token", undefined, 401); + return errorResponse( + "AUTH_ERROR", + "Missing bearer token", + undefined, + 401, + ); } const result = await authProvider.refreshBearerToken(token); if (!result.ok) { - return errorResponse("AUTH_ERROR", result.message, undefined, result.status); + return errorResponse( + "AUTH_ERROR", + result.message, + undefined, + result.status, + ); } return Response.json({ success: true, @@ -501,7 +647,9 @@ export function createDaemonServer(opts: DaemonServerOptions) { name, backend: svc.backend, connected: pool.baseServiceNames.includes(name), - ...(svc.backend !== "stdio" && "url" in svc ? { url: svc.url } : {}), + ...(svc.backend !== "stdio" && "url" in svc + ? { url: svc.url } + : {}), })); return Response.json({ success: true, services }); } @@ -514,17 +662,36 @@ export function createDaemonServer(opts: DaemonServerOptions) { // POST /api/services -- add a service { name, config } if (path === "/api/services" && req.method === "POST") { try { - const body = await req.json() as { name: string; config: unknown }; + const body = (await req.json()) as { + name: string; + config: unknown; + }; if (!body.name || !body.config) { - return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'name' or 'config' field", undefined, 400); + return errorResponse( + "INPUT_VALIDATION_ERROR", + "Missing 'name' or 'config' field", + undefined, + 400, + ); } await configManager.addService(body.name, body.config); - return Response.json({ success: true, message: `Service '${body.name}' added` }, { status: 201 }); + return Response.json( + { success: true, message: `Service '${body.name}' added` }, + { status: 201 }, + ); } catch (err) { if (err instanceof ConfigManagerError) { - return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + return errorResponse( + "INPUT_VALIDATION_ERROR", + err.message, + undefined, + 400, + ); } - return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + return errorResponse( + "INTERNAL_ERROR", + err instanceof Error ? err.message : String(err), + ); } } @@ -533,17 +700,33 @@ export function createDaemonServer(opts: DaemonServerOptions) { if (putMatch && req.method === "PUT") { try { const name = decodeURIComponent(putMatch[1]!); - const body = await req.json() as { config: unknown }; + const body = (await req.json()) as { config: unknown }; if (!body.config) { - return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'config' field", undefined, 400); + return errorResponse( + "INPUT_VALIDATION_ERROR", + "Missing 'config' field", + undefined, + 400, + ); } await configManager.updateService(name, body.config); - return Response.json({ success: true, message: `Service '${name}' updated` }); + return Response.json({ + success: true, + message: `Service '${name}' updated`, + }); } catch (err) { if (err instanceof ConfigManagerError) { - return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + return errorResponse( + "INPUT_VALIDATION_ERROR", + err.message, + undefined, + 400, + ); } - return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + return errorResponse( + "INTERNAL_ERROR", + err instanceof Error ? err.message : String(err), + ); } } @@ -553,12 +736,23 @@ export function createDaemonServer(opts: DaemonServerOptions) { try { const name = decodeURIComponent(deleteMatch[1]!); await configManager.removeService(name); - return Response.json({ success: true, message: `Service '${name}' removed` }); + return Response.json({ + success: true, + message: `Service '${name}' removed`, + }); } catch (err) { if (err instanceof ConfigManagerError) { - return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + return errorResponse( + "INPUT_VALIDATION_ERROR", + err.message, + undefined, + 400, + ); } - return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + return errorResponse( + "INTERNAL_ERROR", + err instanceof Error ? err.message : String(err), + ); } } @@ -569,7 +763,12 @@ export function createDaemonServer(opts: DaemonServerOptions) { const name = decodeURIComponent(statusMatch[1]!); const svc = configManager.getService(name); if (!svc) { - return errorResponse("UNKNOWN_COMMAND", `Service not found: ${name}`, undefined, 404); + return errorResponse( + "UNKNOWN_COMMAND", + `Service not found: ${name}`, + undefined, + 404, + ); } const connected = pool.baseServiceNames.includes(name); let toolCount = 0; @@ -578,7 +777,9 @@ export function createDaemonServer(opts: DaemonServerOptions) { const conn = await pool.getConnection(name, getConfig()); const tools = await listToolsCached(conn.client, name); toolCount = tools.length; - } catch { /* connection may have gone stale */ } + } catch { + /* connection may have gone stale */ + } } return Response.json({ success: true, @@ -588,7 +789,10 @@ export function createDaemonServer(opts: DaemonServerOptions) { toolCount, }); } catch (err) { - return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + return errorResponse( + "INTERNAL_ERROR", + err instanceof Error ? err.message : String(err), + ); } } @@ -599,16 +803,24 @@ export function createDaemonServer(opts: DaemonServerOptions) { return Response.json({ success: true, ...diff }); } catch (err) { if (err instanceof ConfigManagerError) { - return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + return errorResponse( + "INPUT_VALIDATION_ERROR", + err.message, + undefined, + 400, + ); } - return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + return errorResponse( + "INTERNAL_ERROR", + err instanceof Error ? err.message : String(err), + ); } } // POST /api/services/import -- import from URL { url, mode?, repo?, branch?, path? } if (path === "/api/services/import" && req.method === "POST") { try { - const body = await req.json() as { + const body = (await req.json()) as { url?: string; mode?: "merge" | "replace"; repo?: string; @@ -624,22 +836,44 @@ export function createDaemonServer(opts: DaemonServerOptions) { ); } if (!importUrl) { - return errorResponse("INPUT_VALIDATION_ERROR", "Missing 'url' or 'repo' field", undefined, 400); + return errorResponse( + "INPUT_VALIDATION_ERROR", + "Missing 'url' or 'repo' field", + undefined, + 400, + ); } - const diff = await configManager.importFromUrl(importUrl, body.mode ?? "merge"); + const diff = await configManager.importFromUrl( + importUrl, + body.mode ?? "merge", + ); return Response.json({ success: true, url: importUrl, ...diff }); } catch (err) { if (err instanceof ConfigManagerError) { - return errorResponse("INPUT_VALIDATION_ERROR", err.message, undefined, 400); + return errorResponse( + "INPUT_VALIDATION_ERROR", + err.message, + undefined, + 400, + ); } - return errorResponse("INTERNAL_ERROR", err instanceof Error ? err.message : String(err)); + return errorResponse( + "INTERNAL_ERROR", + err instanceof Error ? err.message : String(err), + ); } } } // --- Credential management API routes (require credentialManager) --- if (credentialManager) { - const credResponse = await handleCredentialRoutes(req, url, path, credentialManager, authCtx); + const credResponse = await handleCredentialRoutes( + req, + url, + path, + credentialManager, + authCtx, + ); if (credResponse) return credResponse; } @@ -660,10 +894,7 @@ function extractBearerToken(req: Request): string | null { } /** Shared error handler for /call, /list-tools, /schema endpoints */ -function handleEndpointError( - err: unknown, - _pool: ConnectionPool, -): Response { +function handleEndpointError(err: unknown, _pool: ConnectionPool): Response { if (err instanceof ConnectionError) { return errorResponse("CONNECTION_ERROR", err.message, err.reason); } diff --git a/src/generation/auto-regen.ts b/src/generation/auto-regen.ts index 1c233af..75ba482 100644 --- a/src/generation/auto-regen.ts +++ b/src/generation/auto-regen.ts @@ -7,9 +7,17 @@ import type { ToolSummary } from "../schema/types.ts"; import type { AccessPolicy } from "../access/types.ts"; import type { SkillTemplateInput } from "./types.ts"; import { filterTools } from "../access/filter.ts"; -import { generateSkillMd, generateReferenceMd, estimateTokens } from "./templates.ts"; +import { + generateSkillMd, + generateReferenceMd, + estimateTokens, +} from "./templates.ts"; import { detectPrefixGroups } from "./grouping.ts"; -import { resolveOutputDir, planFileWrites, executeFileWrites } from "./file-manager.ts"; +import { + resolveOutputDir, + planFileWrites, + executeFileWrites, +} from "./file-manager.ts"; import { extractManualSections, injectManualSections } from "./preserve.ts"; import { computeSchemaHash } from "./skill-hash.ts"; import { createLogger } from "../logger/index.ts"; @@ -70,23 +78,42 @@ export async function autoRegenerateSkills( const resolvedDir = outputDir ?? resolveOutputDir(serviceName); const existingSkillPath = join(resolvedDir, "SKILL.md"); const existingFile = Bun.file(existingSkillPath); - const existingContent = await existingFile.exists() ? await existingFile.text() : null; + const existingContent = (await existingFile.exists()) + ? await existingFile.text() + : null; // Reuse existing timestamp if hash hasn't changed let generatedAt = new Date().toISOString(); if (existingContent) { const existingHashMatch = existingContent.match(/^schema_hash:\s*(\S+)/m); if (existingHashMatch?.[1] === schemaHash) { - const existingAtMatch = existingContent.match(/^generated_at:\s*(\S+)/m); + const existingAtMatch = existingContent.match( + /^generated_at:\s*(\S+)/m, + ); generatedAt = existingAtMatch?.[1] ?? generatedAt; } } + // Build schemas for grouping + reference file generation. + // Use minimal SchemaOutput objects from ToolSummary data. + const schemas = filteredTools.map((t) => ({ + tool: t.name, + description: t.description, + inputSchema: {} as object, + usage: `mcp2cli ${serviceName} ${t.name}`, + })); + + const groups = detectPrefixGroups(schemas, serviceName); + const input: SkillTemplateInput = { serviceName, description: `MCP tools for ${serviceName}`, tools: filteredTools, triggerKeywords: [serviceName], + // Pass groups so auto-regen emits the same slim group-routing index as a + // manual `generate-skills` run; without it the drift hook regenerated the + // flat fallback format, diverging from the manual path. + groups, generatedAt, schemaHash, toolCount: filteredTools.length, @@ -104,17 +131,6 @@ export async function autoRegenerateSkills( } } - // Build schemas for reference file generation - // Use minimal SchemaOutput objects from ToolSummary data - const schemas = filteredTools.map((t) => ({ - tool: t.name, - description: t.description, - inputSchema: {} as object, - usage: `mcp2cli ${serviceName} ${t.name}`, - })); - - const groups = detectPrefixGroups(schemas, serviceName); - // Collect generated files const generated = new Map(); generated.set("SKILL.md", skillMd); diff --git a/src/generation/diff.ts b/src/generation/diff.ts index e4ea528..f3e4970 100644 --- a/src/generation/diff.ts +++ b/src/generation/diff.ts @@ -4,6 +4,7 @@ * to produce a human-readable diff showing added, removed, and modified tools. */ import type { ToolSummary } from "../schema/types.ts"; +import { MARKER_START, MARKER_END } from "./templates.ts"; /** Classification of a single tool change */ export interface ToolChange { @@ -27,54 +28,96 @@ export interface SkillDiffResult { } /** - * Parse tool names and descriptions from an existing SKILL.md file. - * Extracts from the quick reference table (| Tool | Description |). + * Parse tool names from an existing SKILL.md front skill. + * + * The slim front skill lists tools two ways, both supported here: + * - a "Tool Groups" table whose middle column is a comma-separated tool list + * (`| Group | Tools | Reference |`), or + * - a flat fallback bullet list (`- tool_name`) when no grouping was available. + * + * Descriptions live in `references/*.md`, not the front skill, so they are not + * recovered here -- name-level add/remove diffing plus the frontmatter + * `schema_hash` are the drift signals for the front skill. + * + * The legacy `| Tool | Description |` quick-reference table is still parsed so + * diffs against pre-existing generated skills keep working during migration. */ export function parseExistingTools(skillContent: string): ToolSummary[] { const tools: ToolSummary[] = []; - const lines = skillContent.split("\n"); - let inTable = false; + // Only parse the auto-generated block. Outside it live YAML frontmatter + // (whose `triggers:` bullets look like flat tool entries) and the manual + // Notes section -- scanning those produced phantom tools and made every + // `--diff` report spurious removals. If the markers are absent (e.g. a + // pre-marker hand-authored file), fall back to scanning the whole content. + const startIdx = skillContent.indexOf(MARKER_START); + const endIdx = skillContent.indexOf(MARKER_END); + const scoped = + startIdx !== -1 && endIdx !== -1 && endIdx > startIdx + ? skillContent.slice(startIdx + MARKER_START.length, endIdx) + : skillContent; + const lines = scoped.split("\n"); + + // Mode: parsing rows of a markdown table we recognized via its header. + type TableKind = "legacy" | "groups" | null; + let tableKind: TableKind = null; let headerSeen = false; for (const line of lines) { const trimmed = line.trim(); - // Detect table start by header row (exact match on "| Tool |" pattern) + // Legacy quick-reference table header: | Tool | Description | if ( (trimmed.startsWith("| Tool |") || trimmed.startsWith("| tool |")) && trimmed.includes("Description") ) { - inTable = true; + tableKind = "legacy"; + headerSeen = false; + continue; + } + + // New group-index table header: | Group | Tools | Reference | + if (trimmed.startsWith("| Group |") && trimmed.includes("Tools")) { + tableKind = "groups"; headerSeen = false; continue; } // Skip separator row (|------|...) - if (inTable && !headerSeen && trimmed.startsWith("|---")) { + if (tableKind && !headerSeen && trimmed.startsWith("|---")) { headerSeen = true; continue; } // Parse data rows - if (inTable && headerSeen && trimmed.startsWith("|")) { + if (tableKind && headerSeen && trimmed.startsWith("|")) { const cells = trimmed .split("|") .map((c) => c.trim()) .filter((c) => c.length > 0); - if (cells.length >= 2) { - tools.push({ - name: cells[0]!, - description: cells[1]!, - }); + if (tableKind === "legacy" && cells.length >= 2) { + tools.push({ name: cells[0]!, description: cells[1]! }); + } else if (tableKind === "groups" && cells.length >= 2) { + // Middle column is a comma-separated list of tool names. + for (const name of cells[1]!.split(",").map((n) => n.trim())) { + if (name.length > 0) tools.push({ name, description: "" }); + } } continue; } // End of table - if (inTable && headerSeen && !trimmed.startsWith("|")) { - inTable = false; + if (tableKind && headerSeen && !trimmed.startsWith("|")) { + tableKind = null; + } + + // Flat fallback list: "- tool_name" (no table, no spaces in the name). + if (!tableKind && trimmed.startsWith("- ")) { + const name = trimmed.slice(2).trim(); + if (name.length > 0 && !name.includes(" ")) { + tools.push({ name, description: "" }); + } } } @@ -109,7 +152,13 @@ export function computeSkillDiff( const existing = existingMap.get(name); if (!existing) { added.push({ tool: name, type: "added" }); - } else if (existing.description !== newTool.description) { + } else if ( + // The slim group-index front skill stores no per-tool descriptions, so a + // parsed empty description means "unknown", not "changed to empty". Only + // flag a real description change to avoid every tool showing as modified. + existing.description !== "" && + existing.description !== newTool.description + ) { modified.push({ tool: name, type: "modified", diff --git a/src/generation/file-manager.ts b/src/generation/file-manager.ts index 28eff3a..e84130b 100644 --- a/src/generation/file-manager.ts +++ b/src/generation/file-manager.ts @@ -42,9 +42,7 @@ interface FrontmatterBlock { } function splitFrontmatterBlocks(frontmatter: string): FrontmatterBlock[] { - const body = frontmatter - .replace(/^---\n/, "") - .replace(/\n---\n?$/, ""); + const body = frontmatter.replace(/^---\n/, "").replace(/\n---\n?$/, ""); const blocks: FrontmatterBlock[] = []; for (const line of body.split("\n")) { @@ -73,7 +71,9 @@ function triggerValues(block: FrontmatterBlock | undefined): string[] { const inlineScalarMatch = firstLine.match(/^[A-Za-z0-9_-]+:\s*(\S.*)$/); if (inlineScalarMatch) { - return [inlineScalarMatch[1]!.trim().replace(/^['"]|['"]$/g, "")].filter(Boolean); + return [inlineScalarMatch[1]!.trim().replace(/^['"]|['"]$/g, "")].filter( + Boolean, + ); } return block.lines @@ -82,11 +82,16 @@ function triggerValues(block: FrontmatterBlock | undefined): string[] { .filter((value): value is string => Boolean(value)); } -function mergeFrontmatter(existingFrontmatter: string, generatedFrontmatter: string): string { +function mergeFrontmatter( + existingFrontmatter: string, + generatedFrontmatter: string, +): string { const existingBlocks = splitFrontmatterBlocks(existingFrontmatter); const generatedBlocks = splitFrontmatterBlocks(generatedFrontmatter); const generatedKeys = new Set(generatedBlocks.map((block) => block.key)); - const existingByKey = new Map(existingBlocks.map((block) => [block.key, block])); + const existingByKey = new Map( + existingBlocks.map((block) => [block.key, block]), + ); const merged: string[] = ["---"]; for (const generatedBlock of generatedBlocks) { @@ -115,7 +120,10 @@ function mergeFrontmatter(existingFrontmatter: string, generatedFrontmatter: str return merged.join("\n") + "\n"; } -function mergeGeneratedFrontmatter(existing: string, generated: string): string { +function mergeGeneratedFrontmatter( + existing: string, + generated: string, +): string { const generatedFrontmatter = generated.match(FRONTMATTER_RE)?.[0]; if (!generatedFrontmatter) { return existing; @@ -123,7 +131,7 @@ function mergeGeneratedFrontmatter(existing: string, generated: string): string if (FRONTMATTER_RE.test(existing)) { return existing.replace(FRONTMATTER_RE, (existingFrontmatter) => - mergeFrontmatter(existingFrontmatter, generatedFrontmatter) + mergeFrontmatter(existingFrontmatter, generatedFrontmatter), ); } diff --git a/src/generation/templates.ts b/src/generation/templates.ts index e1aeb4a..81b9776 100644 --- a/src/generation/templates.ts +++ b/src/generation/templates.ts @@ -12,16 +12,22 @@ export function estimateTokens(text: string): number { } /** Auto-generated section markers */ -const MARKER_START = ""; -const MARKER_END = ""; +export const MARKER_START = ""; +export const MARKER_END = ""; /** Manual (user-editable) section markers */ const MANUAL_START = ""; const MANUAL_END = ""; /** - * Generate a slim SKILL.md file with YAML frontmatter, tool table, and invoke pattern. - * Stays under 300 tokens for typical services. + * Generate a slim SKILL.md front skill: YAML frontmatter, a routing index of + * tool *groups* (each linking to its reference file), and the invoke pattern. + * + * Progressive-disclosure shape (per PAI skill-creator standard): the front skill + * carries only routing -- the per-tool detail lives in `references/*.md`. The flat + * per-tool listing is intentionally NOT inlined here; it would blow the token + * budget for large services (a 46-tool service was ~1100 tokens with the old + * inline table). Falls back to a flat tool list only when no groups are supplied. */ export function generateSkillMd(input: SkillTemplateInput): string { const lines: string[] = []; @@ -56,15 +62,30 @@ export function generateSkillMd(input: SkillTemplateInput): string { lines.push(MARKER_START); lines.push(""); - // Quick reference tool table - lines.push("## Quick Reference"); - lines.push(""); - lines.push("| Tool | Description |"); - lines.push("|------|-------------|"); - for (const tool of input.tools) { - lines.push(`| ${tool.name} | ${tool.description.replace(/\|/g, "\\|")} |`); + const groups = input.groups ?? []; + + if (groups.length > 0) { + // Routing index: one row per group, linking to its reference file. + lines.push("## Tool Groups"); + lines.push(""); + lines.push("| Group | Tools | Reference |"); + lines.push("|-------|-------|-----------|"); + for (const group of groups) { + const names = group.tools.map((t) => t.tool).join(", "); + lines.push( + `| ${group.label} | ${names.replace(/\|/g, "\\|")} | [${group.filename}](references/${group.filename}) |`, + ); + } + lines.push(""); + } else { + // Fallback (no grouping available): flat tool name list, no descriptions. + lines.push("## Tools"); + lines.push(""); + for (const tool of input.tools) { + lines.push(`- ${tool.name}`); + } + lines.push(""); } - lines.push(""); // Invoke pattern lines.push("## Usage"); @@ -75,7 +96,7 @@ export function generateSkillMd(input: SkillTemplateInput): string { lines.push(""); // References pointer - lines.push("See `references/` for detailed parameter docs per tool."); + lines.push("See `references/` for per-tool parameters, types, and examples."); lines.push(""); lines.push(MARKER_END); diff --git a/src/generation/types.ts b/src/generation/types.ts index 8404be5..ff3eb0b 100644 --- a/src/generation/types.ts +++ b/src/generation/types.ts @@ -10,6 +10,8 @@ export interface SkillTemplateInput { description: string; tools: ToolSummary[]; triggerKeywords: string[]; + /** Tool groups used to build the slim front-skill reference index. */ + groups?: ToolGroup[]; generatedAt?: string; schemaHash?: string; toolCount?: number; diff --git a/src/process/client.ts b/src/process/client.ts index 39ef0df..85d0a98 100644 --- a/src/process/client.ts +++ b/src/process/client.ts @@ -25,7 +25,10 @@ function readPositiveIntEnv(name: string, fallback: number): number { return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } -const STARTUP_TIMEOUT_MS = readPositiveIntEnv("MCP2CLI_STARTUP_TIMEOUT", 10_000); +const STARTUP_TIMEOUT_MS = readPositiveIntEnv( + "MCP2CLI_STARTUP_TIMEOUT", + 10_000, +); const STARTUP_POLL_MS = 50; const REQUEST_TIMEOUT_MS = 60_000; const REMOTE_CONNECT_TIMEOUT_MS = 10_000; @@ -57,14 +60,19 @@ async function getLocalToken(): Promise { } // Read tokens.json - const tokensPath = process.env.MCP2CLI_TOKENS_FILE - ?? join(process.env.HOME ?? "", ".config", "mcp2cli", "tokens.json"); + const tokensPath = + process.env.MCP2CLI_TOKENS_FILE ?? + join(process.env.HOME ?? "", ".config", "mcp2cli", "tokens.json"); try { const file = Bun.file(tokensPath); if (await file.exists()) { - const raw = await file.json() as { tokens?: Array<{ token: string; role: string; expiresAt?: string }> }; + const raw = (await file.json()) as { + tokens?: Array<{ token: string; role: string; expiresAt?: string }>; + }; // Use the first non-expired admin token for local socket auth. - const adminEntry = raw.tokens?.find((t) => t.role === "admin" && !isExpiredToken(t.expiresAt)); + const adminEntry = raw.tokens?.find( + (t) => t.role === "admin" && !isExpiredToken(t.expiresAt), + ); cachedLocalToken = adminEntry?.token; cachedLocalTokenExpiresAt = adminEntry?.expiresAt; } @@ -91,7 +99,10 @@ async function buildLocalHeaders(): Promise> { return headers; } -async function refreshLocalToken(paths: DaemonPaths, token: string): Promise { +async function refreshLocalToken( + paths: DaemonPaths, + token: string, +): Promise { const response = await fetch("http://localhost/api/auth/refresh", { unix: paths.socketPath, method: "POST", @@ -104,29 +115,41 @@ async function refreshLocalToken(paths: DaemonPaths, token: string): Promise null) as { token?: unknown; expiresAt?: unknown } | null; + const body = (await response.json().catch(() => null)) as { + token?: unknown; + expiresAt?: unknown; + } | null; if (typeof body?.token !== "string") return reloadLocalTokenIfChanged(token); cachedLocalToken = body.token; - cachedLocalTokenExpiresAt = typeof body.expiresAt === "string" ? body.expiresAt : undefined; + cachedLocalTokenExpiresAt = + typeof body.expiresAt === "string" ? body.expiresAt : undefined; localTokenResolved = true; return true; } -async function reloadLocalTokenIfChanged(previousToken: string): Promise { +async function reloadLocalTokenIfChanged( + previousToken: string, +): Promise { clearLocalTokenCache(); const latestToken = await getLocalToken(); return latestToken !== undefined && latestToken !== previousToken; } -async function refreshLocalTokenIfNearExpiry(paths: DaemonPaths): Promise { +async function refreshLocalTokenIfNearExpiry( + paths: DaemonPaths, +): Promise { const token = await getLocalToken(); if (!token || !cachedLocalTokenExpiresAt) return; const expiresAtMs = Date.parse(cachedLocalTokenExpiresAt); if (Number.isNaN(expiresAtMs)) return; - const refreshWindowMs = parseInt(process.env.MCP2CLI_TOKEN_REFRESH_WINDOW_MS ?? String(24 * 60 * 60 * 1000), 10); - const windowMs = Number.isFinite(refreshWindowMs) && refreshWindowMs > 0 - ? refreshWindowMs - : 24 * 60 * 60 * 1000; + const refreshWindowMs = parseInt( + process.env.MCP2CLI_TOKEN_REFRESH_WINDOW_MS ?? String(24 * 60 * 60 * 1000), + 10, + ); + const windowMs = + Number.isFinite(refreshWindowMs) && refreshWindowMs > 0 + ? refreshWindowMs + : 24 * 60 * 60 * 1000; if (expiresAtMs - Date.now() <= windowMs) { await refreshLocalToken(paths, token); } @@ -142,9 +165,7 @@ export function clearLocalTokenCache(): void { * Start the daemon as a background process. * Detects dev vs compiled mode for correct spawn args. */ -export async function startDaemonProcess( - paths: DaemonPaths, -): Promise { +export async function startDaemonProcess(paths: DaemonPaths): Promise { // Ensure runtime directory exists await mkdir(dirname(paths.pidFile), { recursive: true }); @@ -208,7 +229,10 @@ export async function waitForDaemonReady( async function acquireStartLock(paths: DaemonPaths): Promise { const lockPath = paths.pidFile + ".lock"; try { - const fd = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY); + const fd = await open( + lockPath, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, + ); await fd.close(); return true; } catch { @@ -290,7 +314,9 @@ async function getLocalConfig(): Promise { * - "remote-local" when MCP2CLI_REMOTE_URL is set * - "local" when no remote URL */ -export async function resolveSource(serviceName: string | undefined): Promise { +export async function resolveSource( + serviceName: string | undefined, +): Promise { if (!serviceName) return undefined; const config = await getLocalConfig(); const svc = config?.services[serviceName]; @@ -345,7 +371,10 @@ async function fetchLocal( } const REMOTE_RETRIES = parseInt(process.env.MCP2CLI_REMOTE_RETRIES ?? "3", 10); -const REMOTE_BACKOFF_BASE_MS = parseInt(process.env.MCP2CLI_REMOTE_BACKOFF_MS ?? "500", 10); +const REMOTE_BACKOFF_BASE_MS = parseInt( + process.env.MCP2CLI_REMOTE_BACKOFF_MS ?? "500", + 10, +); async function fetchRemote( path: string, @@ -385,7 +414,10 @@ async function fetchRemote( return result; } catch (err) { // Auth errors are permanent -- bail immediately, don't retry - if (err instanceof Error && err.message.startsWith("Remote auth failed")) { + if ( + err instanceof Error && + err.message.startsWith("Remote auth failed") + ) { throw err; } lastError = err; @@ -410,9 +442,10 @@ async function fetchDaemon( path: string, body?: unknown, ): Promise { - const serviceName = body && typeof body === "object" && "service" in body - ? (body as { service: string }).service - : undefined; + const serviceName = + body && typeof body === "object" && "service" in body + ? (body as { service: string }).service + : undefined; const remote = getRemoteConfig(); const explicitSource = await resolveSource(serviceName); @@ -434,14 +467,19 @@ async function fetchDaemon( if (isRemoteAuthError(err)) { throw err; } - if (serviceName && await isIdentitySensitiveService(serviceName)) { + if (serviceName && (await isIdentitySensitiveService(serviceName))) { throw err; } return await fetchLocal(path, body); } } catch (err) { const message = err instanceof Error ? err.message : String(err); - const target = source === "local" ? "local daemon" : remote ? `remote daemon at ${remote.url}` : "daemon"; + const target = + source === "local" + ? "local daemon" + : remote + ? `remote daemon at ${remote.url}` + : "daemon"; return { success: false, error: { @@ -456,7 +494,9 @@ function isRemoteAuthError(err: unknown): boolean { return err instanceof Error && err.message.startsWith("Remote auth failed"); } -async function isIdentitySensitiveService(serviceName: string): Promise { +async function isIdentitySensitiveService( + serviceName: string, +): Promise { try { const config = await getLocalConfig(); if (!config) { diff --git a/tests/cli/daemon-routed-discovery.test.ts b/tests/cli/daemon-routed-discovery.test.ts index abd9fc6..1f77fc4 100644 --- a/tests/cli/daemon-routed-discovery.test.ts +++ b/tests/cli/daemon-routed-discovery.test.ts @@ -12,20 +12,22 @@ const listToolsViaDaemon = mock(async () => ({ ], })); -const getSchemaViaDaemon = mock(async ({ tool }: { service: string; tool: string }) => ({ - success: true, - result: { - tool, - description: `${tool} full description`, - inputSchema: { - type: "object", - properties: { - query: { type: "string" }, +const getSchemaViaDaemon = mock( + async ({ tool }: { service: string; tool: string }) => ({ + success: true, + result: { + tool, + description: `${tool} full description`, + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, }, + usage: `mcp2cli open-brain ${tool}`, }, - usage: `mcp2cli open-brain ${tool}`, - }, -})); + }), +); mock.module("../../src/cli/commands/daemon-schema-client.ts", () => ({ listToolsViaDaemon, @@ -64,15 +66,18 @@ describe("daemon-routed schema discovery commands", () => { testDir = await mkdtemp(join(tmpdir(), "mcp2cli-daemon-discovery-test-")); await mkdir(join(testDir, "skills"), { recursive: true }); const configPath = join(testDir, "services.json"); - await Bun.write(configPath, JSON.stringify({ - services: { - "open-brain": { - backend: "http", - url: "http://10.71.1.21:3100/mcp", - description: "Open Brain", + await Bun.write( + configPath, + JSON.stringify({ + services: { + "open-brain": { + backend: "http", + url: "http://10.71.1.21:3100/mcp", + description: "Open Brain", + }, }, - }, - })); + }), + ); originalConfig = process.env.MCP2CLI_CONFIG; originalCacheDir = process.env.MCP2CLI_CACHE_DIR; @@ -96,11 +101,18 @@ describe("daemon-routed schema discovery commands", () => { test("cache warm uses daemon-routed discovery and writes cache", async () => { const { handleCache } = await import("../../src/cli/commands/cache.ts"); - const output = await captureOutput(() => handleCache(["warm", "open-brain"])); + const output = await captureOutput(() => + handleCache(["warm", "open-brain"]), + ); - expect(output.stderr).not.toContain("direct HTTP transport should not be used"); + expect(output.stderr).not.toContain( + "direct HTTP transport should not be used", + ); expect(output.stdout).toContain("open-brain: 2 tools cached"); - expect(listToolsViaDaemon).toHaveBeenCalledWith({ service: "open-brain", fresh: true }); + expect(listToolsViaDaemon).toHaveBeenCalledWith({ + service: "open-brain", + fresh: true, + }); expect(getSchemaViaDaemon).toHaveBeenCalledTimes(2); expect(getSchemaViaDaemon).toHaveBeenCalledWith({ service: "open-brain", @@ -117,18 +129,25 @@ describe("daemon-routed schema discovery commands", () => { test("cache diff refreshes live schemas through daemon-routed discovery", async () => { const { writeCache } = await import("../../src/cache/index.ts"); - await writeCache("open-brain", [{ - name: "search_all", - description: "old", - inputSchema: { type: "object" }, - hash: "old-hash", - }]); + await writeCache("open-brain", [ + { + name: "search_all", + description: "old", + inputSchema: { type: "object" }, + hash: "old-hash", + }, + ]); const { handleCache } = await import("../../src/cli/commands/cache.ts"); - const output = await captureOutput(() => handleCache(["diff", "open-brain"])); + const output = await captureOutput(() => + handleCache(["diff", "open-brain"]), + ); expect(output.stdout).toContain('Schema drift detected for "open-brain"'); - expect(listToolsViaDaemon).toHaveBeenCalledWith({ service: "open-brain", fresh: true }); + expect(listToolsViaDaemon).toHaveBeenCalledWith({ + service: "open-brain", + fresh: true, + }); expect(getSchemaViaDaemon).toHaveBeenCalledTimes(2); expect(getSchemaViaDaemon).toHaveBeenCalledWith({ service: "open-brain", @@ -138,10 +157,15 @@ describe("daemon-routed schema discovery commands", () => { }); test("generate-skills dry run uses daemon-routed discovery", async () => { - const { handleGenerateSkills } = await import("../../src/cli/commands/generate-skills.ts"); - const output = await captureOutput(() => handleGenerateSkills(["open-brain", "--dry-run"])); - - expect(output.stderr).not.toContain("direct HTTP transport should not be used"); + const { handleGenerateSkills } = + await import("../../src/cli/commands/generate-skills.ts"); + const output = await captureOutput(() => + handleGenerateSkills(["open-brain", "--dry-run"]), + ); + + expect(output.stderr).not.toContain( + "direct HTTP transport should not be used", + ); expect(listToolsViaDaemon).toHaveBeenCalledWith({ service: "open-brain" }); expect(getSchemaViaDaemon).toHaveBeenCalledTimes(2); expect(getSchemaViaDaemon).toHaveBeenCalledWith({ @@ -163,13 +187,16 @@ function restoreEnv(name: string, value: string | undefined): void { } } -async function captureOutput(fn: () => Promise): Promise<{ stdout: string; stderr: string }> { +async function captureOutput( + fn: () => Promise, +): Promise<{ stdout: string; stderr: string }> { const stdout: string[] = []; const stderr: string[] = []; const originalLog = console.log; const originalError = console.error; console.log = (...args: unknown[]) => stdout.push(args.map(String).join(" ")); - console.error = (...args: unknown[]) => stderr.push(args.map(String).join(" ")); + console.error = (...args: unknown[]) => + stderr.push(args.map(String).join(" ")); try { await fn(); } finally { diff --git a/tests/cli/skills.test.ts b/tests/cli/skills.test.ts index f52c8b8..dc7b8b3 100644 --- a/tests/cli/skills.test.ts +++ b/tests/cli/skills.test.ts @@ -49,7 +49,9 @@ async function writeSkillFile(service: string, content: string): Promise { await Bun.write(join(dir, "SKILL.md"), content); } -async function captureSkills(args: string[]): Promise<{ stdout: string; exitCode: number }> { +async function captureSkills( + args: string[], +): Promise<{ stdout: string; exitCode: number }> { const { handleSkills } = await import("../../src/cli/commands/skills.ts"); const lines: string[] = []; const origLog = console.log; @@ -110,7 +112,10 @@ describe("skills get", () => { // H1: Path traversal validation test("rejects path traversal in service name", async () => { - const { stdout, exitCode } = await captureSkills(["get", "../../../etc/passwd"]); + const { stdout, exitCode } = await captureSkills([ + "get", + "../../../etc/passwd", + ]); expect(stdout).toContain("path traversal"); expect(exitCode).not.toBe(0); }); @@ -121,7 +126,12 @@ describe("skills install", () => { await writeSkillFile("test-svc", "# test-svc skill content\n"); const targetDir = join(testDir, "install-target"); - const { stdout } = await captureSkills(["install", "test-svc", "--target", targetDir]); + const { stdout } = await captureSkills([ + "install", + "test-svc", + "--target", + targetDir, + ]); expect(stdout).toContain("Installed test-svc"); const installed = Bun.file(join(targetDir, "SKILL.md")); @@ -135,7 +145,12 @@ describe("skills install", () => { }); test("errors when skill file missing", async () => { - const { stdout, exitCode } = await captureSkills(["install", "nonexistent", "--target", "/tmp/x"]); + const { stdout, exitCode } = await captureSkills([ + "install", + "nonexistent", + "--target", + "/tmp/x", + ]); expect(stdout).toContain("No skill file found"); expect(exitCode).not.toBe(0); }); @@ -144,7 +159,11 @@ describe("skills install", () => { await writeSkillFile("test-svc", "# content\n"); const targetDir = join(testDir, "install-eq"); - const { stdout } = await captureSkills(["install", "test-svc", `--target=${targetDir}`]); + const { stdout } = await captureSkills([ + "install", + "test-svc", + `--target=${targetDir}`, + ]); expect(stdout).toContain("Installed"); const installed = Bun.file(join(targetDir, "SKILL.md")); @@ -153,7 +172,12 @@ describe("skills install", () => { // H1: Path traversal validation test("rejects path traversal in service name", async () => { - const { stdout, exitCode } = await captureSkills(["install", "../etc/passwd", "--target", "/tmp/x"]); + const { stdout, exitCode } = await captureSkills([ + "install", + "../etc/passwd", + "--target", + "/tmp/x", + ]); expect(stdout).toContain("path traversal"); expect(exitCode).not.toBe(0); }); @@ -165,7 +189,12 @@ describe("skills install", () => { await mkdir(targetDir, { recursive: true }); await Bun.write(join(targetDir, "SKILL.md"), "# old content\n"); - const { stdout, exitCode } = await captureSkills(["install", "test-svc", "--target", targetDir]); + const { stdout, exitCode } = await captureSkills([ + "install", + "test-svc", + "--target", + targetDir, + ]); expect(stdout).toContain("Use --force to overwrite"); expect(exitCode).not.toBe(0); }); @@ -177,7 +206,13 @@ describe("skills install", () => { await mkdir(targetDir, { recursive: true }); await Bun.write(join(targetDir, "SKILL.md"), "# old content\n"); - const { stdout } = await captureSkills(["install", "test-svc", "--target", targetDir, "--force"]); + const { stdout } = await captureSkills([ + "install", + "test-svc", + "--target", + targetDir, + "--force", + ]); expect(stdout).toContain("Overwriting existing skill bundle"); expect(stdout).toContain("Installed test-svc"); }); @@ -188,9 +223,14 @@ describe("skills list", () => { await writeCache("test-svc", [makeTool("tool_a", "Does A")]); const origConfig = process.env.MCP2CLI_CONFIG; const configPath = join(testDir, "services.json"); - await Bun.write(configPath, JSON.stringify({ - services: { "test-svc": { backend: "stdio", command: "echo", args: [] } }, - })); + await Bun.write( + configPath, + JSON.stringify({ + services: { + "test-svc": { backend: "stdio", command: "echo", args: [] }, + }, + }), + ); process.env.MCP2CLI_CONFIG = configPath; try { @@ -228,9 +268,14 @@ describe("skills list", () => { const origConfig = process.env.MCP2CLI_CONFIG; const configPath = join(testDir, "services.json"); - await Bun.write(configPath, JSON.stringify({ - services: { "test-svc": { backend: "stdio", command: "echo", args: [] } }, - })); + await Bun.write( + configPath, + JSON.stringify({ + services: { + "test-svc": { backend: "stdio", command: "echo", args: [] }, + }, + }), + ); process.env.MCP2CLI_CONFIG = configPath; try { @@ -252,7 +297,8 @@ describe("skills list", () => { await writeCache("test-svc", tools); // Compute the expected hash - const { computeSchemaHash } = await import("../../src/generation/skill-hash.ts"); + const { computeSchemaHash } = + await import("../../src/generation/skill-hash.ts"); const expectedHash = await computeSchemaHash(tools); const skillContent = [ @@ -271,9 +317,14 @@ describe("skills list", () => { const origConfig = process.env.MCP2CLI_CONFIG; const configPath = join(testDir, "services.json"); - await Bun.write(configPath, JSON.stringify({ - services: { "test-svc": { backend: "stdio", command: "echo", args: [] } }, - })); + await Bun.write( + configPath, + JSON.stringify({ + services: { + "test-svc": { backend: "stdio", command: "echo", args: [] }, + }, + }), + ); process.env.MCP2CLI_CONFIG = configPath; try { @@ -291,10 +342,14 @@ describe("skills list", () => { }); test("uses access-filtered cache surface for stale detection", async () => { - const tools = [makeTool("allowed_tool", "Allowed"), makeTool("blocked_tool", "Blocked")]; + const tools = [ + makeTool("allowed_tool", "Allowed"), + makeTool("blocked_tool", "Blocked"), + ]; await writeCache("test-svc", tools); - const { computeSchemaHash } = await import("../../src/generation/skill-hash.ts"); + const { computeSchemaHash } = + await import("../../src/generation/skill-hash.ts"); const expectedHash = await computeSchemaHash([tools[0]!]); const skillContent = [ @@ -313,16 +368,19 @@ describe("skills list", () => { const origConfig = process.env.MCP2CLI_CONFIG; const configPath = join(testDir, "services.json"); - await Bun.write(configPath, JSON.stringify({ - services: { - "test-svc": { - backend: "stdio", - command: "echo", - args: [], - allowTools: ["allowed_*"], + await Bun.write( + configPath, + JSON.stringify({ + services: { + "test-svc": { + backend: "stdio", + command: "echo", + args: [], + allowTools: ["allowed_*"], + }, }, - }, - })); + }), + ); process.env.MCP2CLI_CONFIG = configPath; try { @@ -345,9 +403,14 @@ describe("skills list", () => { test("outputs JSON when --json flag is passed via args", async () => { const origConfig = process.env.MCP2CLI_CONFIG; const configPath = join(testDir, "services.json"); - await Bun.write(configPath, JSON.stringify({ - services: { "test-svc": { backend: "stdio", command: "echo", args: [] } }, - })); + await Bun.write( + configPath, + JSON.stringify({ + services: { + "test-svc": { backend: "stdio", command: "echo", args: [] }, + }, + }), + ); process.env.MCP2CLI_CONFIG = configPath; try { diff --git a/tests/config/loader.test.ts b/tests/config/loader.test.ts index 7ea80d0..ecff1ff 100644 --- a/tests/config/loader.test.ts +++ b/tests/config/loader.test.ts @@ -258,7 +258,10 @@ describe("loadConfig", () => { const config = await loadConfig(configPath); expect(Object.keys(config.services).sort()).toEqual(["local", "remote"]); const secondLoad = await loadConfig(configPath); - expect(Object.keys(secondLoad.services).sort()).toEqual(["local", "remote"]); + expect(Object.keys(secondLoad.services).sort()).toEqual([ + "local", + "remote", + ]); expect(requests).toBe(1); } finally { server.stop(true); @@ -267,7 +270,9 @@ describe("loadConfig", () => { }); test("cached import state still requires current importUrl policy", async () => { - const dir = await mkdtemp(join(tmpdir(), "mcp2cli-import-cache-policy-test-")); + const dir = await mkdtemp( + join(tmpdir(), "mcp2cli-import-cache-policy-test-"), + ); const configPath = join(dir, "services.json"); const server = Bun.serve({ port: 0, @@ -353,14 +358,20 @@ describe("loadConfig", () => { }); test("importUrl token requires an explicit host allowlist", async () => { - const dir = await mkdtemp(join(tmpdir(), "mcp2cli-import-auth-allowlist-test-")); + const dir = await mkdtemp( + join(tmpdir(), "mcp2cli-import-auth-allowlist-test-"), + ); const configPath = join(dir, "services.json"); let requests = 0; const server = Bun.serve({ port: 0, fetch() { requests++; - return Response.json({ services: { remote: { backend: "http", url: "http://ct216.example/mcp" } } }); + return Response.json({ + services: { + remote: { backend: "http", url: "http://ct216.example/mcp" }, + }, + }); }, }); try { @@ -393,7 +404,11 @@ describe("loadConfig", () => { port: 0, fetch(req) { redirectedAuthHeader = req.headers.get("authorization"); - return Response.json({ services: { remote: { backend: "http", url: "http://ct216.example/mcp" } } }); + return Response.json({ + services: { + remote: { backend: "http", url: "http://ct216.example/mcp" }, + }, + }); }, }); const origin = Bun.serve({ @@ -401,7 +416,9 @@ describe("loadConfig", () => { fetch() { return new Response(null, { status: 302, - headers: { Location: `http://127.0.0.1:${target.port}/api/services/export` }, + headers: { + Location: `http://127.0.0.1:${target.port}/api/services/export`, + }, }); }, }); @@ -438,7 +455,11 @@ describe("loadConfig", () => { port: 0, fetch(req) { authHeader = req.headers.get("authorization"); - return Response.json({ services: { remote: { backend: "http", url: "http://ct216.example/mcp" } } }); + return Response.json({ + services: { + remote: { backend: "http", url: "http://ct216.example/mcp" }, + }, + }); }, }); try { @@ -490,7 +511,9 @@ describe("loadConfig", () => { }); test("importUrl blocks IPv4-mapped IPv6 loopback hosts", async () => { - const dir = await mkdtemp(join(tmpdir(), "mcp2cli-import-mapped-ipv6-test-")); + const dir = await mkdtemp( + join(tmpdir(), "mcp2cli-import-mapped-ipv6-test-"), + ); const configPath = join(dir, "services.json"); try { process.env.MCP2CLI_IMPORT_ALLOW_HTTP = "1"; diff --git a/tests/config/schema.test.ts b/tests/config/schema.test.ts index 532e2c0..e9ae0de 100644 --- a/tests/config/schema.test.ts +++ b/tests/config/schema.test.ts @@ -221,7 +221,9 @@ describe("ServicesConfigSchema", () => { }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.importUrl).toBe("http://localhost:9500/api/services/export"); + expect(result.data.importUrl).toBe( + "http://localhost:9500/api/services/export", + ); expect(result.data.importTtlSeconds).toBe(0); } }); diff --git a/tests/daemon/pool.test.ts b/tests/daemon/pool.test.ts index f188761..0486d3b 100644 --- a/tests/daemon/pool.test.ts +++ b/tests/daemon/pool.test.ts @@ -8,7 +8,10 @@ import type { ServicesConfig } from "../../src/config/index.ts"; // Mock connectToService before importing the pool const mockConnectToService = mock(async () => { const conn: McpConnection = { - client: { callTool: mock(() => Promise.resolve({})), listTools: mock(() => Promise.resolve({ tools: [] })) } as never, + client: { + callTool: mock(() => Promise.resolve({})), + listTools: mock(() => Promise.resolve({ tools: [] })), + } as never, close: mock(() => Promise.resolve()), }; return conn; @@ -153,7 +156,9 @@ describe("ConnectionPool", () => { await pool.getConnection("secreted", config); expect(mockConnectToService).toHaveBeenCalledTimes(1); - const calls = mockConnectToService.mock.calls as unknown as Array<[unknown]>; + const calls = mockConnectToService.mock.calls as unknown as Array< + [unknown] + >; expect(calls[0]![0]).toEqual({ backend: "http", url: "http://resolved.example/mcp", @@ -198,7 +203,10 @@ describe("ConnectionPool", () => { }); test("closeServicePattern only closes entries for the matching base service", async () => { - const unrelated = await pool.getConnection("svc::with-delimiter", testConfig); + const unrelated = await pool.getConnection( + "svc::with-delimiter", + testConfig, + ); const credentialed = await pool.getConnection( "credential:test", testConfig, @@ -233,7 +241,10 @@ describe("ConnectionPool", () => { new Promise((resolve) => { setTimeout(() => { resolve({ - client: { callTool: mock(() => Promise.resolve({})), listTools: mock(() => Promise.resolve({ tools: [] })) } as never, + client: { + callTool: mock(() => Promise.resolve({})), + listTools: mock(() => Promise.resolve({ tools: [] })), + } as never, close: mock(() => Promise.resolve()), }); }, 50); diff --git a/tests/generation/diff.test.ts b/tests/generation/diff.test.ts index 38764c5..57b4e1e 100644 --- a/tests/generation/diff.test.ts +++ b/tests/generation/diff.test.ts @@ -4,6 +4,7 @@ import { computeSkillDiff, formatDiffPreview, } from "../../src/generation/diff.ts"; +import { generateSkillMd } from "../../src/generation/templates.ts"; // -- parseExistingTools -- @@ -67,15 +68,53 @@ describe("parseExistingTools", () => { expect(tools[0]!.name).toBe("tool_a"); expect(tools[1]!.name).toBe("tool_b"); }); + + test("extracts tool names from the slim group-index table", () => { + const content = [ + "## Tool Groups", + "", + "| Group | Tools | Reference |", + "|-------|-------|-----------|", + "| Item Operations | list_items, create_item | [item-ops.md](references/item-ops.md) |", + "| Tier Operations | set_tier, bulk_set_tier | [tier-ops.md](references/tier-ops.md) |", + "", + "## Usage", + ].join("\n"); + + const tools = parseExistingTools(content); + expect(tools.map((t) => t.name)).toEqual([ + "list_items", + "create_item", + "set_tier", + "bulk_set_tier", + ]); + }); + + test("extracts tool names from the flat fallback list", () => { + const content = [ + "## Tools", + "", + "- json_tool", + "- error_tool", + "- create_item", + "", + "## Usage", + ].join("\n"); + + const tools = parseExistingTools(content); + expect(tools.map((t) => t.name)).toEqual([ + "json_tool", + "error_tool", + "create_item", + ]); + }); }); // -- computeSkillDiff -- describe("computeSkillDiff", () => { test("detects added tools", () => { - const existing = [ - { name: "tool_a", description: "Tool A" }, - ]; + const existing = [{ name: "tool_a", description: "Tool A" }]; const newTools = [ { name: "tool_a", description: "Tool A" }, { name: "tool_b", description: "Tool B" }, @@ -94,9 +133,7 @@ describe("computeSkillDiff", () => { { name: "tool_a", description: "Tool A" }, { name: "tool_b", description: "Tool B" }, ]; - const newTools = [ - { name: "tool_a", description: "Tool A" }, - ]; + const newTools = [{ name: "tool_a", description: "Tool A" }]; const diff = computeSkillDiff("test", existing, newTools); expect(diff.hasChanges).toBe(true); @@ -106,12 +143,8 @@ describe("computeSkillDiff", () => { }); test("detects modified tools (description changed)", () => { - const existing = [ - { name: "tool_a", description: "Old description" }, - ]; - const newTools = [ - { name: "tool_a", description: "New description" }, - ]; + const existing = [{ name: "tool_a", description: "Old description" }]; + const newTools = [{ name: "tool_a", description: "New description" }]; const diff = computeSkillDiff("test", existing, newTools); expect(diff.hasChanges).toBe(true); @@ -221,7 +254,10 @@ describe("formatDiffPreview", () => { const diff = computeSkillDiff( "my-service", [{ name: "a", description: "A" }], - [{ name: "a", description: "A" }, { name: "b", description: "B" }], + [ + { name: "a", description: "A" }, + { name: "b", description: "B" }, + ], ); const output = formatDiffPreview(diff); expect(output).toContain("Existing: 1 tools"); @@ -229,3 +265,63 @@ describe("formatDiffPreview", () => { expect(output).toContain("my-service"); }); }); + +// -- round-trip: generated SKILL.md must diff clean against its own tools -- +// Regression guard: parseExistingTools previously scanned the whole file, +// picking up YAML `triggers:` bullets as phantom tools (spurious "removed"), +// and the slim group index has no descriptions, which flagged every tool as +// "modified". Both made --diff useless for the format this generator emits. + +describe("generate -> parse -> diff round-trip", () => { + const tools = [ + { name: "ob_search", description: "Search the brain" }, + { name: "ob_write", description: "Write to the brain" }, + ]; + + test("grouped front skill diffs clean against its own tool list", () => { + const md = generateSkillMd({ + serviceName: "open-brain", + description: "Open Brain KB", + tools, + triggerKeywords: ["open-brain", "search", "knowledge"], + groups: [ + { + prefix: "ob", + label: "Ob Operations", + tools: tools.map((t) => ({ + tool: t.name, + description: t.description, + inputSchema: {}, + usage: "", + })), + filename: "ob.md", + }, + ], + }); + + const parsed = parseExistingTools(md); + expect(parsed.map((t) => t.name).sort()).toEqual(["ob_search", "ob_write"]); + // trigger keywords must NOT leak in as tools + expect(parsed.some((t) => t.name === "search")).toBe(false); + + const diff = computeSkillDiff("open-brain", parsed, tools); + expect(diff.hasChanges).toBe(false); + expect(diff.removed).toHaveLength(0); + expect(diff.added).toHaveLength(0); + expect(diff.modified).toHaveLength(0); + }); + + test("flat fallback front skill diffs clean against its own tool list", () => { + const md = generateSkillMd({ + serviceName: "svc", + description: "Flat service", + tools, + triggerKeywords: ["svc", "flat"], + }); + + const parsed = parseExistingTools(md); + expect(parsed.map((t) => t.name).sort()).toEqual(["ob_search", "ob_write"]); + const diff = computeSkillDiff("svc", parsed, tools); + expect(diff.hasChanges).toBe(false); + }); +}); diff --git a/tests/generation/file-manager.test.ts b/tests/generation/file-manager.test.ts index 256af5f..0a9217d 100644 --- a/tests/generation/file-manager.test.ts +++ b/tests/generation/file-manager.test.ts @@ -138,7 +138,7 @@ describe("mergeContent", () => { const existing = [ "---", "name: old-skill", - "triggers: [custom-trigger, \"quoted trigger\"]", + 'triggers: [custom-trigger, "quoted trigger"]', "x-owner: human", "---", "# Custom header", diff --git a/tests/generation/templates.test.ts b/tests/generation/templates.test.ts index 9760f46..7504e88 100644 --- a/tests/generation/templates.test.ts +++ b/tests/generation/templates.test.ts @@ -100,14 +100,26 @@ describe("generateSkillMd", () => { expect(md).toContain("test"); }); - test("includes quick reference tool table", () => { + test("falls back to a flat tool name list when no groups supplied", () => { const md = generateSkillMd(mockInput); - expect(md).toContain("json_tool"); - expect(md).toContain("error_tool"); - expect(md).toContain("create_item"); - // Table headers - expect(md).toContain("Tool"); - expect(md).toContain("Description"); + expect(md).toContain("## Tools"); + expect(md).toContain("- json_tool"); + expect(md).toContain("- error_tool"); + expect(md).toContain("- create_item"); + // No description column in the slim front skill. + expect(md).not.toContain("Description"); + }); + + test("emits a group index linking to reference files when groups supplied", () => { + const md = generateSkillMd({ ...mockInput, groups: [mockGroup] }); + expect(md).toContain("## Tool Groups"); + expect(md).toContain("| Group | Tools | Reference |"); + expect(md).toContain("Data Operations"); + // Group row carries tool names and a link to its reference file. + expect(md).toContain("json_tool, create_item"); + expect(md).toContain("[data-ops.md](references/data-ops.md)"); + // Detail (descriptions) stays out of the front skill. + expect(md).not.toContain("Returns a JSON object"); }); test("includes generic invoke pattern", () => { diff --git a/tests/integration/generate-skills.test.ts b/tests/integration/generate-skills.test.ts index a49e984..aff189a 100644 --- a/tests/integration/generate-skills.test.ts +++ b/tests/integration/generate-skills.test.ts @@ -83,10 +83,7 @@ describe("generate-skills integration", () => { test("SKILL.md has valid frontmatter, tool table, invoke pattern, markers", async () => { const { env, outputDir } = await setupTestEnv(); - runCli( - ["generate-skills", "mock-server", `--output=${outputDir}`], - env, - ); + runCli(["generate-skills", "mock-server", `--output=${outputDir}`], env); const skillContent = await Bun.file(join(outputDir, "SKILL.md")).text(); @@ -96,11 +93,12 @@ describe("generate-skills integration", () => { expect(skillContent).toContain("description:"); expect(skillContent).toContain("triggers:"); - // Tool table with all 3 mock tools + // Slim group index lists all 3 mock tools and links to reference files expect(skillContent).toContain("json_tool"); expect(skillContent).toContain("error_tool"); expect(skillContent).toContain("create_item"); - expect(skillContent).toContain("| Tool | Description |"); + expect(skillContent).toContain("| Group | Tools | Reference |"); + expect(skillContent).toContain("(references/"); // Invoke pattern expect(skillContent).toContain("mcp2cli mock-server"); @@ -163,10 +161,7 @@ describe("generate-skills integration", () => { const { env, outputDir } = await setupTestEnv(); // First run: generate files - runCli( - ["generate-skills", "mock-server", `--output=${outputDir}`], - env, - ); + runCli(["generate-skills", "mock-server", `--output=${outputDir}`], env); // Read original content const originalContent = await Bun.file(join(outputDir, "SKILL.md")).text(); @@ -177,7 +172,12 @@ describe("generate-skills integration", () => { // Second run with --conflict=skip const result = runCli( - ["generate-skills", "mock-server", `--output=${outputDir}`, "--conflict=skip"], + [ + "generate-skills", + "mock-server", + `--output=${outputDir}`, + "--conflict=skip", + ], env, ); @@ -192,10 +192,7 @@ describe("generate-skills integration", () => { const { env, outputDir } = await setupTestEnv(); // First run: generate files - runCli( - ["generate-skills", "mock-server", `--output=${outputDir}`], - env, - ); + runCli(["generate-skills", "mock-server", `--output=${outputDir}`], env); // Modify the file const skillPath = join(outputDir, "SKILL.md"); @@ -204,7 +201,12 @@ describe("generate-skills integration", () => { // Second run with --conflict=force const result = runCli( - ["generate-skills", "mock-server", `--output=${outputDir}`, "--conflict=force"], + [ + "generate-skills", + "mock-server", + `--output=${outputDir}`, + "--conflict=force", + ], env, ); @@ -219,20 +221,23 @@ describe("generate-skills integration", () => { const { env, outputDir } = await setupTestEnv(); // First run: generate files - runCli( - ["generate-skills", "mock-server", `--output=${outputDir}`], - env, - ); + runCli(["generate-skills", "mock-server", `--output=${outputDir}`], env); // Add user content OUTSIDE auto-generated markers const skillPath = join(outputDir, "SKILL.md"); const original = await Bun.file(skillPath).text(); - const withUserContent = original + "\n## My Custom Notes\n\nThis should be preserved.\n"; + const withUserContent = + original + "\n## My Custom Notes\n\nThis should be preserved.\n"; await Bun.write(skillPath, withUserContent); // Second run with --conflict=merge const result = runCli( - ["generate-skills", "mock-server", `--output=${outputDir}`, "--conflict=merge"], + [ + "generate-skills", + "mock-server", + `--output=${outputDir}`, + "--conflict=merge", + ], env, ); @@ -251,10 +256,7 @@ describe("generate-skills integration", () => { test("conflict merge refreshes generated frontmatter metadata", async () => { const { env, outputDir } = await setupTestEnv(); - runCli( - ["generate-skills", "mock-server", `--output=${outputDir}`], - env, - ); + runCli(["generate-skills", "mock-server", `--output=${outputDir}`], env); const skillPath = join(outputDir, "SKILL.md"); const original = await Bun.file(skillPath).text(); @@ -269,14 +271,22 @@ describe("generate-skills integration", () => { "---", "", ].join("\n"); - const withoutMetadata = original.replace(/^---\n[\s\S]*?\n---\n\n/, oldFrontmatter); + const withoutMetadata = original.replace( + /^---\n[\s\S]*?\n---\n\n/, + oldFrontmatter, + ); await Bun.write( skillPath, withoutMetadata + "\n## My Custom Notes\n\nThis should be preserved.\n", ); const result = runCli( - ["generate-skills", "mock-server", `--output=${outputDir}`, "--conflict=merge"], + [ + "generate-skills", + "mock-server", + `--output=${outputDir}`, + "--conflict=merge", + ], env, ); diff --git a/tests/process/client-remote.test.ts b/tests/process/client-remote.test.ts index 1a04d67..9570d86 100644 --- a/tests/process/client-remote.test.ts +++ b/tests/process/client-remote.test.ts @@ -6,7 +6,11 @@ import { getRemoteServiceAvailability, getRemoteServiceNames, } from "../../src/process/remote-discovery.ts"; -import { callViaDaemon, clearClientConfigCache, resolveSource } from "../../src/process/client.ts"; +import { + callViaDaemon, + clearClientConfigCache, + resolveSource, +} from "../../src/process/client.ts"; import { join } from "node:path"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -170,18 +174,27 @@ describe("remote service discovery", () => { }); test("lists remote configured services from authenticated daemon discovery", async () => { - await expect(getRemoteServiceNames()).resolves.toEqual(["yt-dlp", "stealth-browser"]); + await expect(getRemoteServiceNames()).resolves.toEqual([ + "yt-dlp", + "stealth-browser", + ]); }); test("distinguishes hosted and non-hosted services", async () => { - await expect(getRemoteServiceAvailability("yt-dlp")).resolves.toBe("hosted"); - await expect(getRemoteServiceAvailability("king-secrets")).resolves.toBe("not-hosted"); + await expect(getRemoteServiceAvailability("yt-dlp")).resolves.toBe( + "hosted", + ); + await expect(getRemoteServiceAvailability("king-secrets")).resolves.toBe( + "not-hosted", + ); }); test("returns no-remote without MCP2CLI_REMOTE_URL", async () => { delete process.env.MCP2CLI_REMOTE_URL; clearRemoteServiceCache(); - await expect(getRemoteServiceAvailability("yt-dlp")).resolves.toBe("no-remote"); + await expect(getRemoteServiceAvailability("yt-dlp")).resolves.toBe( + "no-remote", + ); }); test("does not cache failed discovery snapshots", async () => { @@ -208,8 +221,12 @@ describe("remote service discovery", () => { process.env.MCP2CLI_REMOTE_URL = `http://localhost:${server.port}`; process.env.MCP2CLI_REMOTE_SERVICE_CACHE_TTL_MS = "60000"; - await expect(getRemoteServiceAvailability("recovered")).resolves.toBe("unknown"); - await expect(getRemoteServiceAvailability("recovered")).resolves.toBe("hosted"); + await expect(getRemoteServiceAvailability("recovered")).resolves.toBe( + "unknown", + ); + await expect(getRemoteServiceAvailability("recovered")).resolves.toBe( + "hosted", + ); }); }); @@ -266,7 +283,9 @@ describe("remote-aware source resolution", () => { } }); - async function writeServices(services: Record): Promise { + async function writeServices( + services: Record, + ): Promise { await Bun.write(configPath, JSON.stringify({ services }, null, 2)); clearClientConfigCache(); } @@ -317,7 +336,8 @@ describe("remote-aware source resolution", () => { success: false, error: { code: "CONNECTION_ERROR", - message: "Failed to connect to HTTP MCP server: SSE error: Non-200 status code (401)", + message: + "Failed to connect to HTTP MCP server: SSE error: Non-200 status code (401)", }, }); } diff --git a/tests/resilience/pool-fallback.test.ts b/tests/resilience/pool-fallback.test.ts index f9065e9..c376f53 100644 --- a/tests/resilience/pool-fallback.test.ts +++ b/tests/resilience/pool-fallback.test.ts @@ -94,7 +94,10 @@ afterEach(async () => { describe("pool HTTP with fallback", () => { test("connects via HTTP when gateway is reachable", async () => { const pool = new ConnectionPool(); - const conn = await pool.getConnection("gateway-svc", httpWithFallbackConfig); + const conn = await pool.getConnection( + "gateway-svc", + httpWithFallbackConfig, + ); expect(conn).toBeDefined(); expect(mockConnectToHttpService).toHaveBeenCalledTimes(1); @@ -109,7 +112,10 @@ describe("pool HTTP with fallback", () => { }); const pool = new ConnectionPool(); - const conn = await pool.getConnection("gateway-svc", httpWithFallbackConfig); + const conn = await pool.getConnection( + "gateway-svc", + httpWithFallbackConfig, + ); expect(conn).toBeDefined(); expect(mockConnectToHttpService).toHaveBeenCalledTimes(1); @@ -130,7 +136,10 @@ describe("pool HTTP with fallback", () => { await saveState("gateway-svc", openState); const pool = new ConnectionPool(); - const conn = await pool.getConnection("gateway-svc", httpWithFallbackConfig); + const conn = await pool.getConnection( + "gateway-svc", + httpWithFallbackConfig, + ); expect(conn).toBeDefined(); // HTTP should NOT have been attempted @@ -257,7 +266,16 @@ describe("pool HTTP with fallback", () => { await pool.getConnection("gateway-svc", httpWithFallbackConfig); expect(mockConnectToService).toHaveBeenCalledTimes(1); - const calls = mockConnectToService.mock.calls as unknown as Array<[{ backend: string; command: string; args: string[]; env: Record }]>; + const calls = mockConnectToService.mock.calls as unknown as Array< + [ + { + backend: string; + command: string; + args: string[]; + env: Record; + }, + ] + >; const callArg = calls[0]![0]; expect(callArg.backend).toBe("stdio"); expect(callArg.command).toBe("npx");