feat(cli): add manifest generator from existing MCP configs
Add mcp-manifest-generate CLI tool to reverse-engineer manifests from Claude Code settings.json. Infers install methods from command patterns (npx→npm, uvx→pip, .exe→dotnet-tool). Converts --flag args to typed config entries (--api-key→secret). Supports --probe flag to run MCP handshake for real server name/version. Add interactive --init wizard mode. Update schema with additional validation rules. Add comprehensive CLI help and examples to README
This commit is contained in:
39
README.md
39
README.md
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
[](https://mcp-manifest.dev)
|
[](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
|
```bash
|
||||||
# Validate a local manifest
|
# Validate a local manifest
|
||||||
@@ -76,6 +76,41 @@ const { manifest, source, errors } = await discover('ironlicensing.com');
|
|||||||
const exists = await checkCommand('ironlicensing-mcp');
|
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
|
## License
|
||||||
|
|
||||||
CC0 1.0 — Public domain.
|
CC0 1.0 — Public domain.
|
||||||
|
|||||||
273
bin/generate.js
Normal file
273
bin/generate.js
Normal file
@@ -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 <name> Generate manifest for a specific server
|
||||||
|
mcp-manifest-generate --init Interactive wizard (from scratch)
|
||||||
|
mcp-manifest-generate --from-settings <path> Use a specific settings file
|
||||||
|
mcp-manifest-generate --server <name> --probe Also run MCP handshake for server info
|
||||||
|
mcp-manifest-generate --server <name> -o <file> 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> Path to settings.json (default: ~/.claude/settings.json)
|
||||||
|
--server <name> Server name to generate manifest for
|
||||||
|
--all Generate manifests for all servers
|
||||||
|
--probe Run MCP initialize handshake for server name/version
|
||||||
|
-o, --output <path> 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 <name> 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);
|
||||||
|
});
|
||||||
@@ -4,11 +4,13 @@
|
|||||||
"description": "Validate mcp-manifest.json files and test autodiscovery from domains",
|
"description": "Validate mcp-manifest.json files and test autodiscovery from domains",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"mcp-manifest-validate": "./bin/cli.js"
|
"mcp-manifest-validate": "./bin/cli.js",
|
||||||
|
"mcp-manifest-generate": "./bin/generate.js"
|
||||||
},
|
},
|
||||||
"main": "./src/index.js",
|
"main": "./src/index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.js"
|
".": "./src/index.js",
|
||||||
|
"./generate": "./src/generate.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"bin/",
|
"bin/",
|
||||||
|
|||||||
@@ -55,6 +55,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "SPDX license identifier"
|
"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": {
|
"keywords": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "type": "string" },
|
"items": { "type": "string" },
|
||||||
@@ -146,6 +151,27 @@
|
|||||||
"prompt": {
|
"prompt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Human-readable prompt for interactive setup"
|
"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
|
"additionalProperties": false
|
||||||
|
|||||||
329
src/generate.js
Normal file
329
src/generate.js
Normal file
@@ -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<string,string>} [env]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read MCP server entries from a Claude Code settings.json file.
|
||||||
|
* @param {string} settingsPath
|
||||||
|
* @returns {Object<string, McpServerEntry>}
|
||||||
|
*/
|
||||||
|
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<string,string>} 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<string,string>} 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<object>} 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;
|
||||||
|
}
|
||||||
52
src/index.js
52
src/index.js
@@ -42,6 +42,13 @@ export function validateManifest(manifest) {
|
|||||||
if (dupes.length > 0) {
|
if (dupes.length > 0) {
|
||||||
errors.push(`duplicate config keys: ${[...new Set(dupes)].join(', ')}`);
|
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) {
|
if (manifest.settings_template) {
|
||||||
@@ -66,7 +73,17 @@ export function validateManifest(manifest) {
|
|||||||
export async function discover(input) {
|
export async function discover(input) {
|
||||||
const errors = [];
|
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 {
|
try {
|
||||||
const { existsSync, readFileSync: readSync } = await import('fs');
|
const { existsSync, readFileSync: readSync } = await import('fs');
|
||||||
if (existsSync(input)) {
|
if (existsSync(input)) {
|
||||||
@@ -79,7 +96,7 @@ export async function discover(input) {
|
|||||||
}
|
}
|
||||||
} catch { /* not a file path */ }
|
} catch { /* not a file path */ }
|
||||||
|
|
||||||
// 2. Direct URL to .json
|
// 3. Direct URL to .json
|
||||||
if (input.startsWith('http') && input.endsWith('.json')) {
|
if (input.startsWith('http') && input.endsWith('.json')) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(input, { signal: AbortSignal.timeout(10000) });
|
const res = await fetch(input, { signal: AbortSignal.timeout(10000) });
|
||||||
@@ -94,12 +111,12 @@ export async function discover(input) {
|
|||||||
return { manifest: null, source: input, errors };
|
return { manifest: null, source: input, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Normalize to base URL
|
// 4. Normalize to base URL
|
||||||
let baseUrl = input;
|
let baseUrl = input;
|
||||||
if (!baseUrl.startsWith('http')) baseUrl = `https://${baseUrl}`;
|
if (!baseUrl.startsWith('http')) baseUrl = `https://${baseUrl}`;
|
||||||
baseUrl = baseUrl.replace(/\/+$/, '');
|
baseUrl = baseUrl.replace(/\/+$/, '');
|
||||||
|
|
||||||
// 4. Try well-known URL
|
// 5. Try well-known URL
|
||||||
const wellKnown = `${baseUrl}/.well-known/mcp-manifest.json`;
|
const wellKnown = `${baseUrl}/.well-known/mcp-manifest.json`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(wellKnown, { signal: AbortSignal.timeout(10000) });
|
const res = await fetch(wellKnown, { signal: AbortSignal.timeout(10000) });
|
||||||
@@ -112,7 +129,7 @@ export async function discover(input) {
|
|||||||
errors.push(`well-known: ${e.message}`);
|
errors.push(`well-known: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Fetch HTML and parse <link rel="mcp-manifest">
|
// 6. Fetch HTML and parse <link rel="mcp-manifest">
|
||||||
try {
|
try {
|
||||||
const res = await fetch(baseUrl, { signal: AbortSignal.timeout(10000) });
|
const res = await fetch(baseUrl, { signal: AbortSignal.timeout(10000) });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -146,6 +163,31 @@ export async function discover(input) {
|
|||||||
return { manifest: null, source: baseUrl, errors };
|
return { manifest: null, source: baseUrl, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try running {command} --manifest and parse the output as a manifest.
|
||||||
|
* @param {string} command
|
||||||
|
* @returns {Promise<object|null>}
|
||||||
|
*/
|
||||||
|
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.
|
* Check if a command exists on PATH.
|
||||||
* @param {string} command
|
* @param {string} command
|
||||||
|
|||||||
Reference in New Issue
Block a user