diff --git a/README.md b/README.md index 9491c55..f117669 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![MCP Manifest](https://mcp-manifest.dev/media/mcp-manifest-badge-light.svg)](https://mcp-manifest.dev) -Validate `mcp-manifest.json` files and test autodiscovery from domains. +Validate `mcp-manifest.json` files, test autodiscovery from domains, and generate manifests from existing MCP server configs. -## Usage +## Validate ```bash # Validate a local manifest @@ -76,6 +76,41 @@ const { manifest, source, errors } = await discover('ironlicensing.com'); const exists = await checkCommand('ironlicensing-mcp'); ``` +## Generate + +Create `mcp-manifest.json` from your existing Claude Code MCP server configs: + +```bash +# List all configured MCP servers +npx mcp-manifest-generate + +# Generate manifest for a specific server +npx mcp-manifest-generate --server ironlicensing + +# Use a specific settings file +npx mcp-manifest-generate --from-settings ~/.claude/settings.json --server myserver + +# Probe the server for name/version via MCP handshake +npx mcp-manifest-generate --server ironlicensing --probe + +# Output to file +npx mcp-manifest-generate --server ironlicensing -o mcp-manifest.json + +# Generate for all servers +npx mcp-manifest-generate --all + +# Raw JSON (for piping) +npx mcp-manifest-generate --server ironlicensing --json +``` + +The generator reverse-engineers your existing config: +- **`--flag value`** args become typed config entries (`--api-key` → type: `secret`) +- **Environment variables** become config entries with `env_var` field +- **Command patterns** infer install method (`npx` → npm, `uvx` → pip, `.exe` → dotnet-tool) +- **`--probe`** runs an MCP initialize handshake to get the real server name and version + +Review the generated manifest, fill in the `TODO` description, and commit. + ## License CC0 1.0 — Public domain. diff --git a/bin/generate.js b/bin/generate.js new file mode 100644 index 0000000..c1271c8 --- /dev/null +++ b/bin/generate.js @@ -0,0 +1,273 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { readSettings, generateManifest } from '../src/generate.js'; + +const RESET = '\x1b[0m'; +const BOLD = '\x1b[1m'; +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const CYAN = '\x1b[36m'; +const DIM = '\x1b[2m'; + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(` +${BOLD}mcp-manifest-generate${RESET} — Generate mcp-manifest.json from existing MCP server configs + +${BOLD}Usage:${RESET} + mcp-manifest-generate List servers in default settings + mcp-manifest-generate --server Generate manifest for a specific server + mcp-manifest-generate --init Interactive wizard (from scratch) + mcp-manifest-generate --from-settings Use a specific settings file + mcp-manifest-generate --server --probe Also run MCP handshake for server info + mcp-manifest-generate --server -o Write manifest to file + mcp-manifest-generate --all Generate manifests for all servers + +${BOLD}Examples:${RESET} + mcp-manifest-generate --init + mcp-manifest-generate --server ironlicensing + mcp-manifest-generate --from-settings ~/.claude/settings.json --server gitcaddy --probe + mcp-manifest-generate --server ironlicensing -o mcp-manifest.json + mcp-manifest-generate --all -o manifests/ + +${BOLD}Options:${RESET} + --init Interactive wizard to create a manifest from scratch + --from-settings Path to settings.json (default: ~/.claude/settings.json) + --server Server name to generate manifest for + --all Generate manifests for all servers + --probe Run MCP initialize handshake for server name/version + -o, --output Output file or directory + --json Output raw JSON (no colors/decoration) + --help Show this help +`); + process.exit(0); +} + +function getArg(name) { + const idx = args.indexOf(name); + return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : null; +} + +const settingsPath = getArg('--from-settings') + || join(homedir(), '.claude', 'settings.json'); +const serverName = getArg('--server'); +const outputPath = getArg('-o') || getArg('--output'); +const doProbe = args.includes('--probe'); +const doAll = args.includes('--all'); +const doInit = args.includes('--init'); +const jsonOnly = args.includes('--json'); + +async function run() { + // Init wizard mode + if (doInit) { + await runInitWizard(); + return; + } + + // Load settings + if (!existsSync(settingsPath)) { + console.error(`${RED}Settings file not found: ${settingsPath}${RESET}`); + console.error(`${DIM}Default location: ~/.claude/settings.json${RESET}`); + process.exit(1); + } + + let servers; + try { + servers = readSettings(settingsPath); + } catch (e) { + console.error(`${RED}Failed to read settings: ${e.message}${RESET}`); + process.exit(1); + } + + const serverNames = Object.keys(servers); + if (serverNames.length === 0) { + console.error(`${YELLOW}No MCP servers found in ${settingsPath}${RESET}`); + process.exit(1); + } + + // List mode — no --server or --all specified + if (!serverName && !doAll) { + console.log(`\n${BOLD}MCP servers in ${DIM}${settingsPath}${RESET}\n`); + for (const name of serverNames) { + const entry = servers[name]; + const cmd = entry.command || '(no command)'; + const argCount = (entry.args || []).length; + const envCount = Object.keys(entry.env || {}).length; + console.log(` ${CYAN}${name}${RESET}`); + console.log(` command: ${cmd}`); + if (argCount > 0) console.log(` args: ${argCount} argument(s)`); + if (envCount > 0) console.log(` env: ${envCount} variable(s)`); + console.log(''); + } + console.log(`${DIM}Use --server to generate a manifest, or --all for all servers${RESET}\n`); + process.exit(0); + } + + // Generate for specific server + if (serverName) { + if (!servers[serverName]) { + console.error(`${RED}Server "${serverName}" not found. Available: ${serverNames.join(', ')}${RESET}`); + process.exit(1); + } + + const manifest = await generateManifest(serverName, servers[serverName], { probe: doProbe }); + outputManifest(manifest, serverName, outputPath, jsonOnly); + return; + } + + // Generate for all servers + if (doAll) { + for (const name of serverNames) { + if (!jsonOnly) console.log(`\n${BOLD}Generating: ${CYAN}${name}${RESET}`); + const manifest = await generateManifest(name, servers[name], { probe: doProbe }); + const outPath = outputPath ? join(outputPath, `${name}.mcp-manifest.json`) : null; + outputManifest(manifest, name, outPath, jsonOnly); + } + return; + } +} + +function outputManifest(manifest, name, outPath, jsonOnly) { + const json = JSON.stringify(manifest, null, 2); + + if (outPath) { + // Ensure directory exists for --all mode + const dir = outPath.endsWith('.json') ? undefined : outPath; + if (dir) { + try { mkdirSync(dir, { recursive: true }); } catch {} + } + const filePath = outPath.endsWith('.json') ? outPath : join(outPath, `${name}.mcp-manifest.json`); + writeFileSync(filePath, json + '\n'); + if (!jsonOnly) console.log(`${GREEN}✓${RESET} Written to ${filePath}`); + return; + } + + if (jsonOnly) { + console.log(json); + return; + } + + console.log(`\n${BOLD}Generated mcp-manifest.json for "${name}":${RESET}\n`); + // Colorize JSON output + const colorized = json + .replace(/"([^"]+)":/g, `"${CYAN}$1${RESET}":`) + .replace(/: "([^"]+)"/g, `: "${GREEN}$1${RESET}"`) + .replace(/: (\d+)/g, `: ${YELLOW}$1${RESET}`); + console.log(colorized); + console.log(`\n${DIM}Review and edit the TODO fields, then save as mcp-manifest.json${RESET}`); + console.log(`${DIM}Validate with: mcp-manifest-validate ./mcp-manifest.json${RESET}\n`); +} + + +async function runInitWizard() { + const { createInterface } = await import('readline'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q, def) => new Promise(resolve => { + const prompt = def ? `${q} ${DIM}(${def})${RESET}: ` : `${q}: `; + rl.question(prompt, answer => resolve(answer.trim() || def || '')); + }); + + console.log(`\n${BOLD}MCP Manifest Generator${RESET}`); + console.log(`${DIM}Create a new mcp-manifest.json interactively\n${RESET}`); + + // Server info + console.log(`${BOLD}Server Info${RESET}`); + const name = await ask(' Server name (lowercase, hyphens)', ''); + const displayName = await ask(' Display name', name.split('-').map(w => w[0]?.toUpperCase() + w.slice(1)).join(' ')); + const description = await ask(' Description', ''); + const version = await ask(' Version', '1.0.0'); + const author = await ask(' Author/organization', ''); + + // Install + console.log(`\n${BOLD}Installation${RESET}`); + console.log(` ${DIM}Methods: npm, pip, dotnet-tool, cargo, binary, docker${RESET}`); + const method = await ask(' Install method', 'npm'); + const pkg = await ask(' Package name', name); + const command = await ask(' Command after install', name); + const source = await ask(' Custom registry URL (optional)', ''); + + // Transport + console.log(`\n${BOLD}Transport${RESET}`); + console.log(` ${DIM}Options: stdio, sse, streamable-http${RESET}`); + const transport = await ask(' Transport', 'stdio'); + let endpoint = ''; + if (transport !== 'stdio') { + endpoint = await ask(' Endpoint URL', ''); + } + + // Config + console.log(`\n${BOLD}Configuration${RESET}`); + console.log(` ${DIM}Add config parameters your server needs. Empty name to finish.${RESET}`); + const config = []; + while (true) { + const key = await ask(` Config key (empty to finish)`, ''); + if (!key) break; + console.log(` ${DIM}Types: string, secret, url, path, boolean, number${RESET}`); + const type = await ask(` Type`, 'string'); + const configDesc = await ask(` Description`, ''); + const required = (await ask(` Required? (y/n)`, 'n')).toLowerCase() === 'y'; + const envVar = await ask(` Environment variable (optional)`, ''); + const arg = await ask(` CLI argument (optional, e.g. --api-key)`, ''); + const promptText = await ask(` User prompt text`, configDesc || key); + + const entry = { key, description: configDesc, type, required, prompt: promptText }; + if (envVar) entry.env_var = envVar; + if (arg) entry.arg = arg; + config.push(entry); + } + + // Scope + console.log(`\n${BOLD}Scope${RESET}`); + const scope = await ask(' Scope (global, project, both)', 'global'); + + rl.close(); + + // Build manifest + const manifest = { + $schema: 'https://mcp-manifest.dev/schema/v0.1.json', + version: '0.1', + server: { + name, + displayName, + description, + version, + ...(author && { author }) + }, + install: [{ + method, + package: pkg, + command, + ...(source && { source }), + priority: 0 + }], + transport, + ...(endpoint && { endpoint }), + ...(config.length > 0 && { config }), + scopes: [scope], + settings_template: { + command, + args: config.filter(c => c.arg).flatMap(c => [c.arg, `\${${c.key}}`]) + } + }; + + const json = JSON.stringify(manifest, null, 2); + + if (outputPath) { + writeFileSync(outputPath, json + '\n'); + console.log(`\n${GREEN}✓${RESET} Written to ${outputPath}`); + } else { + console.log(`\n${BOLD}Generated mcp-manifest.json:${RESET}\n`); + console.log(json); + console.log(`\n${DIM}Save as mcp-manifest.json and validate with: mcp-manifest-validate ./mcp-manifest.json${RESET}\n`); + } +} + +run().catch(err => { + console.error(`${RED}Fatal: ${err.message}${RESET}`); + process.exit(2); +}); diff --git a/package.json b/package.json index 5827354..4d44b41 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "description": "Validate mcp-manifest.json files and test autodiscovery from domains", "type": "module", "bin": { - "mcp-manifest-validate": "./bin/cli.js" + "mcp-manifest-validate": "./bin/cli.js", + "mcp-manifest-generate": "./bin/generate.js" }, "main": "./src/index.js", "exports": { - ".": "./src/index.js" + ".": "./src/index.js", + "./generate": "./src/generate.js" }, "files": [ "bin/", diff --git a/schema/v0.1.json b/schema/v0.1.json index 69cbf48..6fdf2cb 100644 --- a/schema/v0.1.json +++ b/schema/v0.1.json @@ -55,6 +55,11 @@ "type": "string", "description": "SPDX license identifier" }, + "icon": { + "type": "string", + "format": "uri", + "description": "URL to the server's icon (square, PNG or SVG recommended). Clients SHOULD fall back to a default MCP icon when absent." + }, "keywords": { "type": "array", "items": { "type": "string" }, @@ -146,6 +151,27 @@ "prompt": { "type": "string", "description": "Human-readable prompt for interactive setup" + }, + "options": { + "type": "array", + "items": { "type": "string" }, + "description": "Static list of valid values. Clients SHOULD render as a dropdown." + }, + "options_from": { + "type": "object", + "description": "Dynamically resolve available values from a local file.", + "required": ["file", "path"], + "properties": { + "file": { + "type": "string", + "description": "Path to a local JSON file. ~ is expanded to the user's home directory." + }, + "path": { + "type": "string", + "description": "JSONPath expression to extract values from the file." + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/src/generate.js b/src/generate.js new file mode 100644 index 0000000..6e79499 --- /dev/null +++ b/src/generate.js @@ -0,0 +1,329 @@ +import { readFileSync, existsSync } from 'fs'; +import { basename, extname } from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +/** + * @typedef {Object} McpServerEntry + * @property {string} command + * @property {string[]} [args] + * @property {Object} [env] + */ + +/** + * Read MCP server entries from a Claude Code settings.json file. + * @param {string} settingsPath + * @returns {Object} + */ +export function readSettings(settingsPath) { + if (!existsSync(settingsPath)) { + throw new Error(`Settings file not found: ${settingsPath}`); + } + const json = JSON.parse(readFileSync(settingsPath, 'utf-8')); + return json.mcpServers || {}; +} + +/** + * Infer install method from a command string. + * @param {string} command + * @param {string[]} args + * @returns {{ method: string, package: string, command: string } | null} + */ +export function inferInstall(command, args = []) { + const cmd = basename(command).toLowerCase(); + + // npx -y @scope/package or npx @scope/package + if (cmd === 'npx' || cmd === 'npx.cmd') { + const pkgArg = args.find(a => !a.startsWith('-')); + if (pkgArg) { + return { method: 'npm', package: pkgArg, command: pkgArg.split('/').pop().replace(/^@/, '') }; + } + } + + // uvx package or uv run package + if (cmd === 'uvx' || cmd === 'uvx.exe') { + const pkgArg = args.find(a => !a.startsWith('-')); + if (pkgArg) { + return { method: 'pip', package: pkgArg, command: pkgArg }; + } + } + + // python -m package + if ((cmd === 'python' || cmd === 'python3' || cmd === 'python.exe') && args[0] === '-m') { + const pkg = args[1]; + if (pkg) { + return { method: 'pip', package: pkg, command: `python -m ${pkg}` }; + } + } + + // docker run image + if (cmd === 'docker' || cmd === 'docker.exe') { + if (args[0] === 'run') { + const image = args.filter(a => !a.startsWith('-')).pop(); + if (image) { + return { method: 'docker', package: image, command: 'docker' }; + } + } + } + + // dotnet tool — check if it's a known dotnet tool command + // Heuristic: if the command has no extension or is an .exe not in system dirs + if (cmd.endsWith('.exe') || !cmd.includes('.')) { + const toolName = cmd.replace(/\.exe$/, ''); + // Try to detect if it's a dotnet tool + return { method: 'dotnet-tool', package: toolName, command: toolName }; + } + + return null; +} + +/** + * Extract config entries from args and env vars. + * @param {string[]} args + * @param {Object} env + * @returns {Array<{key: string, type: string, arg?: string, env_var?: string, description: string}>} + */ +export function extractConfig(args = [], env = {}) { + const config = []; + const skipNext = new Set(); + + // Parse --flag value pairs from args + for (let i = 0; i < args.length; i++) { + if (skipNext.has(i)) continue; + + const arg = args[i]; + if (arg.startsWith('--') && i + 1 < args.length && !args[i + 1].startsWith('-')) { + const key = arg.replace(/^--/, ''); + const value = args[i + 1]; + skipNext.add(i + 1); + + const type = inferConfigType(key, value); + config.push({ + key, + description: humanize(key), + type, + required: false, + arg, + prompt: humanize(key) + }); + } else if (arg.startsWith('-') && arg.length === 2 && i + 1 < args.length && !args[i + 1].startsWith('-')) { + // Short flags like -k value + const key = arg.replace(/^-/, ''); + const value = args[i + 1]; + skipNext.add(i + 1); + + const type = inferConfigType(key, value); + config.push({ + key, + description: humanize(key), + type, + required: false, + arg, + prompt: humanize(key) + }); + } + } + + // Parse env vars + for (const [envKey, envValue] of Object.entries(env)) { + // Check if this env var already maps to an arg-based config + const existing = config.find(c => c.key === envKey.toLowerCase().replace(/_/g, '-')); + if (existing) { + existing.env_var = envKey; + continue; + } + + const key = envKey.toLowerCase().replace(/_/g, '-'); + const type = inferConfigType(key, envValue); + config.push({ + key, + description: humanize(key), + type, + required: false, + env_var: envKey, + prompt: humanize(key) + }); + } + + return config; +} + +/** + * Infer config value type from key name and sample value. + */ +function inferConfigType(key, value) { + const keyLower = key.toLowerCase(); + if (keyLower.includes('key') || keyLower.includes('token') || keyLower.includes('secret') || keyLower.includes('password')) { + return 'secret'; + } + if (keyLower.includes('url') || keyLower.includes('endpoint') || keyLower.includes('host')) { + if (value && (value.startsWith('http') || value.startsWith('//'))) return 'url'; + } + if (keyLower.includes('path') || keyLower.includes('dir') || keyLower.includes('file')) { + return 'path'; + } + if (value === 'true' || value === 'false') return 'boolean'; + if (value && !isNaN(value)) return 'number'; + return 'string'; +} + +/** + * Convert kebab-case or snake_case to human-readable. + */ +function humanize(str) { + return str + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); +} + +/** + * Build a settings_template from the original command/args, replacing detected values with ${key} vars. + */ +export function buildSettingsTemplate(command, args = [], config = []) { + const templateArgs = [...args]; + + // Replace arg values with template variables + for (let i = 0; i < templateArgs.length; i++) { + const argConfig = config.find(c => c.arg === templateArgs[i]); + if (argConfig && i + 1 < templateArgs.length) { + templateArgs[i + 1] = `\${${argConfig.key}}`; + } + } + + // For npx commands, use the package as the command + const cmd = basename(command).toLowerCase(); + if (cmd === 'npx' || cmd === 'npx.cmd') { + const pkgIdx = templateArgs.findIndex(a => !a.startsWith('-')); + if (pkgIdx >= 0) { + const pkg = templateArgs[pkgIdx]; + return { + command: pkg.split('/').pop().replace(/^@/, ''), + args: templateArgs.filter((_, i) => i !== pkgIdx && templateArgs[i] !== '-y') + .filter(a => a.length > 0) + }; + } + } + + return { + command: basename(command).replace(/\.exe$/, ''), + args: templateArgs.filter(a => a.length > 0) + }; +} + +/** + * Try to get server info via MCP initialize handshake. + * @param {string} command + * @param {string[]} args + * @param {Object} env + * @returns {Promise<{name?: string, version?: string} | null>} + */ +export async function probeServer(command, args = [], env = {}) { + const initMsg = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'mcp-manifest-generate', version: '0.1.0' } + } + }); + + try { + const { spawn } = await import('child_process'); + return new Promise((resolve) => { + const proc = spawn(command, args, { + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + let output = ''; + const timeout = setTimeout(() => { + proc.kill(); + resolve(null); + }, 5000); + + proc.stdout.on('data', (data) => { + output += data.toString(); + // Try to parse JSON-RPC response + try { + const lines = output.split('\n').filter(l => l.trim()); + for (const line of lines) { + // Skip Content-Length headers + if (line.startsWith('{')) { + const resp = JSON.parse(line); + if (resp.result?.serverInfo) { + clearTimeout(timeout); + proc.kill(); + resolve(resp.result.serverInfo); + return; + } + } + } + } catch { /* partial data, keep reading */ } + }); + + proc.on('error', () => { + clearTimeout(timeout); + resolve(null); + }); + + proc.on('exit', () => { + clearTimeout(timeout); + resolve(null); + }); + + // Send the initialize message with Content-Length header + const content = initMsg; + proc.stdin.write(`Content-Length: ${Buffer.byteLength(content)}\r\n\r\n${content}`); + }); + } catch { + return null; + } +} + +/** + * Generate a manifest from an MCP server settings entry. + * @param {string} name - Server name from settings key + * @param {McpServerEntry} entry - The settings entry + * @param {{ probe?: boolean }} options + * @returns {Promise} Draft mcp-manifest.json object + */ +export async function generateManifest(name, entry, options = {}) { + const { command, args = [], env = {} } = entry; + const install = inferInstall(command, args); + const config = extractConfig(args, env); + const template = buildSettingsTemplate(command, args, config); + + let serverInfo = { name, displayName: humanize(name), version: '1.0.0' }; + + // Try MCP handshake if requested + if (options.probe) { + const probed = await probeServer(command, args, env); + if (probed) { + if (probed.name) serverInfo.name = probed.name.toLowerCase().replace(/\s+/g, '-'); + if (probed.name) serverInfo.displayName = probed.name; + if (probed.version) serverInfo.version = probed.version; + } + } + + const manifest = { + $schema: 'https://mcp-manifest.dev/schema/v0.1.json', + version: '0.1', + server: { + name: serverInfo.name, + displayName: serverInfo.displayName, + description: `TODO: Add description for ${serverInfo.displayName}`, + version: serverInfo.version + }, + install: install ? [{ ...install, priority: 0 }] : [], + transport: 'stdio', + config, + scopes: ['global'], + settings_template: template + }; + + return manifest; +} diff --git a/src/index.js b/src/index.js index 6037872..b7e34c1 100644 --- a/src/index.js +++ b/src/index.js @@ -42,6 +42,13 @@ export function validateManifest(manifest) { if (dupes.length > 0) { errors.push(`duplicate config keys: ${[...new Set(dupes)].join(', ')}`); } + + // Validate options and options_from aren't both set + for (const cfg of manifest.config) { + if (cfg.options && cfg.options_from) { + errors.push(`config "${cfg.key}": cannot have both "options" and "options_from"`); + } + } } if (manifest.settings_template) { @@ -66,7 +73,17 @@ export function validateManifest(manifest) { export async function discover(input) { const errors = []; - // 1. Local file + // 1. Installed tool: try {command} --manifest + if (!input.startsWith('http') && !input.startsWith('/') && !input.includes('\\') && !input.endsWith('.json')) { + try { + const manifest = await tryCommandManifest(input); + if (manifest) { + return { manifest, source: `command: ${input} --manifest`, errors: [] }; + } + } catch { /* not an installed command */ } + } + + // 2. Local file path try { const { existsSync, readFileSync: readSync } = await import('fs'); if (existsSync(input)) { @@ -79,7 +96,7 @@ export async function discover(input) { } } catch { /* not a file path */ } - // 2. Direct URL to .json + // 3. Direct URL to .json if (input.startsWith('http') && input.endsWith('.json')) { try { const res = await fetch(input, { signal: AbortSignal.timeout(10000) }); @@ -94,12 +111,12 @@ export async function discover(input) { return { manifest: null, source: input, errors }; } - // 3. Normalize to base URL + // 4. Normalize to base URL let baseUrl = input; if (!baseUrl.startsWith('http')) baseUrl = `https://${baseUrl}`; baseUrl = baseUrl.replace(/\/+$/, ''); - // 4. Try well-known URL + // 5. Try well-known URL const wellKnown = `${baseUrl}/.well-known/mcp-manifest.json`; try { const res = await fetch(wellKnown, { signal: AbortSignal.timeout(10000) }); @@ -112,7 +129,7 @@ export async function discover(input) { errors.push(`well-known: ${e.message}`); } - // 5. Fetch HTML and parse + // 6. Fetch HTML and parse try { const res = await fetch(baseUrl, { signal: AbortSignal.timeout(10000) }); if (res.ok) { @@ -146,6 +163,31 @@ export async function discover(input) { return { manifest: null, source: baseUrl, errors }; } +/** + * Try running {command} --manifest and parse the output as a manifest. + * @param {string} command + * @returns {Promise} + */ +async function tryCommandManifest(command) { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + + try { + const cmd = process.platform === 'win32' ? command.replace(/\.exe$/, '') : command; + const { stdout, stderr } = await execAsync(`${cmd} --manifest`, { timeout: 10000 }); + + if (!stdout || !stdout.trim()) return null; + + const manifest = JSON.parse(stdout.trim()); + // Basic sanity check — must have server.name + if (manifest?.server?.name) return manifest; + return null; + } catch { + return null; + } +} + /** * Check if a command exists on PATH. * @param {string} command