feat(notes): add manifest validation CLI tool

Create mcp-manifest-validate package with CLI and programmatic API:
- Validate manifests against JSON Schema v0.1
- Test autodiscovery from domains (well-known URL + HTML link tags)
- Check command availability on PATH
- Semantic validation (config keys, template variables, transport)
- Colorized terminal output with detailed error reporting
- JSON output mode for CI/CD integration
- Programmatic API for library use

Includes schema/v0.1.json, CLI in bin/cli.js, and core logic in src/index.js
This commit is contained in:
2026-03-29 23:28:26 -04:00
commit 33b623fe68
7 changed files with 739 additions and 0 deletions

165
src/index.js Normal file
View File

@@ -0,0 +1,165 @@
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const schemaPath = join(__dirname, '..', 'schema', 'v0.1.json');
const schema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
const validateSchema = ajv.compile(schema);
/**
* Validate a manifest object against the JSON Schema.
* @param {object} manifest - Parsed manifest JSON
* @returns {{ valid: boolean, errors: string[] }}
*/
export function validateManifest(manifest) {
const valid = validateSchema(manifest);
const errors = [];
if (!valid && validateSchema.errors) {
for (const err of validateSchema.errors) {
const path = err.instancePath || '(root)';
errors.push(`${path}: ${err.message}`);
}
}
// Additional semantic checks beyond JSON Schema
if (manifest.transport === 'sse' || manifest.transport === 'streamable-http') {
if (!manifest.endpoint) {
errors.push(`transport "${manifest.transport}" requires an "endpoint" URL`);
}
}
if (manifest.config) {
const keys = manifest.config.map(c => c.key);
const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
if (dupes.length > 0) {
errors.push(`duplicate config keys: ${[...new Set(dupes)].join(', ')}`);
}
}
if (manifest.settings_template) {
const templateStr = JSON.stringify(manifest.settings_template);
const varRefs = [...templateStr.matchAll(/\$\{([^}]+)\}/g)].map(m => m[1]);
const configKeys = (manifest.config || []).map(c => c.key);
for (const ref of varRefs) {
if (!configKeys.includes(ref)) {
errors.push(`settings_template references "\${${ref}}" but no config entry has key "${ref}"`);
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* Discover a manifest from a domain, URL, or file path.
* @param {string} input - Domain, URL, or file path
* @returns {Promise<{ manifest: object|null, source: string, errors: string[] }>}
*/
export async function discover(input) {
const errors = [];
// 1. Local file
try {
const { existsSync, readFileSync: readSync } = await import('fs');
if (existsSync(input)) {
try {
const manifest = JSON.parse(readSync(input, 'utf-8'));
return { manifest, source: `file: ${input}`, errors: [] };
} catch (e) {
return { manifest: null, source: `file: ${input}`, errors: [`Failed to parse: ${e.message}`] };
}
}
} catch { /* not a file path */ }
// 2. Direct URL to .json
if (input.startsWith('http') && input.endsWith('.json')) {
try {
const res = await fetch(input, { signal: AbortSignal.timeout(10000) });
if (res.ok) {
const manifest = await res.json();
return { manifest, source: `url: ${input}`, errors: [] };
}
errors.push(`${input} returned ${res.status}`);
} catch (e) {
errors.push(`${input}: ${e.message}`);
}
return { manifest: null, source: input, errors };
}
// 3. Normalize to base URL
let baseUrl = input;
if (!baseUrl.startsWith('http')) baseUrl = `https://${baseUrl}`;
baseUrl = baseUrl.replace(/\/+$/, '');
// 4. Try well-known URL
const wellKnown = `${baseUrl}/.well-known/mcp-manifest.json`;
try {
const res = await fetch(wellKnown, { signal: AbortSignal.timeout(10000) });
if (res.ok) {
const manifest = await res.json();
return { manifest, source: `well-known: ${wellKnown}`, errors: [] };
}
errors.push(`well-known: ${res.status}`);
} catch (e) {
errors.push(`well-known: ${e.message}`);
}
// 5. Fetch HTML and parse <link rel="mcp-manifest">
try {
const res = await fetch(baseUrl, { signal: AbortSignal.timeout(10000) });
if (res.ok) {
const html = await res.text();
const match = html.match(/<link[^>]+rel\s*=\s*["']mcp-manifest["'][^>]+href\s*=\s*["']([^"']+)["']/i)
|| html.match(/<link[^>]+href\s*=\s*["']([^"']+)["'][^>]+rel\s*=\s*["']mcp-manifest["']/i);
if (match) {
let href = match[1];
if (href.startsWith('/')) href = `${baseUrl}${href}`;
else if (!href.startsWith('http')) href = `${baseUrl}/${href}`;
try {
const mRes = await fetch(href, { signal: AbortSignal.timeout(10000) });
if (mRes.ok) {
const manifest = await mRes.json();
return { manifest, source: `link tag: ${href}`, errors: [] };
}
errors.push(`link tag href ${href}: ${mRes.status}`);
} catch (e) {
errors.push(`link tag href ${href}: ${e.message}`);
}
} else {
errors.push('no <link rel="mcp-manifest"> found in HTML');
}
}
} catch (e) {
errors.push(`HTML fetch: ${e.message}`);
}
return { manifest: null, source: baseUrl, errors };
}
/**
* Check if a command exists on PATH.
* @param {string} command
* @returns {Promise<boolean>}
*/
export async function checkCommand(command) {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
const cmd = process.platform === 'win32' ? `where ${command}` : `which ${command}`;
try {
await execAsync(cmd);
return true;
} catch {
return false;
}
}