From ef332ba4a4ec591f59ae31eec07e53b456476e04 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Thu, 25 Jun 2026 18:23:29 +0300 Subject: [PATCH] chore: preserve spark02 repo state Preserve reviewed non-risky working tree changes before fleet repo redistribution. Risky local-only paths, if any, were left uncommitted. --- README.md | 39 +++- bun.lock | 10 +- package.json | 15 +- src/cli/index.tsx | 390 ++++++++++++++++++++++++------- src/index.ts | 13 ++ src/lib/compact-output.ts | 212 +++++++++++++++++ src/lib/db.ts | 20 +- src/lib/pg-migrations.ts | 34 ++- src/lib/remote-storage.ts | 45 ++++ src/lib/storage-sync.ts | 320 +++++++++++++++++++++++++ src/mcp/server.ts | 101 +++++++- src/mcp/tools.ts | 226 ++++++++++++++---- src/storage.ts | 21 ++ tests/cli-compact-output.test.ts | 59 +++++ tests/cli-storage.test.ts | 60 +++++ tests/index.test.ts | 10 + tests/mcp.test.ts | 106 ++++++++- tests/storage-sync.test.ts | 69 ++++++ 18 files changed, 1583 insertions(+), 167 deletions(-) create mode 100644 src/lib/compact-output.ts create mode 100644 src/lib/remote-storage.ts create mode 100644 src/lib/storage-sync.ts create mode 100644 src/storage.ts create mode 100644 tests/cli-compact-output.test.ts create mode 100644 tests/cli-storage.test.ts create mode 100644 tests/storage-sync.test.ts diff --git a/README.md b/README.md index 56958de..5d64031 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,28 @@ mcps --help - `mcps fleet health --refresh` - `mcps fleet install --yes` +## Output Defaults + +CLI list/search/status-style commands are compact by default for human and +agent terminals. They show essential fields, truncate long text, and cap rows. +Use `--limit` and `--cursor` to page through broad results. + +Use detail flags and commands when you need full records: + +```bash +mcps list --verbose +mcps info --json +mcps tools --verbose --limit 50 +mcps find postgres --cursor 20 +mcps providers info github --json +mcps storage status --json +``` + +MCP list/search/catalog tools follow the same gradual-disclosure pattern: +default responses are compact envelopes with `items`, `total`, `nextCursor`, +and a `hint`; pass `verbose: true` or call a detail tool such as +`get_server_info` or `get_provider_profile` for full JSON records. + ## Fleet Operations Use machine registration plus fleet health/install commands to manage `@hasna/*` @@ -102,16 +124,23 @@ mcps-mcp The MCP server exposes registry, finder, machine registry, and fleet orchestration tools. -## Cloud Sync +## Storage Sync -This package supports cloud sync via `@hasna/cloud`: +This package supports optional remote storage sync directly against a Postgres/RDS +database. Local SQLite remains the default. ```bash -cloud setup -cloud sync push --service mcps -cloud sync pull --service mcps +export HASNA_MCPS_DATABASE_URL=postgres://... + +mcps storage status +mcps storage push +mcps storage pull +mcps storage sync ``` +MCP exposes the same flow through `storage_status`, `storage_push`, +`storage_pull`, and `storage_sync`. + ## Data Directory Data is stored in `~/.hasna/mcps/`. diff --git a/bun.lock b/bun.lock index 43dae2e..e24df23 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@hasna/mcps", "dependencies": { - "@hasna/cloud": "^0.1.24", + "@hasna/events": "^0.1.6", "@modelcontextprotocol/sdk": "^1.26.0", "chalk": "^5.3.0", "commander": "^12.1.0", @@ -13,11 +13,13 @@ "ink-select-input": "^6.0.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", + "pg": "^8.20.0", "react": "^18.2.0", "zod": "^3.23.0", }, "devDependencies": { "@types/bun": "latest", + "@types/pg": "^8.15.6", "@types/react": "^18.2.0", "typescript": "^5", }, @@ -26,7 +28,7 @@ "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], - "@hasna/cloud": ["@hasna/cloud@0.1.30", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", "commander": "^13.1.0", "pg": "^8.13.3", "zod": "^3.24.2" }, "bin": { "cloud": "dist/cli/index.js", "cloud-mcp": "dist/mcp/index.js" } }, "sha512-qpDiJNsU/K1cFDk1l+xUA1xEEq8+Q37OdAz/ec0LwrQiA1HyWukzR8Lh7igkOqBT2foa46cxhB7yGnfiT2cWrA=="], + "@hasna/events": ["@hasna/events@0.1.6", "", { "dependencies": { "commander": "^13.1.0" }, "bin": { "events": "dist/cli/index.js", "hasna-events": "dist/cli/index.js" } }, "sha512-rKLxjBIat45UxciUwh9hKM63l/mcurJXAhgVkYHvrsGnOK9HwExrwgOlylNzDBDb+huiQw9DWdLb31q5oWDfIw=="], "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], @@ -36,6 +38,8 @@ "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], @@ -346,7 +350,7 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - "@hasna/cloud/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], + "@hasna/events/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], diff --git a/package.json b/package.json index 3a4d932..0a1f6cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hasna/mcps", - "version": "0.0.18", + "version": "0.0.21", "description": "Meta-MCP registry & CLI — discover, manage, and proxy MCP servers", "type": "module", "repository": { @@ -17,6 +17,10 @@ "./mcp": { "import": "./dist/mcp/index.js", "types": "./dist/mcp/index.d.ts" + }, + "./storage": { + "import": "./dist/storage.js", + "types": "./dist/storage.d.ts" } }, "bin": { @@ -31,7 +35,8 @@ "README.md" ], "scripts": { - "build": "bun run build:dashboard && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external ink --external react --external chalk && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun run scripts/fix-shebangs.ts && bun build ./src/index.ts --outdir ./dist --target bun && bun build ./src/mcp/index.ts --outdir ./dist/mcp --target bun && tsc --emitDeclarationOnly --declaration --outDir dist", + "clean": "rm -rf bin dist dashboard/dist", + "build": "bun run clean && bun run build:dashboard && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external ink --external react --external chalk && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun run scripts/fix-shebangs.ts && bun build ./src/index.ts ./src/storage.ts --outdir ./dist --target bun && bun build ./src/mcp/index.ts --outdir ./dist/mcp --target bun && tsc --emitDeclarationOnly --declaration --outDir dist", "build:dashboard": "cd dashboard && bun install && bun run build", "dev": "bun run src/cli/index.tsx", "dev:mcp": "bun run src/mcp/index.ts", @@ -43,7 +48,6 @@ "postinstall": "mkdir -p $HOME/.hasna/mcps/cache 2>/dev/null || true" }, "dependencies": { - "@hasna/cloud": "^0.1.24", "@modelcontextprotocol/sdk": "^1.26.0", "chalk": "^5.3.0", "commander": "^12.1.0", @@ -51,11 +55,14 @@ "ink-select-input": "^6.0.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", + "pg": "^8.20.0", "react": "^18.2.0", - "zod": "^3.23.0" + "zod": "^3.23.0", + "@hasna/events": "^0.1.6" }, "devDependencies": { "@types/bun": "latest", + "@types/pg": "^8.15.6", "@types/react": "^18.2.0", "typescript": "^5" }, diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 0a7e53b..8640444 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { Command } from "commander"; +import { registerEventsCommands } from "@hasna/events/commander"; import React from "react"; import { render } from "ink"; import chalk from "chalk"; @@ -74,6 +75,18 @@ import { redactServerCredentials, redactEnv, } from "../lib/credentials.js"; +import { + DEFAULT_LIST_LIMIT, + compactProviderProfile, + compactServer, + compactSource, + compactSyncResult, + compactTool, + pageSummary, + paginate, + truncateText, + type Page, +} from "../lib/compact-output.js"; import * as readline from "readline"; import { startMcpServer } from "../mcp/index.js"; import { startServer } from "../server/serve.js"; @@ -104,6 +117,12 @@ type LocalConsentOptions = { allowRiskyCommand?: boolean; }; +type CompactListOptions = { + limit?: string; + cursor?: string; + verbose?: boolean; +}; + function localConsentFromOptions(opts: LocalConsentOptions, approved = false): LocalCommandConsent { return { approved: approved || opts.yes === true || opts.allowLocalStdio === true, @@ -182,6 +201,17 @@ function printJson(value: unknown): void { console.log(JSON.stringify(value, null, 2)); } +function printPageFooter(page: Page, noun: string, hint?: string): void { + console.log(chalk.dim(`\n${pageSummary(page, noun)}`)); + if (page.nextCursor) console.log(chalk.dim(`Use --cursor ${page.nextCursor} for the next page.`)); + if (hint) console.log(chalk.dim(hint)); +} + +function parseTables(value?: string): string[] | undefined { + if (!value) return undefined; + return value.split(",").map((table) => table.trim()).filter(Boolean); +} + function parseIntegerOption(value: string, label: string, { min = 0, max }: { min?: number; max?: number } = {}): number { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || Number.isNaN(parsed) || parsed < min || (max !== undefined && parsed > max)) { @@ -203,36 +233,45 @@ function formatMachineTarget(machine: MachineEntry): string { return `${userPrefix}${machine.host}:${machine.port}`; } -function renderMachines(machines: MachineEntry[]): void { +function renderMachines(machines: MachineEntry[], opts: CompactListOptions = {}): void { if (machines.length === 0) { console.log(chalk.dim("No machines registered. Use `mcps machines add` or `mcps machines seed-defaults`.")); return; } - for (const machine of machines) { + const page = paginate(machines, { limit: opts.limit, cursor: opts.cursor }); + for (const machine of page.items) { const status = machine.enabled ? chalk.green("enabled") : chalk.red("disabled"); const runtime = `${machine.platform}/${machine.arch}`; console.log(` ${chalk.bold(machine.name)} ${chalk.dim(`[${machine.id}]`)} — ${status}`); - console.log(` ${chalk.dim(`${formatMachineTarget(machine)} · installer=${machine.installer} · ${runtime}`)}`); - if (machine.last_seen_at) console.log(` ${chalk.dim(`last seen: ${machine.last_seen_at}`)}`); - if (machine.last_error) console.log(` ${chalk.red(machine.last_error)}`); + if (opts.verbose) { + console.log(` ${chalk.dim(`${formatMachineTarget(machine)} · installer=${machine.installer} · ${runtime}`)}`); + if (machine.last_seen_at) console.log(` ${chalk.dim(`last seen: ${machine.last_seen_at}`)}`); + if (machine.last_error) console.log(` ${chalk.red(truncateText(machine.last_error, 180))}`); + } else { + console.log(` ${chalk.dim(`${runtime} · installer=${machine.installer}${machine.last_error ? " · has last error" : ""}`)}`); + } } + printPageFooter(page, "machine(s)", "Use --verbose for SSH targets and last errors."); } -function renderCatalog(entries: HasnaMcpCatalogEntry[]): void { +function renderCatalog(entries: HasnaMcpCatalogEntry[], opts: CompactListOptions = {}): void { if (entries.length === 0) { console.log(chalk.dim("No @hasna MCP packages found.")); return; } - for (const entry of entries) { + const page = paginate(entries, { limit: opts.limit, cursor: opts.cursor }); + for (const entry of page.items) { const binLabel = entry.mcpBin ? chalk.dim(`bin=${entry.mcpBin}`) : chalk.yellow("no MCP bin"); console.log(` ${chalk.bold(entry.name)} ${chalk.dim(`@${entry.version}`)} ${binLabel}`); - if (entry.description) console.log(` ${chalk.dim(entry.description)}`); + if (entry.description) console.log(` ${chalk.dim(truncateText(entry.description))}`); + if (opts.verbose && entry.repository) console.log(` ${chalk.cyan(entry.repository)}`); } + printPageFooter(page, "package(s)", "Use --verbose for repository URLs or -j for full catalog JSON."); } -function renderFleetHealth(reports: FleetHealthReport[]): void { +function renderFleetHealth(reports: FleetHealthReport[], opts: { verbose?: boolean } = {}): void { if (reports.length === 0) { console.log(chalk.dim("No machines selected.")); return; @@ -241,7 +280,7 @@ function renderFleetHealth(reports: FleetHealthReport[]): void { for (const report of reports) { console.log(` ${chalk.bold(report.machine.name)} ${chalk.dim(`[${report.machine.id}]`)} — ${chalk.dim(formatMachineTarget(report.machine))}`); if (report.error) { - console.log(` ${chalk.red(report.error)}`); + console.log(` ${chalk.red(truncateText(report.error, 180))}`); continue; } @@ -251,6 +290,8 @@ function renderFleetHealth(reports: FleetHealthReport[]): void { )}`, ); + if (!opts.verbose) continue; + for (const pkg of report.packages) { const driftColor = pkg.drift === "current" ? chalk.green : pkg.drift === "missing" ? chalk.red : chalk.yellow; @@ -260,12 +301,14 @@ function renderFleetHealth(reports: FleetHealthReport[]): void { console.log( ` ${driftColor(pkg.drift.padEnd(8))} ${pkg.packageName} ${chalk.dim(`${installed} -> ${pkg.latestVersion} · handshake=${handshakeLabel}`)}`, ); - if (pkg.handshakeError) console.log(` ${chalk.red(pkg.handshakeError)}`); + if (pkg.handshakeError) console.log(` ${chalk.red(truncateText(pkg.handshakeError, 180))}`); } } + + if (!opts.verbose) console.log(chalk.dim("\nUse --verbose for per-package health details or -j for full JSON.")); } -function renderFleetInstall(reports: FleetInstallReport[]): void { +function renderFleetInstall(reports: FleetInstallReport[], opts: { verbose?: boolean } = {}): void { if (reports.length === 0) { console.log(chalk.dim("No machines selected.")); return; @@ -275,7 +318,7 @@ function renderFleetInstall(reports: FleetInstallReport[]): void { const installerLabel = report.installer ? chalk.dim(`installer=${report.installer}`) : chalk.dim("installer=none"); console.log(` ${chalk.bold(report.machine.name)} ${chalk.dim(`[${report.machine.id}]`)} ${installerLabel}`); if (report.error) { - console.log(` ${chalk.red(report.error)}`); + console.log(` ${chalk.red(truncateText(report.error, 180))}`); continue; } @@ -284,14 +327,20 @@ function renderFleetInstall(reports: FleetInstallReport[]): void { continue; } - for (const result of report.results) { - const icon = result.success ? chalk.green("✓") : chalk.red("✗"); - console.log(` ${icon} ${result.packageName}@${result.requestedVersion}`); - if (!result.success && result.stderr.trim()) { - console.log(` ${chalk.red(result.stderr.trim())}`); + const successes = report.results.filter((result) => result.success).length; + console.log(` ${chalk.dim(`attempted=${report.attempted} · success=${successes} · failed=${report.results.length - successes}`)}`); + if (opts.verbose) { + for (const result of report.results) { + const icon = result.success ? chalk.green("✓") : chalk.red("✗"); + console.log(` ${icon} ${result.packageName}@${result.requestedVersion}`); + if (!result.success && result.stderr.trim()) { + console.log(` ${chalk.red(truncateText(result.stderr.trim(), 180))}`); + } } } } + + if (!opts.verbose) console.log(chalk.dim("\nUse --verbose for per-package install results or -j for full JSON.")); } const program = new Command(); @@ -308,6 +357,8 @@ program .description("List registered MCP servers") .option("--json", "Output as JSON") .option("--verbose", "Show detailed info including health, command, and transport") + .option("--limit ", "Maximum servers to show in compact output", String(DEFAULT_LIST_LIMIT)) + .option("--cursor ", "Start compact output at a numeric cursor") .action((opts) => { const servers = listServers(); if (opts.json) { @@ -322,14 +373,15 @@ program return; } const toolCounts = getToolCounts(); - for (const s of servers) { + const page = paginate(servers, { limit: opts.limit, cursor: opts.cursor }); + for (const s of page.items) { const status = s.enabled ? chalk.green("enabled") : chalk.red("disabled"); const cachedCount = toolCounts.get(s.id) ?? 0; const toolCount = cachedCount > 0 ? chalk.dim(` (${cachedCount} tools)`) : ""; const errorWarning = s.last_error ? chalk.red(" ⚠") : ""; console.log(` ${chalk.bold(s.name)} ${chalk.dim(`[${s.id}]`)} — ${status}${toolCount}${errorWarning}`); - if (s.description) console.log(` ${chalk.dim(s.description)}`); if (opts.verbose) { + if (s.description) console.log(` ${chalk.dim(truncateText(s.description))}`); console.log(` Command: ${chalk.dim(`${s.command} ${s.args.join(" ")}`)}`); console.log(` Transport: ${chalk.dim(s.transport)}`); const now = Date.now(); @@ -357,9 +409,12 @@ program : chalk.yellow("⚠ stale"); console.log(` Health: ${healthIcon}`); } else { - console.log(` ${chalk.dim(`${s.command} ${s.args.join(" ")}`)}`); + const summary = compactServer(s, cachedCount); + if (s.description) console.log(` ${chalk.dim(truncateText(s.description))}`); + console.log(chalk.dim(` transport=${summary.transport} source=${summary.source}${summary.hasLastError ? " last_error=yes" : ""}`)); } } + printPageFooter(page, "server(s)", "Use --verbose or `mcps info ` for command, env, health, and cached tool details."); closeDb(); }); @@ -368,21 +423,29 @@ program .command("search") .argument("", "Search query") .description("Search official MCP registry") - .action(async (query: string) => { + .option("--json", "Output as JSON") + .option("--limit ", "Maximum results to show in compact output", String(DEFAULT_LIST_LIMIT)) + .option("--cursor ", "Start compact output at a numeric cursor") + .action(async (query: string, opts) => { console.log(chalk.dim(`Searching registry for "${query}"...`)); try { const results = await searchRegistry(query); + if (opts.json) { + printJson(results); + return; + } if (results.length === 0) { console.log(chalk.dim("No servers found.")); return; } - for (const s of results) { + const page = paginate(results, { limit: opts.limit, cursor: opts.cursor }); + for (const s of page.items) { console.log(` ${chalk.bold(s.name)} ${chalk.dim(`[${s.id}]`)}`); - if (s.description) console.log(` ${chalk.dim(s.description)}`); + if (s.description) console.log(` ${chalk.dim(truncateText(s.description))}`); const pkg = s.packages?.[0]; if (pkg) console.log(` ${chalk.dim(`${pkg.registryType}: ${pkg.identifier}`)}`); } - console.log(chalk.dim(`\n${results.length} result(s). Use \`mcps add --from-registry \` to install.`)); + printPageFooter(page, "result(s)", "Use --json for full registry records, or `mcps add --from-registry ` to install."); } catch (err) { console.error(chalk.red(`Search failed: ${(err as Error).message}`)); process.exit(1); @@ -399,6 +462,9 @@ providersCmd .description("List curated provider profiles") .option("--json", "Output as JSON") .option("--enabled-only", "Only include enabled profiles") + .option("--verbose", "Show endpoints, auth metadata, and longer descriptions") + .option("--limit ", "Maximum profiles to show in compact output", String(DEFAULT_LIST_LIMIT)) + .option("--cursor ", "Start compact output at a numeric cursor") .action((opts) => { const profiles = listProviderProfiles({ enabledOnly: opts.enabledOnly === true }); if (opts.json) { @@ -411,7 +477,19 @@ providersCmd closeDb(); return; } - for (const profile of profiles) printProviderProfile(profile); + const page = paginate(profiles, { limit: opts.limit, cursor: opts.cursor }); + for (const profile of page.items) { + if (opts.verbose) { + printProviderProfile(profile); + } else { + const compact = compactProviderProfile(profile); + const status = compact.enabled ? chalk.green("enabled") : chalk.red("disabled"); + console.log(` ${chalk.bold(compact.displayName)} ${chalk.dim(`[${compact.id}]`)} — ${chalk.dim(compact.transport)} — ${status}`); + if (compact.description) console.log(` ${chalk.dim(compact.description)}`); + console.log(chalk.dim(` auth=${compact.authType} token=${compact.tokenMode}`)); + } + } + printPageFooter(page, "profile(s)", "Use --verbose or `mcps providers info ` for endpoints, scopes, and fallback details."); closeDb(); }); @@ -421,6 +499,9 @@ providersCmd .description("Search curated provider profiles") .option("--json", "Output as JSON") .option("--enabled-only", "Only include enabled profiles") + .option("--verbose", "Show endpoints, auth metadata, and longer descriptions") + .option("--limit ", "Maximum profiles to show in compact output", String(DEFAULT_LIST_LIMIT)) + .option("--cursor ", "Start compact output at a numeric cursor") .action((query: string, opts) => { const profiles = searchProviderProfiles(query, { enabledOnly: opts.enabledOnly === true }); if (opts.json) { @@ -433,8 +514,19 @@ providersCmd closeDb(); return; } - for (const profile of profiles) printProviderProfile(profile); - console.log(chalk.dim(`\n${profiles.length} provider profile(s). Use \`mcps providers install \` to register one.`)); + const page = paginate(profiles, { limit: opts.limit, cursor: opts.cursor }); + for (const profile of page.items) { + if (opts.verbose) { + printProviderProfile(profile); + } else { + const compact = compactProviderProfile(profile); + const status = compact.enabled ? chalk.green("enabled") : chalk.red("disabled"); + console.log(` ${chalk.bold(compact.displayName)} ${chalk.dim(`[${compact.id}]`)} — ${chalk.dim(compact.transport)} — ${status}`); + if (compact.description) console.log(` ${chalk.dim(compact.description)}`); + console.log(chalk.dim(` auth=${compact.authType} token=${compact.tokenMode}`)); + } + } + printPageFooter(page, "profile(s)", "Use --verbose, `mcps providers info `, or `mcps providers install ` for the next step."); closeDb(); }); @@ -808,54 +900,57 @@ program .argument("[server-id]", "Optional server ID to filter by") .description("List tools (all or per server)") .option("--connect", "Connect to servers to fetch live tools") + .option("--json", "Output as JSON") + .option("--verbose", "Show descriptions in compact output") + .option("--limit ", "Maximum tools to show in compact output", String(DEFAULT_LIST_LIMIT)) + .option("--cursor ", "Start compact output at a numeric cursor") .option("--yes", "Approve launching local stdio commands when --connect is used") .option("--allow-local-stdio", "Approve launching local stdio commands when --connect is used") .option("--allow-risky-command", "Approve high-risk local command patterns") .action(async (serverId: string | undefined, opts) => { + let tools: Array<{ server_id?: string; name: string; description: string; input_schema: Record }> = []; if (opts.connect) { console.log(chalk.dim("Connecting to enabled servers...")); await connectAllEnabled({ localCommandConsent: localConsentFromOptions(opts) }); - const tools = listAllTools(); - if (tools.length === 0) { - console.log(chalk.dim("No tools available.")); - } else { - for (const t of tools) { - console.log(` ${chalk.bold(t.name)}`); - if (t.description) console.log(` ${chalk.dim(t.description)}`); - } - console.log(chalk.dim(`\n${tools.length} tool(s) available.`)); - } + tools = listAllTools(); await disconnectAll(); } else if (serverId) { - const cached = getCachedTools(serverId); - if (cached.length === 0) { - console.log(chalk.dim(`No cached tools for "${serverId}". Use --connect to fetch live tools.`)); - } else { - for (const t of cached) { - console.log(` ${chalk.bold(t.name)}`); - if (t.description) console.log(` ${chalk.dim(t.description)}`); - } - } + tools = getCachedTools(serverId).map((tool) => ({ ...tool, server_id: serverId })); } else { const servers = listServers(); - let total = 0; for (const s of servers) { const cached = getCachedTools(s.id); - if (cached.length > 0) { - console.log(chalk.bold(`\n${s.name} [${s.id}]:`)); - for (const t of cached) { - console.log(` ${chalk.bold(t.name)}`); - if (t.description) console.log(` ${chalk.dim(t.description)}`); - } - total += cached.length; - } + tools.push(...cached.map((tool) => ({ ...tool, server_id: s.id }))); } - if (total === 0) { - console.log(chalk.dim("No cached tools. Use `mcps tools --connect` to fetch from servers.")); - } else { - console.log(chalk.dim(`\n${total} tool(s) total.`)); + } + + if (opts.json) { + printJson(tools); + closeDb(); + return; + } + + if (tools.length === 0) { + console.log(chalk.dim(serverId ? `No cached tools for "${serverId}". Use --connect to fetch live tools.` : "No cached tools. Use `mcps tools --connect` to fetch from servers.")); + closeDb(); + return; + } + + const page = paginate(tools, { limit: opts.limit, cursor: opts.cursor }); + for (const tool of page.items) { + const compact = compactTool(tool); + const prefix = compact.server_id ? `${compact.server_id}__` : ""; + const schemaBits = [ + `props=${compact.inputSchema.propertyCount}`, + `required=${compact.inputSchema.requiredCount}`, + ].join(" "); + console.log(` ${chalk.bold(`${prefix}${compact.name}`)} ${chalk.dim(schemaBits)}`); + if (opts.verbose && compact.description) console.log(` ${chalk.dim(compact.description)}`); + if (opts.verbose && compact.inputSchema.propertyPreview.length > 0) { + console.log(` ${chalk.dim(`fields: ${compact.inputSchema.propertyPreview.join(", ")}`)}`); } } + printPageFooter(page, "tool(s)", "Use --verbose for descriptions/field previews, --json for full schemas, or `mcps info ` for one server."); closeDb(); }); @@ -914,7 +1009,11 @@ program .command("info") .argument("", "Server ID") .description("Show server details & tools") - .action((id: string) => { + .option("--json", "Output full server and cached tools as JSON") + .option("--verbose", "Show full cached tool descriptions") + .option("--limit ", "Maximum cached tools to show", String(DEFAULT_LIST_LIMIT)) + .option("--cursor ", "Start cached tools at a numeric cursor") + .action((id: string, opts) => { const server = getServer(id); if (!server) { console.error(chalk.red(`Server "${id}" not found.`)); @@ -923,13 +1022,20 @@ program } const safeServer = redactServerCredentials(server); + const cached = getCachedTools(id); + if (opts.json) { + printJson({ server: safeServer, tools: cached }); + closeDb(); + return; + } + console.log(chalk.bold(safeServer.name) + " " + chalk.dim(`[${safeServer.id}]`)); console.log(` Status: ${safeServer.enabled ? chalk.green("enabled") : chalk.red("disabled")}`); console.log(` Source: ${safeServer.source}`); console.log(` Transport: ${safeServer.transport}`); console.log(` Command: ${safeServer.command} ${safeServer.args.join(" ")}`); if (safeServer.url) console.log(` URL: ${safeServer.url}`); - if (safeServer.description) console.log(` Desc: ${safeServer.description}`); + if (safeServer.description) console.log(` Desc: ${truncateText(safeServer.description, opts.verbose ? 400 : 160)}`); if (Object.keys(safeServer.env).length > 0) { console.log(` Env: ${Object.entries(safeServer.env).map(([k, v]) => `${k}=${v}`).join(", ")}`); } @@ -941,13 +1047,20 @@ program console.log(` Created: ${safeServer.created_at}`); console.log(` Updated: ${safeServer.updated_at}`); - const cached = getCachedTools(id); if (cached.length > 0) { + const page = paginate(cached, { limit: opts.limit, cursor: opts.cursor }); console.log(chalk.bold(`\n Tools (${cached.length}):`)); - for (const t of cached) { + for (const t of page.items) { console.log(` ${chalk.bold(t.name)}`); - if (t.description) console.log(` ${chalk.dim(t.description)}`); + if (t.description) console.log(` ${chalk.dim(truncateText(t.description, opts.verbose ? 300 : 120))}`); + if (opts.verbose) { + const compact = compactTool(t); + if (compact.inputSchema.propertyPreview.length > 0) { + console.log(` ${chalk.dim(`fields: ${compact.inputSchema.propertyPreview.join(", ")}`)}`); + } + } } + printPageFooter(page, "tool(s)", "Use --cursor for more cached tools or --json for full schemas."); } closeDb(); }); @@ -1110,6 +1223,8 @@ program .description("Find MCP servers across npm, GitHub, official registry, and awesome lists") .option("--source ", "Source IDs to search (see `mcps sources list`)") .option("--limit ", "Max results per source", "20") + .option("--cursor ", "Start compact output at a numeric cursor") + .option("--verbose", "Show URLs and longer descriptions in compact output") .option("--awesome", "List curated servers from punkpeye/awesome-mcp-servers") .option("--json", "Output as JSON") .option("--install", "After showing results, prompt to select one and install it") @@ -1128,13 +1243,14 @@ program } const allSources = listSources(); const sourceNameMap = new Map(allSources.map((s) => [s.id, s.name])); - for (const r of results) { + const page = paginate(results, { limit: opts.limit, cursor: opts.cursor }); + for (const r of page.items) { const sourceName = r.sourceId ? (sourceNameMap.get(r.sourceId) ?? r.source) : r.source; console.log(` ${chalk.bold(r.name)} ${chalk.yellow(`[${sourceName}]`)}`); - if (r.description) console.log(` ${chalk.dim(r.description)}`); - if (r.url) console.log(` ${chalk.cyan(r.url)}`); + if (r.description) console.log(` ${chalk.dim(truncateText(r.description))}`); + if (opts.verbose && r.url) console.log(` ${chalk.cyan(r.url)}`); } - console.log(chalk.dim(`\n${results.length} servers in awesome list.`)); + printPageFooter(page, "server(s)", "Use --cursor for more results, --verbose for URLs, or --json for the full awesome list."); closeDb(); return; } @@ -1186,25 +1302,27 @@ program github: chalk.magenta, }; - for (let i = 0; i < results.length; i++) { - const r = results[i]; + const page = paginate(results, { limit, cursor: opts.cursor }); + for (let i = 0; i < page.items.length; i++) { + const r = page.items[i]; const sourceName = r.sourceId ? (sourceNameMap.get(r.sourceId) ?? r.source) : r.source; const sourceLabel = (sourceColors[r.source] ?? chalk.dim)(`[${sourceName}]`); const stars = r.stars ? chalk.dim(` ★${r.stars}`) : ""; - const idx = opts.install ? chalk.dim(`${i + 1}. `) : " "; + const idx = opts.install ? chalk.dim(`${page.offset + i + 1}. `) : " "; console.log(`${idx}${chalk.bold(r.name)} ${sourceLabel}${stars}`); - if (r.description) console.log(` ${chalk.dim(r.description)}`); + if (r.description) console.log(` ${chalk.dim(truncateText(r.description))}`); if (r.installCmd) console.log(` ${chalk.green(`Install: ${r.installCmd}`)}`); - else if (r.url) console.log(` ${chalk.cyan(r.url)}`); + else if (opts.verbose && r.url) console.log(` ${chalk.cyan(r.url)}`); } console.log( chalk.dim( - `\nFound ${results.length} results across ${sourcesUsed} source${sourcesUsed === 1 ? "" : "s"} (${elapsed}ms)` + `\n${pageSummary(page, "result(s)")} Found ${results.length} total across ${sourcesUsed} source${sourcesUsed === 1 ? "" : "s"} (${elapsed}ms)` ) ); + if (page.nextCursor) console.log(chalk.dim(`Use --cursor ${page.nextCursor} for the next page.`)); if (breakdownParts) console.log(chalk.dim(` Breakdown: ${breakdownParts}`)); - console.log(chalk.dim(`Use \`mcps add --from-registry \` or \`mcps add npx -y \` to install.`)); + console.log(chalk.dim(`Use --verbose for URLs, --json for full records, or \`mcps add --from-registry \` / \`mcps add npx -y \` to install.`)); if (opts.install) { let chosen = results[0]; @@ -1280,19 +1398,35 @@ const sourcesCmd = program.command("sources").description("Manage search sources sourcesCmd .command("list") .description("List all search sources") - .action(() => { + .option("--json", "Output as JSON") + .option("--verbose", "Show source URLs and descriptions") + .option("--limit ", "Maximum sources to show in compact output", String(DEFAULT_LIST_LIMIT)) + .option("--cursor ", "Start compact output at a numeric cursor") + .action((opts) => { const sources = listSources(); + if (opts.json) { + printJson(sources); + closeDb(); + return; + } if (sources.length === 0) { console.log(chalk.dim("No sources configured.")); closeDb(); return; } - for (const s of sources) { + const page = paginate(sources, { limit: opts.limit, cursor: opts.cursor }); + for (const s of page.items) { + const compact = compactSource(s); const status = s.enabled ? chalk.green("enabled") : chalk.red("disabled"); - console.log(` ${chalk.bold(s.name)} ${chalk.dim(`[${s.id}]`)} — ${chalk.dim(s.type)} — ${status}`); - if (s.description) console.log(` ${chalk.dim(s.description)}`); - console.log(` ${chalk.cyan(s.url)}`); + console.log(` ${chalk.bold(compact.name)} ${chalk.dim(`[${compact.id}]`)} — ${chalk.dim(compact.type)} — ${status}`); + if (opts.verbose) { + if (s.description) console.log(` ${chalk.dim(truncateText(s.description))}`); + console.log(` ${chalk.cyan(s.url)}`); + } else if (compact.hasDescription) { + console.log(chalk.dim(" has description")); + } } + printPageFooter(page, "source(s)", "Use --verbose for URLs and descriptions or --json for full source records."); closeDb(); }); @@ -1534,6 +1668,9 @@ machinesCmd .description("List registered machines") .option("-j, --json", "Output as JSON") .option("--enabled-only", "Only show enabled machines") + .option("--verbose", "Show SSH targets and last errors") + .option("--limit ", "Maximum machines to show in compact output", String(DEFAULT_LIST_LIMIT)) + .option("--cursor ", "Start compact output at a numeric cursor") .action((opts) => { const machines = listMachines().filter((machine) => (opts.enabledOnly ? machine.enabled : true)); if (opts.json) { @@ -1541,7 +1678,7 @@ machinesCmd closeDb(); return; } - renderMachines(machines); + renderMachines(machines, opts); closeDb(); }); @@ -1682,6 +1819,9 @@ fleetCmd .description("List the discovered @hasna MCP package catalog") .option("--refresh", "Refresh the npm catalog instead of using cache") .option("--package ", "Filter to specific package names") + .option("--verbose", "Show repository URLs") + .option("--limit ", "Maximum packages to show in compact output", String(DEFAULT_LIST_LIMIT)) + .option("--cursor ", "Start compact output at a numeric cursor") .option("-j, --json", "Output as JSON") .action(async (opts) => { try { @@ -1691,7 +1831,7 @@ fleetCmd if (opts.json) { printJson(entries); } else { - renderCatalog(entries); + renderCatalog(entries, opts); } } catch (err) { console.error(chalk.red(`Catalog lookup failed: ${(err as Error).message}`)); @@ -1710,6 +1850,7 @@ fleetCmd .option("--package ", "Restrict the check to specific @hasna package names") .option("--refresh", "Refresh the package catalog before checking") .option("--timeout ", "Remote timeout in milliseconds", String(180_000)) + .option("--verbose", "Show per-package health details") .option("-j, --json", "Output as JSON") .action(async (machineIds: string[] | undefined, opts) => { try { @@ -1723,7 +1864,7 @@ fleetCmd if (opts.json) { printJson(reports); } else { - renderFleetHealth(reports); + renderFleetHealth(reports, opts); } } catch (err) { console.error(chalk.red(`Fleet health check failed: ${(err as Error).message}`)); @@ -1744,6 +1885,7 @@ fleetCmd .option("--refresh", "Refresh the package catalog before installing") .option("--timeout ", "Remote timeout in milliseconds", String(180_000)) .option("--yes", "Confirm remote installs") + .option("--verbose", "Show per-package install results") .option("-j, --json", "Output as JSON") .action(async (machineIds: string[] | undefined, opts) => { try { @@ -1763,7 +1905,7 @@ fleetCmd if (opts.json) { printJson(reports); } else { - renderFleetInstall(reports); + renderFleetInstall(reports, opts); } } catch (err) { console.error(chalk.red(`Fleet install failed: ${(err as Error).message}`)); @@ -1956,6 +2098,82 @@ envCmd.command("unset").argument("").argument("").description("Remove a closeDb(); }); +// --- storage --- +program + .command("storage [action]") + .description("Sync local MCP registry tables with configured remote Postgres storage") + .option("--tables ", "comma-separated table list") + .option("--json", "Output as JSON") + .option("--verbose", "Show full sync metadata and table results") + .action(async (action = "status", opts: { tables?: string; json?: boolean; verbose?: boolean }) => { + const { getStorageStatus, storagePull, storagePush, storageSync } = await import("../lib/storage-sync.js"); + const tables = parseTables(opts.tables); + const printSyncSummary = (label: string, results: Awaited>) => { + const rowsRead = results.reduce((sum, result) => sum + result.rowsRead, 0); + const rowsWritten = results.reduce((sum, result) => sum + result.rowsWritten, 0); + const errors = results.reduce((sum, result) => sum + result.errors.length, 0); + console.log(chalk.bold(label)); + console.log(` Tables: ${results.length}`); + console.log(` Rows read: ${rowsRead}`); + console.log(` Rows written: ${rowsWritten}`); + console.log(` Errors: ${errors === 0 ? chalk.green("0") : chalk.red(String(errors))}`); + for (const result of results.filter((item) => item.errors.length > 0)) { + const compact = compactSyncResult(result); + console.log(` ${chalk.red(compact.table)}: ${compact.firstError}`); + } + }; + try { + switch (action) { + case "status": { + const status = getStorageStatus(); + if (opts.json || opts.verbose) { + printJson(status); + break; + } + console.log(chalk.bold("Storage Status")); + console.log(` Mode: ${status.mode}`); + console.log(` Configured: ${status.configured ? chalk.green("yes") : chalk.yellow("no")}`); + console.log(` Active env: ${status.activeEnv ?? "none"}`); + console.log(` Tables: ${status.tables.length}`); + console.log(` Sync meta: ${status.sync.length} record(s)`); + console.log(chalk.dim("Use --verbose or --json for table names and sync timestamps.")); + break; + } + case "push": { + const results = await storagePush({ tables }); + if (opts.json || opts.verbose) printJson(results); + else printSyncSummary("Storage Push", results); + break; + } + case "pull": { + const results = await storagePull({ tables }); + if (opts.json || opts.verbose) printJson(results); + else printSyncSummary("Storage Pull", results); + break; + } + case "sync": { + const results = await storageSync({ tables }); + if (opts.json || opts.verbose) { + printJson(results); + } else { + printSyncSummary("Storage Sync Push", results.push); + printSyncSummary("Storage Sync Pull", results.pull); + } + break; + } + default: + console.error(chalk.red(`Unknown storage action: ${action}. Valid actions: status, push, pull, sync`)); + closeDb(); + process.exit(1); + } + } catch (err) { + console.error(chalk.red(`Storage ${action} failed: ${(err as Error).message}`)); + closeDb(); + process.exit(1); + } + closeDb(); + }); + // --- mcp --- program .command("mcp") @@ -1984,5 +2202,7 @@ program program.action(() => { render(React.createElement(App)); }); +registerEventsCommands(program, { source: "mcps" }); + program.parse(); diff --git a/src/index.ts b/src/index.ts index 5a152cc..6487300 100644 --- a/src/index.ts +++ b/src/index.ts @@ -129,3 +129,16 @@ export { } from "./lib/proxy.js"; export { getDb, closeDb } from "./lib/db.js"; +export { + STORAGE_TABLES, + storagePull, + storagePush, + storageSync, + getStorageDatabaseEnv, + getStorageDatabaseUrl, + getStorageMode, + getStorageStatus, + runStorageMigrations, + getSyncMetaAll, +} from "./lib/storage-sync.js"; +export type { StorageEnv, StorageMode, SyncMeta, SyncResult } from "./lib/storage-sync.js"; diff --git a/src/lib/compact-output.ts b/src/lib/compact-output.ts new file mode 100644 index 0000000..7ef0a20 --- /dev/null +++ b/src/lib/compact-output.ts @@ -0,0 +1,212 @@ +import type { + FinderResult, + FleetHealthReport, + FleetInstallReport, + HasnaMcpCatalogEntry, + MachineEntry, + McpServerEntry, + McpSource, + ProviderProfile, + RegistryServer, +} from "../types.js"; +import type { SyncResult } from "./storage-sync.js"; + +export const DEFAULT_LIST_LIMIT = 20; +export const MAX_LIST_LIMIT = 100; +export const DEFAULT_TEXT_LIMIT = 120; + +export interface Page { + items: T[]; + total: number; + limit: number; + offset: number; + nextCursor: string | null; + truncated: boolean; +} + +export function truncateText(value: unknown, maxLength = DEFAULT_TEXT_LIMIT): string { + const text = String(value ?? "").replace(/\s+/g, " ").trim(); + if (text.length <= maxLength) return text; + if (maxLength <= 3) return text.slice(0, maxLength); + return `${text.slice(0, maxLength - 3)}...`; +} + +export function parseLimit(value: unknown, defaultLimit = DEFAULT_LIST_LIMIT, maxLimit = MAX_LIST_LIMIT): number { + if (value === undefined || value === null || value === "") return defaultLimit; + const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || Number.isNaN(parsed) || parsed < 1) return defaultLimit; + return Math.min(Math.floor(parsed), maxLimit); +} + +export function parseCursor(value: unknown): number { + if (value === undefined || value === null || value === "") return 0; + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || Number.isNaN(parsed) || parsed < 0) return 0; + return Math.floor(parsed); +} + +export function paginate( + items: T[], + options: { limit?: unknown; cursor?: unknown; defaultLimit?: number; maxLimit?: number } = {}, +): Page { + const limit = parseLimit(options.limit, options.defaultLimit ?? DEFAULT_LIST_LIMIT, options.maxLimit ?? MAX_LIST_LIMIT); + const offset = Math.min(parseCursor(options.cursor), items.length); + const pageItems = items.slice(offset, offset + limit); + const nextOffset = offset + pageItems.length; + return { + items: pageItems, + total: items.length, + limit, + offset, + nextCursor: nextOffset < items.length ? String(nextOffset) : null, + truncated: nextOffset < items.length, + }; +} + +export function pageSummary(page: Page, noun: string): string { + const rangeStart = page.total === 0 ? 0 : page.offset + 1; + const rangeEnd = page.offset + page.items.length; + return `Showing ${rangeStart}-${rangeEnd} of ${page.total} ${noun}.`; +} + +export function compactServer(server: McpServerEntry, toolCount?: number) { + return { + id: server.id, + name: truncateText(server.name, 80), + enabled: server.enabled, + transport: server.transport, + source: server.source, + toolCount: toolCount ?? 0, + hasDescription: Boolean(server.description), + hasLastError: Boolean(server.last_error), + }; +} + +export function compactRegistryServer(server: RegistryServer) { + const pkg = server.packages?.[0]; + return { + id: server.id, + name: truncateText(server.name, 80), + description: truncateText(server.description), + package: pkg ? `${pkg.registryType}:${pkg.identifier}` : null, + }; +} + +export function compactFinderResult(result: FinderResult) { + return { + name: truncateText(result.name, 90), + source: result.source, + sourceId: result.sourceId ?? null, + description: truncateText(result.description), + installCmd: result.installCmd ?? null, + url: result.url ?? null, + stars: result.stars ?? null, + }; +} + +export function compactTool(tool: { + server_id?: string; + name: string; + description: string; + input_schema: Record; +}) { + const schema = tool.input_schema; + const properties = isRecord(schema.properties) ? Object.keys(schema.properties) : []; + const required = Array.isArray(schema.required) ? schema.required.length : 0; + return { + server_id: tool.server_id ?? null, + name: tool.name, + description: truncateText(tool.description), + inputSchema: { + propertyCount: properties.length, + requiredCount: required, + propertyPreview: properties.slice(0, 8), + }, + }; +} + +export function compactProviderProfile(profile: ProviderProfile) { + return { + id: profile.id, + displayName: truncateText(profile.displayName, 80), + description: truncateText(profile.description), + transport: profile.transport, + authType: profile.authType, + tokenMode: profile.tokenMode, + enabled: profile.enabled, + }; +} + +export function compactSource(source: McpSource) { + return { + id: source.id, + name: truncateText(source.name, 80), + type: source.type, + enabled: source.enabled, + hasDescription: Boolean(source.description), + }; +} + +export function compactMachine(machine: MachineEntry) { + return { + id: machine.id, + name: truncateText(machine.name, 80), + enabled: machine.enabled, + platform: machine.platform, + arch: machine.arch, + installer: machine.installer, + hasLastError: Boolean(machine.last_error), + }; +} + +export function compactCatalogEntry(entry: HasnaMcpCatalogEntry) { + return { + name: entry.name, + version: entry.version, + description: truncateText(entry.description), + mcpBin: entry.mcpBin, + binCount: Object.keys(entry.bins).length, + }; +} + +export function compactFleetHealthReport(report: FleetHealthReport) { + return { + machine: { + id: report.machine.id, + name: report.machine.name, + }, + checkedAt: report.checkedAt, + runtime: `${report.runtime.platform}/${report.runtime.arch}`, + summary: report.summary, + error: report.error ? truncateText(report.error) : null, + }; +} + +export function compactFleetInstallReport(report: FleetInstallReport) { + const successes = report.results.filter((result) => result.success).length; + return { + machine: { + id: report.machine.id, + name: report.machine.name, + }, + installer: report.installer, + attempted: report.attempted, + successes, + failures: report.results.length - successes, + error: report.error ? truncateText(report.error) : null, + }; +} + +export function compactSyncResult(result: SyncResult) { + return { + table: result.table, + rowsRead: result.rowsRead, + rowsWritten: result.rowsWritten, + errorCount: result.errors.length, + firstError: result.errors[0] ? truncateText(result.errors[0]) : null, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index c3c3db3..da04d2d 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,9 +1,26 @@ import { Database } from "bun:sqlite"; -import { SqliteAdapter } from "@hasna/cloud"; import { mkdirSync } from "fs"; import { MCPS_DIR, DB_PATH } from "./config.js"; import { DEFAULT_PROVIDER_PROFILE_SEEDS } from "./provider-profile-seeds.js"; +export class SqliteAdapter { + readonly raw: Database; + + constructor(path: string) { + this.raw = new Database(path); + this.raw.exec("PRAGMA journal_mode = WAL"); + this.raw.exec("PRAGMA foreign_keys = ON"); + } + + run(sql: string, ...params: any[]) { + return this.raw.run(sql, ...params); + } + + close(): void { + this.raw.close(); + } +} + let db: Database | null = null; let _adapter: SqliteAdapter | null = null; @@ -14,7 +31,6 @@ export function getDb(): Database { _adapter = new SqliteAdapter(DB_PATH); db = _adapter.raw; - // SqliteAdapter already sets WAL and foreign_keys; add busy_timeout db.exec("PRAGMA busy_timeout = 5000"); db.exec(` diff --git a/src/lib/pg-migrations.ts b/src/lib/pg-migrations.ts index 0350edb..5ba602c 100644 --- a/src/lib/pg-migrations.ts +++ b/src/lib/pg-migrations.ts @@ -1,10 +1,13 @@ /** - * PostgreSQL migrations for open-mcps cloud sync. + * PostgreSQL migrations for open-mcps remote storage sync. * * Equivalent to the SQLite schema in db.ts, translated for PostgreSQL. */ export const PG_MIGRATIONS: string[] = [ + // Migration 0: UUID helper for feedback rows + `CREATE EXTENSION IF NOT EXISTS pgcrypto`, + // Migration 1: servers table `CREATE TABLE IF NOT EXISTS servers ( id TEXT PRIMARY KEY, @@ -13,6 +16,7 @@ export const PG_MIGRATIONS: string[] = [ command TEXT NOT NULL, args TEXT NOT NULL DEFAULT '[]', env TEXT NOT NULL DEFAULT '{}', + credential_refs TEXT NOT NULL DEFAULT '{}', transport TEXT NOT NULL DEFAULT 'stdio', url TEXT, source TEXT NOT NULL DEFAULT 'local', @@ -23,6 +27,8 @@ export const PG_MIGRATIONS: string[] = [ updated_at TEXT NOT NULL DEFAULT NOW()::text )`, + `ALTER TABLE servers ADD COLUMN IF NOT EXISTS credential_refs TEXT NOT NULL DEFAULT '{}'`, + // Migration 2: tool_cache table `CREATE TABLE IF NOT EXISTS tool_cache ( server_id TEXT NOT NULL, @@ -47,7 +53,29 @@ export const PG_MIGRATIONS: string[] = [ created_at TEXT NOT NULL DEFAULT NOW()::text )`, - // Migration 4: feedback table + // Migration 4: machines table + `CREATE TABLE IF NOT EXISTS machines ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + host TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 22, + platform TEXT NOT NULL DEFAULT 'unknown', + arch TEXT NOT NULL DEFAULT 'unknown', + bun_path TEXT, + npm_path TEXT, + installer TEXT NOT NULL DEFAULT 'auto', + ssh_key_path TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TEXT NOT NULL DEFAULT NOW()::text, + updated_at TEXT NOT NULL DEFAULT NOW()::text, + last_seen_at TEXT, + last_error TEXT + )`, + + `CREATE INDEX IF NOT EXISTS idx_machines_enabled ON machines(enabled)`, + + // Migration 5: feedback table `CREATE TABLE IF NOT EXISTS feedback ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, message TEXT NOT NULL, @@ -58,7 +86,7 @@ export const PG_MIGRATIONS: string[] = [ created_at TEXT NOT NULL DEFAULT NOW()::text )`, - // Migration 5: provider profile catalog + // Migration 6: provider profile catalog `CREATE TABLE IF NOT EXISTS provider_profiles ( id TEXT PRIMARY KEY, display_name TEXT NOT NULL, diff --git a/src/lib/remote-storage.ts b/src/lib/remote-storage.ts new file mode 100644 index 0000000..4912a47 --- /dev/null +++ b/src/lib/remote-storage.ts @@ -0,0 +1,45 @@ +import pg from "pg"; +import type { Pool } from "pg"; + +function translatePlaceholders(sql: string): string { + let index = 0; + return sql.replace(/\?/g, () => `$${++index}`); +} + +function normalizeParams(params: unknown[]): unknown[] { + const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params; + return flat.map((value) => value === undefined ? null : value); +} + +function sslConfigFor(connectionString: string): { rejectUnauthorized: boolean } | undefined { + return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") + ? { rejectUnauthorized: false } + : undefined; +} + +export class PgAdapterAsync { + private readonly pool: Pool; + + constructor(connectionString: string) { + this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) }); + } + + async run(sql: string, ...params: unknown[]): Promise<{ changes: number }> { + const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params)); + return { changes: result.rowCount ?? 0 }; + } + + async get(sql: string, ...params: unknown[]): Promise { + const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params)); + return result.rows[0] ?? null; + } + + async all(sql: string, ...params: unknown[]): Promise { + const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params)); + return result.rows; + } + + async close(): Promise { + await this.pool.end(); + } +} diff --git a/src/lib/storage-sync.ts b/src/lib/storage-sync.ts new file mode 100644 index 0000000..8336afe --- /dev/null +++ b/src/lib/storage-sync.ts @@ -0,0 +1,320 @@ +import type { Database } from "bun:sqlite"; +import { getDb } from "./db.js"; +import { PG_MIGRATIONS } from "./pg-migrations.js"; +import { PgAdapterAsync } from "./remote-storage.js"; + +export const STORAGE_TABLES = [ + "servers", + "tool_cache", + "sources", + "machines", + "provider_profiles", + "feedback", +] as const; + +type StorageTable = (typeof STORAGE_TABLES)[number]; +type Row = Record; +export type StorageMode = "local" | "hybrid" | "remote"; + +const DATABASE_ENV_NAMES = [ + { name: "HASNA_MCPS_DATABASE_URL", deprecated: false }, + { name: "MCPS_DATABASE_URL", deprecated: false }, +] as const; + +const MODE_ENV_NAMES = [ + { name: "HASNA_MCPS_STORAGE_MODE", deprecated: false }, + { name: "MCPS_STORAGE_MODE", deprecated: false }, +] as const; + +const PRIMARY_KEYS: Record = { + servers: ["id"], + tool_cache: ["server_id", "name"], + sources: ["id"], + machines: ["id"], + provider_profiles: ["id"], + feedback: ["id"], +}; + +export interface SyncResult { + table: string; + rowsRead: number; + rowsWritten: number; + errors: string[]; +} + +export interface SyncMeta { + table_name: string; + last_synced_at: string | null; + direction: "push" | "pull"; +} + +export interface StorageEnv { + name: string; + deprecated: boolean; +} + +function readEnv(name: string): string | null { + const value = process.env[name]?.trim(); + return value ? value : null; +} + +export function getStorageDatabaseEnv(): StorageEnv | null { + for (const env of DATABASE_ENV_NAMES) { + if (readEnv(env.name)) return env; + } + return null; +} + +export function getStorageDatabaseUrl(): string | null { + const env = getStorageDatabaseEnv(); + return env ? readEnv(env.name) : null; +} + +function normalizeStorageMode(value: string): StorageMode { + const normalized = value.trim().toLowerCase(); + if (normalized === "local" || normalized === "hybrid" || normalized === "remote") { + return normalized; + } + throw new Error(`Unknown mcps storage mode: ${value}`); +} + +export function getStorageMode(): StorageMode { + for (const env of MODE_ENV_NAMES) { + const value = readEnv(env.name); + if (value) return normalizeStorageMode(value); + } + return getStorageDatabaseUrl() ? "hybrid" : "local"; +} + +export function getStorageStatus(): { + configured: boolean; + mode: StorageMode; + env: string[]; + deprecatedEnv: string[]; + activeEnv: string | null; + deprecatedActiveEnv: boolean; + tables: readonly StorageTable[]; + sync: SyncMeta[]; +} { + const activeEnv = getStorageDatabaseEnv(); + return { + configured: Boolean(activeEnv), + mode: getStorageMode(), + env: DATABASE_ENV_NAMES.filter((env) => !env.deprecated).map((env) => env.name), + deprecatedEnv: DATABASE_ENV_NAMES.filter((env) => env.deprecated).map((env) => env.name), + activeEnv: activeEnv?.name ?? null, + deprecatedActiveEnv: activeEnv?.deprecated ?? false, + tables: STORAGE_TABLES, + sync: getSyncMetaAll(), + }; +} + +export async function getStoragePg(): Promise { + const url = getStorageDatabaseUrl(); + if (!url) { + throw new Error("Missing HASNA_MCPS_DATABASE_URL or MCPS_DATABASE_URL"); + } + return new PgAdapterAsync(url); +} + +export async function runStorageMigrations(remote: PgAdapterAsync): Promise { + for (const sql of PG_MIGRATIONS) await remote.run(sql); +} + +export async function storagePush(options?: { tables?: string[] }): Promise { + const remote = await getStoragePg(); + try { + await runStorageMigrations(remote); + const db = getDb(); + const results: SyncResult[] = []; + for (const table of parseStorageTables(options?.tables)) { + results.push(await pushTable(db, remote, table)); + } + recordSyncMeta("push", results); + return results; + } finally { + await remote.close(); + } +} + +export async function storagePull(options?: { tables?: string[] }): Promise { + const remote = await getStoragePg(); + try { + await runStorageMigrations(remote); + const db = getDb(); + const results: SyncResult[] = []; + for (const table of parseStorageTables(options?.tables)) { + results.push(await pullTable(remote, db, table)); + } + recordSyncMeta("pull", results); + return results; + } finally { + await remote.close(); + } +} + +export async function storageSync(options?: { tables?: string[] }): Promise<{ push: SyncResult[]; pull: SyncResult[] }> { + const push = await storagePush(options); + const pull = await storagePull(options); + return { push, pull }; +} + +export function getSyncMetaAll(): SyncMeta[] { + const db = getDb(); + ensureSyncMetaTable(db); + return db + .prepare("SELECT table_name, last_synced_at, direction FROM _mcps_sync_meta ORDER BY table_name, direction") + .all() as SyncMeta[]; +} + +export function parseStorageTables(tables?: string[]): StorageTable[] { + if (!tables || tables.length === 0) return [...STORAGE_TABLES]; + const allowed = new Set(STORAGE_TABLES); + const requested = tables.map((table) => table.trim()).filter(Boolean); + const invalid = requested.filter((table) => !allowed.has(table)); + if (invalid.length > 0) throw new Error(`Unknown mcps sync table(s): ${invalid.join(", ")}`); + return requested as StorageTable[]; +} + +export const resolveTables = parseStorageTables; + +async function pushTable(db: Database, remote: PgAdapterAsync, table: StorageTable): Promise { + const result: SyncResult = { table, rowsRead: 0, rowsWritten: 0, errors: [] }; + try { + const rows = db.prepare(`SELECT * FROM ${quoteIdent(table)}`).all() as Row[]; + result.rowsRead = rows.length; + if (rows.length === 0) return result; + const columns = await filterRemoteColumns(remote, table, Object.keys(rows[0]!)); + result.rowsWritten = await upsertPg(remote, table, columns, rows); + } catch (error) { + result.errors.push(error instanceof Error ? error.message : String(error)); + } + return result; +} + +async function pullTable(remote: PgAdapterAsync, db: Database, table: StorageTable): Promise { + const result: SyncResult = { table, rowsRead: 0, rowsWritten: 0, errors: [] }; + try { + const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`) as Row[]; + result.rowsRead = rows.length; + if (rows.length === 0) return result; + const columns = filterLocalColumns(db, table, Object.keys(rows[0]!)); + result.rowsWritten = upsertSqlite(db, table, columns, rows); + } catch (error) { + result.errors.push(error instanceof Error ? error.message : String(error)); + } + return result; +} + +async function filterRemoteColumns(remote: PgAdapterAsync, table: string, columns: string[]): Promise { + const rows = await remote.all(` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ? + `, table) as Array<{ column_name: string }>; + if (rows.length === 0) return columns; + const allowed = new Set(rows.map((row) => row.column_name)); + return columns.filter((column) => allowed.has(column)); +} + +function filterLocalColumns(db: Database, table: string, columns: string[]): string[] { + const rows = db.prepare(`PRAGMA table_info(${quoteIdent(table)})`).all() as Array<{ name: string }>; + const allowed = new Set(rows.map((row) => row.name)); + return columns.filter((column) => allowed.has(column)); +} + +async function upsertPg(remote: PgAdapterAsync, table: StorageTable, columns: string[], rows: Row[]): Promise { + if (columns.length === 0) return 0; + const primaryKeys = PRIMARY_KEYS[table]; + const columnList = columns.map(quoteIdent).join(", "); + const placeholders = columns.map(() => "?").join(", "); + const keyList = primaryKeys.map(quoteIdent).join(", "); + const updateColumns = columns.filter((column) => !primaryKeys.includes(column)); + const fallbackKey = primaryKeys[0]!; + const setClause = updateColumns.length > 0 + ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") + : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`; + const whereClause = updateColumns.includes("updated_at") + ? ` WHERE ${quoteIdent(table)}.${quoteIdent("updated_at")} IS NULL OR EXCLUDED.${quoteIdent("updated_at")} >= ${quoteIdent(table)}.${quoteIdent("updated_at")}` + : ""; + + for (const row of rows) { + await remote.run( + `INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) + ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`, + ...columns.map((column) => coerceForPg(table, column, row[column])), + ); + } + return rows.length; +} + +function upsertSqlite(db: Database, table: StorageTable, columns: string[], rows: Row[]): number { + if (columns.length === 0) return 0; + const primaryKeys = PRIMARY_KEYS[table]; + const columnList = columns.map(quoteIdent).join(", "); + const placeholders = columns.map(() => "?").join(", "); + const keyList = primaryKeys.map(quoteIdent).join(", "); + const updateColumns = columns.filter((column) => !primaryKeys.includes(column)); + const fallbackKey = primaryKeys[0]!; + const setClause = updateColumns.length > 0 + ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") + : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`; + const whereClause = updateColumns.includes("updated_at") + ? ` WHERE ${quoteIdent(table)}.${quoteIdent("updated_at")} IS NULL OR excluded.${quoteIdent("updated_at")} >= ${quoteIdent(table)}.${quoteIdent("updated_at")}` + : ""; + const statement = db.prepare( + `INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) + ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`, + ); + const insert = db.transaction((batch: Row[]) => { + for (const row of batch) { + statement.run(...columns.map((column) => coerceForSqlite(row[column]))); + } + }); + insert(rows); + return rows.length; +} + +function recordSyncMeta(direction: "push" | "pull", results: SyncResult[]): void { + const db = getDb(); + ensureSyncMetaTable(db); + const now = new Date().toISOString(); + for (const result of results) { + if (result.errors.length > 0) continue; + db.prepare(` + INSERT INTO _mcps_sync_meta (table_name, last_synced_at, direction) + VALUES (?, ?, ?) + ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at + `).run(result.table, now, direction); + } +} + +function ensureSyncMetaTable(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS _mcps_sync_meta ( + table_name TEXT NOT NULL, + last_synced_at TEXT, + direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')), + PRIMARY KEY (table_name, direction) + ) + `); +} + +function quoteIdent(identifier: string): string { + return `"${identifier.replace(/"/g, '""')}"`; +} + +function coerceForPg(table: StorageTable, column: string, value: unknown): unknown { + if (value === undefined) return null; + if (["servers", "sources", "machines", "provider_profiles"].includes(table) && column === "enabled") return Boolean(value); + return value; +} + +function coerceForSqlite(value: unknown): string | number | bigint | boolean | null | Uint8Array { + if (value === undefined || value === null) return null; + if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return value; + if (value instanceof Date) return value.toISOString(); + if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 12eee4b..ea2e9c8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,5 +1,7 @@ -import { registerCloudTools } from "@hasna/cloud"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { STORAGE_TABLES, getStorageStatus, storagePull, storagePush, storageSync } from "../lib/storage-sync.js"; +import { compactSyncResult } from "../lib/compact-output.js"; import { readPackageVersion } from "../lib/version.js"; import { registerMcpTools, @@ -11,7 +13,7 @@ export const VERSION = readPackageVersion(import.meta.url); export interface CreateMcpServerOptions { name?: string; version?: string; - cloudTools?: boolean; + storageTools?: boolean; tools?: McpsMcpToolDefinition[]; } @@ -23,9 +25,100 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): McpServer registerMcpTools(server, options.tools); - if (options.cloudTools !== false) { - registerCloudTools(server, "mcps"); + if (options.storageTools !== false) { + registerMcpsStorageTools(server); } return server; } + +function registerMcpsStorageTools(server: McpServer): void { + const tableSchema = z.enum(STORAGE_TABLES); + + server.tool( + "storage_status", + "Show open-mcps remote storage configuration and local sync metadata. Defaults to a compact summary.", + { verbose: z.boolean().optional().describe("Return full table and sync metadata") }, + async ({ verbose }) => { + const status = getStorageStatus(); + const payload = verbose === true + ? status + : { + configured: status.configured, + mode: status.mode, + activeEnv: status.activeEnv, + tableCount: status.tables.length, + syncMetaCount: status.sync.length, + hint: "Use storage_status({verbose:true}) for table names and sync timestamps.", + }; + return { + content: [{ + type: "text" as const, + text: JSON.stringify(payload, null, 2), + }], + }; + }, + ); + + server.tool( + "storage_push", + "Push local open-mcps tables to the configured remote Postgres storage. Defaults to compact table summaries.", + { + tables: z.array(tableSchema).optional().describe("Tables to push"), + verbose: z.boolean().optional().describe("Return full per-table sync results"), + }, + async ({ tables, verbose }) => { + const results = await storagePush({ tables }); + return { + content: [{ type: "text" as const, text: JSON.stringify(verbose === true ? results : summarizeStorageResults(results), null, 2) }], + }; + }, + ); + + server.tool( + "storage_pull", + "Pull open-mcps tables from the configured remote Postgres storage. Defaults to compact table summaries.", + { + tables: z.array(tableSchema).optional().describe("Tables to pull"), + verbose: z.boolean().optional().describe("Return full per-table sync results"), + }, + async ({ tables, verbose }) => { + const results = await storagePull({ tables }); + return { + content: [{ type: "text" as const, text: JSON.stringify(verbose === true ? results : summarizeStorageResults(results), null, 2) }], + }; + }, + ); + + server.tool( + "storage_sync", + "Push then pull open-mcps tables with the configured remote Postgres storage. Defaults to compact table summaries.", + { + tables: z.array(tableSchema).optional().describe("Tables to sync"), + verbose: z.boolean().optional().describe("Return full per-table sync results"), + }, + async ({ tables, verbose }) => { + const results = await storageSync({ tables }); + const payload = verbose === true + ? results + : { + push: summarizeStorageResults(results.push), + pull: summarizeStorageResults(results.pull), + }; + return { + content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], + }; + }, + ); +} + +function summarizeStorageResults(results: Awaited>) { + return { + items: results.map(compactSyncResult), + tableCount: results.length, + rowsRead: results.reduce((sum, result) => sum + result.rowsRead, 0), + rowsWritten: results.reduce((sum, result) => sum + result.rowsWritten, 0), + errorCount: results.reduce((sum, result) => sum + result.errors.length, 0), + hint: "Pass verbose:true for full per-table error arrays.", + }; +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index d7cf503..873b5a2 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -10,6 +10,7 @@ import { disableServer, updateServer, getCachedTools, + getToolCounts, } from "../lib/registry.js"; import { searchRegistry, installFromRegistry } from "../lib/remote.js"; import { listAwesomeServers } from "../lib/finder.js"; @@ -55,6 +56,22 @@ import { listProviderProfiles, searchProviderProfiles, } from "../lib/provider-profiles.js"; +import { + DEFAULT_LIST_LIMIT, + MAX_LIST_LIMIT, + compactCatalogEntry, + compactFinderResult, + compactFleetHealthReport, + compactFleetInstallReport, + compactMachine, + compactProviderProfile, + compactRegistryServer, + compactServer, + compactSource, + compactTool, + paginate, + type Page, +} from "../lib/compact-output.js"; const VERSION = readPackageVersion(import.meta.url); @@ -92,6 +109,18 @@ function jsonContent(value: unknown) { return textContent(JSON.stringify(value, null, 2)); } +function compactPageContent(page: Page, hint: string) { + return jsonContent({ + items: page.items, + total: page.total, + shown: page.items.length, + limit: page.limit, + cursor: page.offset, + nextCursor: page.nextCursor, + hint, + }); +} + function errorContent(text: string) { return { ...textContent(text), isError: true }; } @@ -110,19 +139,37 @@ function readCredentialRefs(input: Record): CredentialReference ); } +const listControls = { + limit: z.number().int().min(1).max(MAX_LIST_LIMIT).optional().describe(`Maximum compact items to return (default: ${DEFAULT_LIST_LIMIT})`), + cursor: z.string().optional().describe("Numeric cursor from a previous compact response"), + verbose: z.boolean().optional().describe("Return the previous full-detail JSON array instead of compact summaries"), +}; + export function buildMcpTools(): McpsMcpToolDefinition[] { const definitions: InternalMcpToolDefinition[] = [ { name: "list_servers", - description: "List all registered MCP servers", - paramsSchema: {}, - run: () => jsonContent(listServers().map(redactServerEnv)), + description: "List registered MCP servers. Defaults to compact summaries; pass verbose=true for full records.", + paramsSchema: { ...listControls }, + run: (input) => { + const servers = listServers().map(redactServerEnv); + if (input.verbose === true) return jsonContent(servers); + const toolCounts = getToolCounts(); + const compact = servers.map((server) => compactServer(server, toolCounts.get(server.id) ?? 0)); + const page = paginate(compact, { limit: input.limit, cursor: input.cursor }); + return compactPageContent(page, "Use get_server_info({id}) for one server or list_servers({verbose:true}) for full records."); + }, }, { name: "search_registry", - description: "Search the official MCP registry for servers", - paramsSchema: { query: z.string().describe("Search query") }, - run: async ({ query }) => jsonContent(await searchRegistry(String(query))), + description: "Search the official MCP registry for servers. Defaults to compact summaries; pass verbose=true for full registry records.", + paramsSchema: { query: z.string().describe("Search query"), ...listControls }, + run: async ({ query, limit, cursor, verbose }) => { + const results = await searchRegistry(String(query)); + if (verbose === true) return jsonContent(results); + const page = paginate(results.map(compactRegistryServer), { limit, cursor }); + return compactPageContent(page, "Use search_registry({query, verbose:true}) for full registry records."); + }, }, { name: "add_server", @@ -279,12 +326,18 @@ export function buildMcpTools(): McpsMcpToolDefinition[] { }, { name: "list_tools", - description: "List all cached tools across registered servers without connecting. Optionally filter by server_id.", - paramsSchema: { server_id: z.string().optional().describe("Server ID to filter by (optional)") }, - run: ({ server_id }) => { + description: "List cached tools across registered servers without connecting. Defaults to compact summaries without full input schemas.", + paramsSchema: { + server_id: z.string().optional().describe("Server ID to filter by (optional)"), + ...listControls, + }, + run: ({ server_id, limit, cursor, verbose }) => { if (typeof server_id === "string" && server_id) { const toolsForServer = getCachedTools(server_id); - return jsonContent(toolsForServer.map((tool) => ({ ...tool, server_id }))); + const full = toolsForServer.map((tool) => ({ ...tool, server_id })); + if (verbose === true) return jsonContent(full); + const page = paginate(full.map(compactTool), { limit, cursor }); + return compactPageContent(page, "Use list_tools({server_id, verbose:true}) for full input schemas."); } const allTools: Array<{ server_id: string; @@ -297,7 +350,9 @@ export function buildMcpTools(): McpsMcpToolDefinition[] { allTools.push({ server_id: server.id, ...tool }); } } - return jsonContent(allTools); + if (verbose === true) return jsonContent(allTools); + const page = paginate(allTools.map(compactTool), { limit, cursor }); + return compactPageContent(page, "Use list_tools({verbose:true}) for full input schemas or get_server_info({id}) for one server."); }, }, { @@ -317,19 +372,32 @@ export function buildMcpTools(): McpsMcpToolDefinition[] { query: z.string().describe("Search query (e.g., 'filesystem', 'postgres', 'browser')"), sources: z.array(z.string()).optional().describe("Source IDs to search (default: all enabled). Use list_sources to get IDs."), limit: z.number().optional().describe("Max results per source (default: 20)"), + cursor: z.string().optional().describe("Numeric cursor from a previous compact response"), + verbose: z.boolean().optional().describe("Return full finder records instead of compact summaries"), + }, + run: async ({ query, sources, limit, cursor, verbose }) => { + const results = await findServers(String(query), { + sources: Array.isArray(sources) ? sources.map(String) : undefined, + limit: typeof limit === "number" ? limit : undefined, + }); + if (verbose === true) return jsonContent(results); + const page = paginate(results.map(compactFinderResult), { limit, cursor }); + return compactPageContent(page, "Use find_mcp_servers({query, verbose:true}) for full result records."); }, - run: async ({ query, sources, limit }) => jsonContent(await findServers(String(query), { - sources: Array.isArray(sources) ? sources.map(String) : undefined, - limit: typeof limit === "number" ? limit : undefined, - })), }, { name: "list_provider_profiles", - description: "List curated provider profiles for hosted/common MCP integrations such as GitHub, Slack, Google Workspace, Stripe, Cloudflare, Postgres, filesystem, and browser automation.", + description: "List curated provider profiles for hosted/common MCP integrations. Defaults to compact summaries.", paramsSchema: { enabled_only: z.boolean().optional().describe("Only include enabled provider profiles"), + ...listControls, + }, + run: ({ enabled_only, limit, cursor, verbose }) => { + const profiles = listProviderProfiles({ enabledOnly: enabled_only === true }); + if (verbose === true) return jsonContent(profiles); + const page = paginate(profiles.map(compactProviderProfile), { limit, cursor }); + return compactPageContent(page, "Use get_provider_profile({id}) for one profile or list_provider_profiles({verbose:true}) for full records."); }, - run: ({ enabled_only }) => jsonContent(listProviderProfiles({ enabledOnly: enabled_only === true })), }, { name: "search_provider_profiles", @@ -337,8 +405,14 @@ export function buildMcpTools(): McpsMcpToolDefinition[] { paramsSchema: { query: z.string().describe("Search query such as 'github', 'slack', 'postgres', or an endpoint URL"), enabled_only: z.boolean().optional().describe("Only include enabled provider profiles"), + ...listControls, + }, + run: ({ query, enabled_only, limit, cursor, verbose }) => { + const profiles = searchProviderProfiles(String(query), { enabledOnly: enabled_only === true }); + if (verbose === true) return jsonContent(profiles); + const page = paginate(profiles.map(compactProviderProfile), { limit, cursor }); + return compactPageContent(page, "Use get_provider_profile({id}) for one profile or search_provider_profiles({query, verbose:true}) for full records."); }, - run: ({ query, enabled_only }) => jsonContent(searchProviderProfiles(String(query), { enabledOnly: enabled_only === true })), }, { name: "get_provider_profile", @@ -376,9 +450,14 @@ export function buildMcpTools(): McpsMcpToolDefinition[] { }, { name: "list_sources", - description: "List all configured search sources for finding MCP servers", - paramsSchema: {}, - run: () => jsonContent(listSources()), + description: "List configured search sources for finding MCP servers. Defaults to compact summaries without URLs.", + paramsSchema: { ...listControls }, + run: ({ limit, cursor, verbose }) => { + const sources = listSources(); + if (verbose === true) return jsonContent(sources); + const page = paginate(sources.map(compactSource), { limit, cursor }); + return compactPageContent(page, "Use list_sources({verbose:true}) for URLs and descriptions."); + }, }, { name: "add_source", @@ -453,16 +532,22 @@ export function buildMcpTools(): McpsMcpToolDefinition[] { }, { name: "list_awesome_servers", - description: "List all MCP servers from the curated punkpeye/awesome-mcp-servers GitHub list", - paramsSchema: {}, - run: async () => jsonContent(await listAwesomeServers()), + description: "List MCP servers from the curated punkpeye/awesome-mcp-servers GitHub list. Defaults to compact paged summaries.", + paramsSchema: { ...listControls }, + run: async ({ limit, cursor, verbose }) => { + const results = await listAwesomeServers(); + if (verbose === true) return jsonContent(results); + const page = paginate(results.map(compactFinderResult), { limit, cursor }); + return compactPageContent(page, "Use list_awesome_servers({verbose:true}) for full records or pass nextCursor for more."); + }, }, { name: "connect_and_list_tools", - description: "Connect to all enabled MCP servers and list their available tools", + description: "Connect to all enabled MCP servers and list available tools. Defaults to compact summaries without full input schemas.", paramsSchema: { allow_local_stdio: z.boolean().optional().describe("Approve launching enabled local stdio commands"), allow_risky_command: z.boolean().optional().describe("Approve launching risky local command patterns"), + ...listControls, }, run: async (input) => { let liveTools = []; @@ -472,7 +557,9 @@ export function buildMcpTools(): McpsMcpToolDefinition[] { } finally { await disconnectAll().catch(() => undefined); } - return jsonContent(liveTools); + if (input.verbose === true) return jsonContent(liveTools); + const page = paginate(liveTools.map(compactTool), { limit: input.limit, cursor: input.cursor }); + return compactPageContent(page, "Use connect_and_list_tools({verbose:true}) for full live tool schemas."); }, }, { @@ -518,11 +605,17 @@ export function buildMcpTools(): McpsMcpToolDefinition[] { }, { name: "list_machines", - description: "List registered fleet machines", + description: "List registered fleet machines. Defaults to compact summaries without SSH targets.", paramsSchema: { enabled_only: z.boolean().optional().describe("When true, only return enabled machines"), + ...listControls, + }, + run: ({ enabled_only, limit, cursor, verbose }) => { + const machines = listMachines().filter((machine) => (enabled_only === true ? machine.enabled : true)); + if (verbose === true) return jsonContent(machines); + const page = paginate(machines.map(compactMachine), { limit, cursor }); + return compactPageContent(page, "Use list_machines({verbose:true}) for SSH targets and last errors."); }, - run: ({ enabled_only }) => jsonContent(listMachines().filter((machine) => (enabled_only === true ? machine.enabled : true))), }, { name: "add_machine", @@ -576,38 +669,50 @@ export function buildMcpTools(): McpsMcpToolDefinition[] { }, { name: "list_hasna_mcp_catalog", - description: "List the discovered @hasna MCP package catalog", + description: "List the discovered @hasna MCP package catalog. Defaults to compact summaries.", paramsSchema: { packages: z.array(z.string()).optional().describe("Optional package-name filter"), refresh: z.boolean().optional().describe("Refresh npm metadata instead of using cache"), + ...listControls, }, - run: async ({ packages, refresh }) => { + run: async ({ packages, refresh, limit, cursor, verbose }) => { const catalog = await listHasnaMcpCatalog({ refresh: refresh === true }); const filtered = Array.isArray(packages) && packages.length > 0 ? catalog.filter((entry) => packages.map(String).includes(entry.name)) : catalog; - return jsonContent(filtered); + if (verbose === true) return jsonContent(filtered); + const page = paginate(filtered.map(compactCatalogEntry), { limit, cursor }); + return compactPageContent(page, "Use list_hasna_mcp_catalog({verbose:true}) for repository, keywords, and bin maps."); }, }, { name: "fleet_health", - description: "Run fleet-wide MCP health checks across registered machines", + description: "Run fleet-wide MCP health checks across registered machines. Defaults to compact machine summaries.", paramsSchema: { machine_ids: z.array(z.string()).optional().describe("Optional machine IDs to check"), packages: z.array(z.string()).optional().describe("Optional @hasna package-name filter"), refresh_catalog: z.boolean().optional().describe("Refresh npm metadata before checking"), timeout_ms: z.number().int().min(1000).optional().describe("Remote timeout in milliseconds"), + verbose: z.boolean().optional().describe("Return full per-package health reports"), + }, + run: async ({ machine_ids, packages, refresh_catalog, timeout_ms, verbose }) => { + const reports = await runFleetHealthCheck({ + machineIds: Array.isArray(machine_ids) ? machine_ids.map(String) : undefined, + packages: Array.isArray(packages) ? packages.map(String) : undefined, + refreshCatalog: refresh_catalog === true, + timeoutMs: typeof timeout_ms === "number" ? timeout_ms : undefined, + }); + if (verbose === true) return jsonContent(reports); + return jsonContent({ + items: reports.map(compactFleetHealthReport), + total: reports.length, + hint: "Use fleet_health({verbose:true}) for per-package health details.", + }); }, - run: async ({ machine_ids, packages, refresh_catalog, timeout_ms }) => jsonContent(await runFleetHealthCheck({ - machineIds: Array.isArray(machine_ids) ? machine_ids.map(String) : undefined, - packages: Array.isArray(packages) ? packages.map(String) : undefined, - refreshCatalog: refresh_catalog === true, - timeoutMs: typeof timeout_ms === "number" ? timeout_ms : undefined, - })), }, { name: "fleet_install", - description: "Batch-install missing or outdated @hasna MCP packages across machines", + description: "Batch-install missing or outdated @hasna MCP packages across machines. Defaults to compact machine summaries.", paramsSchema: { machine_ids: z.array(z.string()).optional().describe("Optional machine IDs to target"), packages: z.array(z.string()).optional().describe("Optional @hasna package-name filter"), @@ -615,15 +720,24 @@ export function buildMcpTools(): McpsMcpToolDefinition[] { installer: z.enum(["auto", "bun", "npm"]).optional().describe("Override installer"), refresh_catalog: z.boolean().optional().describe("Refresh npm metadata before installing"), timeout_ms: z.number().int().min(1000).optional().describe("Remote timeout in milliseconds"), + verbose: z.boolean().optional().describe("Return full per-package install reports"), + }, + run: async ({ machine_ids, packages, mode, installer, refresh_catalog, timeout_ms, verbose }) => { + const reports = await runFleetInstall({ + machineIds: Array.isArray(machine_ids) ? machine_ids.map(String) : undefined, + packages: Array.isArray(packages) ? packages.map(String) : undefined, + mode: mode === "missing" || mode === "missing-or-outdated" || mode === "all" ? mode : undefined, + installer: installer === "auto" || installer === "bun" || installer === "npm" ? installer : undefined, + refreshCatalog: refresh_catalog === true, + timeoutMs: typeof timeout_ms === "number" ? timeout_ms : undefined, + }); + if (verbose === true) return jsonContent(reports); + return jsonContent({ + items: reports.map(compactFleetInstallReport), + total: reports.length, + hint: "Use fleet_install({verbose:true}) for per-package install details and command output.", + }); }, - run: async ({ machine_ids, packages, mode, installer, refresh_catalog, timeout_ms }) => jsonContent(await runFleetInstall({ - machineIds: Array.isArray(machine_ids) ? machine_ids.map(String) : undefined, - packages: Array.isArray(packages) ? packages.map(String) : undefined, - mode: mode === "missing" || mode === "missing-or-outdated" || mode === "all" ? mode : undefined, - installer: installer === "auto" || installer === "bun" || installer === "npm" ? installer : undefined, - refreshCatalog: refresh_catalog === true, - timeoutMs: typeof timeout_ms === "number" ? timeout_ms : undefined, - })), }, { name: "send_feedback", @@ -700,9 +814,21 @@ export function buildMcpTools(): McpsMcpToolDefinition[] { }, { name: "list_agents", - description: "List all registered agents.", - paramsSchema: {}, - run: () => jsonContent([...mcpsAgents.values()]), + description: "List registered agents. Defaults to compact paged summaries.", + paramsSchema: { ...listControls }, + run: ({ limit, cursor, verbose }) => { + const agents = [...mcpsAgents.values()]; + if (verbose === true) return jsonContent(agents); + const compact = agents.map((agent) => ({ + id: agent.id, + name: agent.name, + project_id: agent.project_id ?? null, + last_seen_at: agent.last_seen_at, + hasSession: Boolean(agent.session_id), + })); + const page = paginate(compact, { limit, cursor }); + return compactPageContent(page, "Use list_agents({verbose:true}) for full session metadata."); + }, }, ]; diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..f03d5af --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,21 @@ +export { PG_MIGRATIONS } from "./lib/pg-migrations.js"; +export { PgAdapterAsync } from "./lib/remote-storage.js"; +export { + STORAGE_TABLES, + getStorageDatabaseEnv, + getStorageDatabaseUrl, + getStorageMode, + getStoragePg, + getStorageStatus, + getSyncMetaAll, + parseStorageTables, + resolveTables, + runStorageMigrations, + storagePull, + storagePush, + storageSync, + type StorageEnv, + type StorageMode, + type SyncMeta, + type SyncResult, +} from "./lib/storage-sync.js"; diff --git a/tests/cli-compact-output.test.ts b/tests/cli-compact-output.test.ts new file mode 100644 index 0000000..dad37a2 --- /dev/null +++ b/tests/cli-compact-output.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +function runCli(args: string[], dataDir = mkdtempSync(join(tmpdir(), "mcps-cli-compact-"))) { + const result = Bun.spawnSync({ + cmd: ["bun", "run", "src/cli/index.tsx", ...args], + cwd: process.cwd(), + env: { + ...process.env, + HASNA_MCPS_DATA_DIR: dataDir, + NO_COLOR: "1", + }, + stdout: "pipe", + stderr: "pipe", + }); + + return { + ...result, + dataDir, + stdoutText: new TextDecoder().decode(result.stdout), + stderrText: new TextDecoder().decode(result.stderr), + }; +} + +describe("compact CLI output", () => { + it("keeps list compact by default and discloses details explicitly", () => { + const dataDir = mkdtempSync(join(tmpdir(), "mcps-cli-compact-")); + const add = runCli([ + "add", + "--yes", + "--name", + "Noisy Server", + "--description", + "This description is intentionally long enough to exercise compact truncation in the default server list output.", + "npx", + "-y", + "@example/noisy-mcp", + ], dataDir); + expect(add.exitCode).toBe(0); + + const compact = runCli(["list"], dataDir); + expect(compact.exitCode).toBe(0); + expect(compact.stdoutText).toContain("Noisy Server"); + expect(compact.stdoutText).not.toContain("npx -y @example/noisy-mcp"); + expect(compact.stdoutText).toContain("Use --verbose"); + + const verbose = runCli(["list", "--verbose"], dataDir); + expect(verbose.exitCode).toBe(0); + expect(verbose.stdoutText).toContain("Command:"); + expect(verbose.stdoutText).toContain("npx -y @example/noisy-mcp"); + + const json = runCli(["list", "--json"], dataDir); + expect(json.exitCode).toBe(0); + const servers = JSON.parse(json.stdoutText); + expect(servers[0].args).toEqual(["-y", "@example/noisy-mcp"]); + }); +}); diff --git a/tests/cli-storage.test.ts b/tests/cli-storage.test.ts new file mode 100644 index 0000000..2f52837 --- /dev/null +++ b/tests/cli-storage.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +function runCli(args: string[], dataDir = mkdtempSync(join(tmpdir(), "mcps-cli-storage-"))) { + const result = Bun.spawnSync({ + cmd: ["bun", "run", "src/cli/index.tsx", ...args], + cwd: process.cwd(), + env: { + ...process.env, + HASNA_MCPS_DATA_DIR: dataDir, + NO_COLOR: "1", + }, + stdout: "pipe", + stderr: "pipe", + }); + + return { + ...result, + dataDir, + stdoutText: new TextDecoder().decode(result.stdout), + stderrText: new TextDecoder().decode(result.stderr), + }; +} + +describe("mcps storage command", () => { + it("advertises storage without a legacy cloud command", () => { + const result = runCli(["--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdoutText).toContain("storage"); + expect(result.stdoutText).not.toMatch(/\n\s+cloud(?:\s|$)/); + }); + + it("reports local storage status with canonical env names", () => { + const compact = runCli(["storage", "status"]); + expect(compact.exitCode).toBe(0); + expect(compact.stdoutText).toContain("Storage Status"); + expect(compact.stdoutText).toContain("Mode:"); + expect(compact.stdoutText).toContain("Use --verbose or --json"); + + const result = runCli(["storage", "status", "--json"]); + const status = JSON.parse(result.stdoutText) as { + configured: boolean; + mode: string; + env: string[]; + deprecatedEnv: string[]; + tables: string[]; + }; + + expect(result.exitCode).toBe(0); + expect(status.configured).toBe(false); + expect(status.mode).toBe("local"); + expect(status.env).toEqual(["HASNA_MCPS_DATABASE_URL", "MCPS_DATABASE_URL"]); + expect(status.deprecatedEnv).toEqual([]); + expect(status.tables).toContain("servers"); + expect(status.tables).toContain("tool_cache"); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 6ac4c9f..5f86ce1 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -38,6 +38,16 @@ describe("index exports", () => { expect(typeof api.closeDb).toBe("function"); }); + it("exports storage sync functions", () => { + expect(Array.isArray(api.STORAGE_TABLES)).toBe(true); + expect(typeof api.getStorageDatabaseUrl).toBe("function"); + expect(typeof api.getStorageMode).toBe("function"); + expect(typeof api.getStorageStatus).toBe("function"); + expect(typeof api.storagePush).toBe("function"); + expect(typeof api.storagePull).toBe("function"); + expect(typeof api.storageSync).toBe("function"); + }); + it("exports machine registry functions", () => { expect(typeof api.addMachine).toBe("function"); expect(typeof api.upsertMachine).toBe("function"); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index c2b926b..d73829a 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -3,7 +3,7 @@ import "./setup"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { readFileSync } from "fs"; -import { addServer, getServer } from "../src/lib/registry"; +import { addServer, cacheTools, getServer } from "../src/lib/registry"; import { getDb, closeDb } from "../src/lib/db"; import { DEFAULT_PROVIDER_PROFILE_SEEDS } from "../src/lib/provider-profile-seeds"; import { createMcpServer, listTools, tools } from "../src/mcp/index"; @@ -15,7 +15,16 @@ function clearDb() { } async function createClientServer() { - const server = createMcpServer({ name: "mcps-test", version: "0.0.1", cloudTools: false }); + const server = createMcpServer({ name: "mcps-test", version: "0.0.1", storageTools: false }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "0.0.1" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { client, server }; +} + +async function createClientServerWithStorage() { + const server = createMcpServer({ name: "mcps-test", version: "0.0.1" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "test-client", version: "0.0.1" }); await server.connect(serverTransport); @@ -36,7 +45,12 @@ describe("MCP server tools", () => { const { client } = await createClientServer(); const result = await client.callTool({ name: "list_servers", arguments: {} }); const content = result.content as Array<{ type: string; text: string }>; - expect(JSON.parse(content[0].text)).toEqual([]); + expect(JSON.parse(content[0].text)).toMatchObject({ + items: [], + total: 0, + shown: 0, + nextCursor: null, + }); await client.close(); }); @@ -62,8 +76,20 @@ describe("MCP server tools", () => { // List const listResult = await client.callTool({ name: "list_servers", arguments: {} }); const servers = JSON.parse((listResult.content as any)[0].text); - expect(servers).toHaveLength(1); - expect(servers[0].name).toBe("TestMCP"); + expect(servers.total).toBe(1); + expect(servers.items[0]).toMatchObject({ + id: "testmcp", + name: "TestMCP", + enabled: true, + transport: "stdio", + }); + expect(servers.items[0].command).toBeUndefined(); + expect(servers.hint).toContain("get_server_info"); + + const verboseListResult = await client.callTool({ name: "list_servers", arguments: { verbose: true } }); + const verboseServers = JSON.parse((verboseListResult.content as any)[0].text); + expect(verboseServers).toHaveLength(1); + expect(verboseServers[0].name).toBe("TestMCP"); await client.close(); }); @@ -103,7 +129,7 @@ describe("MCP server tools", () => { required: true, }); - const listResult = await client.callTool({ name: "list_servers", arguments: {} }); + const listResult = await client.callTool({ name: "list_servers", arguments: { verbose: true } }); const listed = JSON.parse((listResult.content as any)[0].text); expect(listed[0].env).toEqual({}); expect(JSON.stringify(listed)).not.toContain("sk_live_should_not_be_stored"); @@ -167,22 +193,63 @@ describe("MCP server tools", () => { await client.close(); }); + it("list_tools is compact by default and verbose returns full schemas", async () => { + const { client } = await createClientServer(); + + addServer({ command: "npx", name: "toolhost" }); + cacheTools("toolhost", [ + { + name: "noisy_tool", + description: "A tool with a large input schema that should not be dumped by default", + input_schema: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + limit: { type: "number" }, + }, + required: ["query"], + }, + }, + ]); + + const compactResult = await client.callTool({ name: "list_tools", arguments: {} }); + const compact = JSON.parse((compactResult.content as any)[0].text); + expect(compact.total).toBe(1); + expect(compact.items[0].inputSchema).toMatchObject({ + propertyCount: 2, + requiredCount: 1, + }); + expect(compact.items[0].input_schema).toBeUndefined(); + + const verboseResult = await client.callTool({ name: "list_tools", arguments: { verbose: true } }); + const verbose = JSON.parse((verboseResult.content as any)[0].text); + expect(verbose[0].input_schema.properties.query.description).toBe("Search query"); + + await client.close(); + }); + it("lists, searches, inspects, and installs provider profiles", async () => { const { client } = await createClientServer(); const listResult = await client.callTool({ name: "list_provider_profiles", arguments: {} }); const profiles = JSON.parse((listResult.content as any)[0].text); + expect(profiles.total).toBeGreaterThan(1); + expect(profiles.items[0].endpoint).toBeUndefined(); + + const verboseListResult = await client.callTool({ name: "list_provider_profiles", arguments: { verbose: true } }); + const verboseProfiles = JSON.parse((verboseListResult.content as any)[0].text); const expectedIds = [...DEFAULT_PROVIDER_PROFILE_SEEDS] .sort((left, right) => left.displayName.localeCompare(right.displayName)) .map((profile) => profile.id); - expect(profiles.map((profile: { id: string }) => profile.id)).toEqual(expectedIds); - expect(profiles.map((profile: { id: string }) => profile.id)).toContain("stripe"); - expect(profiles.map((profile: { id: string }) => profile.id)).toContain("cloudflare"); + expect(verboseProfiles.map((profile: { id: string }) => profile.id)).toEqual(expectedIds); + expect(verboseProfiles.map((profile: { id: string }) => profile.id)).toContain("stripe"); + expect(verboseProfiles.map((profile: { id: string }) => profile.id)).toContain("cloudflare"); const searchResult = await client.callTool({ name: "search_provider_profiles", arguments: { query: "notion" } }); const searchProfiles = JSON.parse((searchResult.content as any)[0].text); - expect(searchProfiles).toHaveLength(1); - expect(searchProfiles[0].endpoint).toBe("https://mcp.notion.com/mcp"); + expect(searchProfiles.total).toBe(1); + expect(searchProfiles.items[0].id).toBe("notion"); + expect(searchProfiles.items[0].endpoint).toBeUndefined(); const infoResult = await client.callTool({ name: "get_provider_profile", arguments: { id: "linear" } }); const linear = JSON.parse((infoResult.content as any)[0].text); @@ -211,6 +278,23 @@ describe("MCP server tools", () => { await client.close(); }); + it("lists storage sync tools by default", async () => { + const { client } = await createClientServerWithStorage(); + const result = await client.listTools(); + const toolNames = result.tools.map((t) => t.name); + + expect(toolNames).toContain("storage_status"); + expect(toolNames).toContain("storage_push"); + expect(toolNames).toContain("storage_pull"); + expect(toolNames).toContain("storage_sync"); + expect(toolNames).not.toContain(["cloud", "status"].join("_")); + expect(toolNames).not.toContain(["cloud", "push"].join("_")); + expect(toolNames).not.toContain(["cloud", "pull"].join("_")); + expect(toolNames).not.toContain(["cloud", "sync"].join("_")); + + await client.close(); + }); + it("exports package-mode tool definitions without starting stdio", async () => { const listed = await listTools(); const toolNames = listed.map((tool) => tool.name); diff --git a/tests/storage-sync.test.ts b/tests/storage-sync.test.ts new file mode 100644 index 0000000..07a7d6c --- /dev/null +++ b/tests/storage-sync.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import "./setup"; +import { + getStorageDatabaseEnv, + getStorageDatabaseUrl, + getStorageMode, + parseStorageTables, +} from "../src/lib/storage-sync"; + +const ENV_NAMES = [ + "HASNA_MCPS_DATABASE_URL", + "MCPS_DATABASE_URL", + "HASNA_MCPS_STORAGE_MODE", + "MCPS_STORAGE_MODE", +] as const; + +const ORIGINAL_ENV = new Map( + ENV_NAMES.map((name) => [name, process.env[name]]), +); + +describe("mcps storage sync configuration", () => { + beforeEach(() => { + for (const name of ENV_NAMES) delete process.env[name]; + }); + + afterEach(() => { + for (const name of ENV_NAMES) { + const value = ORIGINAL_ENV.get(name); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + it("reads canonical storage database envs", () => { + process.env["HASNA_MCPS_DATABASE_URL"] = "postgres://canonical"; + + expect(getStorageDatabaseUrl()).toBe("postgres://canonical"); + expect(getStorageDatabaseEnv()).toEqual({ + name: "HASNA_MCPS_DATABASE_URL", + deprecated: false, + }); + }); + + it("resolves local, hybrid, and remote storage modes", () => { + expect(getStorageMode()).toBe("local"); + + process.env["MCPS_DATABASE_URL"] = "postgres://remote"; + expect(getStorageMode()).toBe("hybrid"); + + process.env["HASNA_MCPS_STORAGE_MODE"] = "remote"; + expect(getStorageMode()).toBe("remote"); + }); + + it("parses and validates storage table filters", () => { + expect(parseStorageTables()).toContain("servers"); + expect(parseStorageTables([" servers ", "tool_cache"])).toEqual(["servers", "tool_cache"]); + expect(() => parseStorageTables(["missing"])).toThrow("Unknown mcps sync table"); + }); + + it("exports storage helpers from the storage subpath source", async () => { + const storage = await import("../src/storage.js"); + + expect(storage.STORAGE_TABLES).toContain("servers"); + expect(storage.getStorageDatabaseUrl()).toBeNull(); + expect(storage.getStorageMode()).toBe("local"); + expect(storage.PG_MIGRATIONS.length).toBeGreaterThan(0); + expect(typeof storage.PgAdapterAsync).toBe("function"); + }); +});