@kryptosai/mcp-observatory 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/adapters/http.js +6 -2
- package/dist/src/adapters/http.js.map +1 -1
- package/dist/src/adapters/local-process.js +25 -3
- package/dist/src/adapters/local-process.js.map +1 -1
- package/dist/src/checks/conformance.js +5 -4
- package/dist/src/checks/conformance.js.map +1 -1
- package/dist/src/checks/list-check.js +2 -1
- package/dist/src/checks/list-check.js.map +1 -1
- package/dist/src/checks/resources.js +3 -2
- package/dist/src/checks/resources.js.map +1 -1
- package/dist/src/checks/tools-invoke.js +3 -2
- package/dist/src/checks/tools-invoke.js.map +1 -1
- package/dist/src/cli.js +79 -875
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/diff.d.ts +2 -0
- package/dist/src/commands/diff.js +27 -0
- package/dist/src/commands/diff.js.map +1 -0
- package/dist/src/commands/helpers.d.ts +25 -0
- package/dist/src/commands/helpers.js +131 -0
- package/dist/src/commands/helpers.js.map +1 -0
- package/dist/src/commands/legacy.d.ts +2 -0
- package/dist/src/commands/legacy.js +77 -0
- package/dist/src/commands/legacy.js.map +1 -0
- package/dist/src/commands/record-replay.d.ts +2 -0
- package/dist/src/commands/record-replay.js +181 -0
- package/dist/src/commands/record-replay.js.map +1 -0
- package/dist/src/commands/scan.d.ts +2 -0
- package/dist/src/commands/scan.js +155 -0
- package/dist/src/commands/scan.js.map +1 -0
- package/dist/src/commands/score.d.ts +2 -0
- package/dist/src/commands/score.js +88 -0
- package/dist/src/commands/score.js.map +1 -0
- package/dist/src/commands/serve.d.ts +2 -0
- package/dist/src/commands/serve.js +10 -0
- package/dist/src/commands/serve.js.map +1 -0
- package/dist/src/commands/suggest.d.ts +2 -0
- package/dist/src/commands/suggest.js +126 -0
- package/dist/src/commands/suggest.js.map +1 -0
- package/dist/src/commands/telemetry.d.ts +2 -0
- package/dist/src/commands/telemetry.js +65 -0
- package/dist/src/commands/telemetry.js.map +1 -0
- package/dist/src/commands/test.d.ts +2 -0
- package/dist/src/commands/test.js +37 -0
- package/dist/src/commands/test.js.map +1 -0
- package/dist/src/commands/watch.d.ts +5 -0
- package/dist/src/commands/watch.js +46 -0
- package/dist/src/commands/watch.js.map +1 -0
- package/dist/src/environment.js +12 -4
- package/dist/src/environment.js.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/integrations/index.d.ts +1 -0
- package/dist/src/integrations/index.js +2 -0
- package/dist/src/integrations/index.js.map +1 -0
- package/dist/src/integrations/smithery.d.ts +96 -0
- package/dist/src/integrations/smithery.js +301 -0
- package/dist/src/integrations/smithery.js.map +1 -0
- package/dist/src/runner.js +2 -1
- package/dist/src/runner.js.map +1 -1
- package/dist/src/runtime/index.d.ts +2 -0
- package/dist/src/runtime/index.js +3 -0
- package/dist/src/runtime/index.js.map +1 -0
- package/dist/src/runtime/monitor.d.ts +68 -0
- package/dist/src/runtime/monitor.js +162 -0
- package/dist/src/runtime/monitor.js.map +1 -0
- package/dist/src/runtime/wrapper.d.ts +28 -0
- package/dist/src/runtime/wrapper.js +30 -0
- package/dist/src/runtime/wrapper.js.map +1 -0
- package/dist/src/server.d.ts +2 -0
- package/dist/src/server.js +30 -14
- package/dist/src/server.js.map +1 -1
- package/dist/src/utils/errors.d.ts +4 -0
- package/dist/src/utils/errors.js +7 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -1,135 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import path from "node:path";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
4
3
|
import { Command } from "commander";
|
|
5
|
-
import { scanForTargets } from "./discovery.js";
|
|
6
|
-
import { detectEnvironment } from "./environment.js";
|
|
7
|
-
import os from "node:os";
|
|
8
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
|
-
import { defaultCassettesDirectory, loadCassette, saveCassette } from "./cassette.js";
|
|
10
|
-
import { runPromptsCheck } from "./checks/prompts.js";
|
|
11
|
-
import { runResourcesCheck } from "./checks/resources.js";
|
|
12
|
-
import { runToolsCheck } from "./checks/tools.js";
|
|
13
|
-
import { runToolsInvokeCheck } from "./checks/tools-invoke.js";
|
|
14
|
-
import { generateBadgeSvg } from "./badge.js";
|
|
15
|
-
import { diffArtifacts, readArtifact, renderHtml, renderMarkdown, renderTerminal, runTarget, writeRunArtifact } from "./index.js";
|
|
16
|
-
import { renderJUnit } from "./reporters/junit.js";
|
|
17
|
-
import { renderSarif } from "./reporters/sarif.js";
|
|
18
|
-
import { runTargetRecording } from "./runner.js";
|
|
19
|
-
import { defaultRunsDirectory } from "./storage.js";
|
|
20
|
-
import { ReplayTransport } from "./transport/replay-transport.js";
|
|
21
|
-
import { SCHEMA_VERSION } from "./types.js";
|
|
22
|
-
import { buildRunId } from "./utils/ids.js";
|
|
23
|
-
import { validateTargetConfig } from "./validate.js";
|
|
24
|
-
import { compareResponses } from "./verify.js";
|
|
25
4
|
import { isCI } from "./ci.js";
|
|
26
|
-
import {
|
|
5
|
+
import { ANSI, LOGO, c, getBinName, useColor } from "./commands/helpers.js";
|
|
6
|
+
import { registerDiffCommands } from "./commands/diff.js";
|
|
7
|
+
import { registerLegacyCommands } from "./commands/legacy.js";
|
|
8
|
+
import { registerRecordReplayCommands } from "./commands/record-replay.js";
|
|
9
|
+
import { registerScanCommands } from "./commands/scan.js";
|
|
10
|
+
import { registerScoreCommands } from "./commands/score.js";
|
|
11
|
+
import { registerServeCommands } from "./commands/serve.js";
|
|
12
|
+
import { registerSuggestCommands } from "./commands/suggest.js";
|
|
13
|
+
import { registerTelemetryCommands } from "./commands/telemetry.js";
|
|
14
|
+
import { registerTestCommands } from "./commands/test.js";
|
|
15
|
+
import { registerWatchCommands } from "./commands/watch.js";
|
|
16
|
+
import { runTarget } from "./index.js";
|
|
17
|
+
import { loadTelemetryConfig, showFirstRunNotice, recordEvent, buildEvent, isTelemetryEnabled } from "./telemetry.js";
|
|
27
18
|
import { TOOL_VERSION } from "./version.js";
|
|
28
|
-
// ── ASCII Logo ──────────────────────────────────────────────────────────────
|
|
29
|
-
const LOGO = `
|
|
30
|
-
███╗ ███╗ ██████╗██████╗
|
|
31
|
-
████╗ ████║██╔════╝██╔══██╗
|
|
32
|
-
██╔████╔██║██║ ██████╔╝
|
|
33
|
-
██║╚██╔╝██║██║ ██╔═══╝
|
|
34
|
-
██║ ╚═╝ ██║╚██████╗██║
|
|
35
|
-
╚═╝ ╚═╝ ╚═════╝╚═╝
|
|
36
|
-
O B S E R V A T O R Y
|
|
37
|
-
`;
|
|
38
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
39
|
-
async function readTargetConfig(filePath) {
|
|
40
|
-
const content = await readFile(filePath, "utf8");
|
|
41
|
-
return validateTargetConfig(JSON.parse(content));
|
|
42
|
-
}
|
|
43
|
-
function targetFromCommand(args) {
|
|
44
|
-
if (args.length === 0) {
|
|
45
|
-
throw new Error("No command provided. Usage: mcp-observatory test <command> [args...]");
|
|
46
|
-
}
|
|
47
|
-
const command = args[0];
|
|
48
|
-
const restArgs = args.slice(1);
|
|
49
|
-
// Build a meaningful targetId: for wrapper commands, use the package/script name
|
|
50
|
-
let targetId = command;
|
|
51
|
-
const wrappers = new Set(["npx", "node", "docker", "uvx", "bunx", "pnpx"]);
|
|
52
|
-
if (wrappers.has(command)) {
|
|
53
|
-
const pkg = restArgs.find(a => !a.startsWith("-") && a !== "run");
|
|
54
|
-
if (pkg)
|
|
55
|
-
targetId = pkg;
|
|
56
|
-
}
|
|
57
|
-
return {
|
|
58
|
-
targetId,
|
|
59
|
-
adapter: "local-process",
|
|
60
|
-
command,
|
|
61
|
-
args: restArgs,
|
|
62
|
-
timeoutMs: 15_000,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
// Extract args after -- before Commander sees them
|
|
66
|
-
const _rawArgv = [...process.argv];
|
|
67
|
-
const _dashDashIdx = _rawArgv.indexOf("--");
|
|
68
|
-
const _passthroughArgs = _dashDashIdx !== -1 ? _rawArgv.splice(_dashDashIdx).slice(1) : [];
|
|
69
|
-
if (_dashDashIdx !== -1) {
|
|
70
|
-
process.argv = _rawArgv;
|
|
71
|
-
}
|
|
72
|
-
function getPassthroughArgs() {
|
|
73
|
-
return _passthroughArgs;
|
|
74
|
-
}
|
|
75
|
-
async function resolveTarget(options) {
|
|
76
|
-
if (options.target) {
|
|
77
|
-
return readTargetConfig(options.target);
|
|
78
|
-
}
|
|
79
|
-
const passthrough = getPassthroughArgs();
|
|
80
|
-
if (passthrough.length > 0) {
|
|
81
|
-
return targetFromCommand(passthrough);
|
|
82
|
-
}
|
|
83
|
-
throw new Error("Provide --target <config.json> or use: mcp-observatory test <command>");
|
|
84
|
-
}
|
|
85
|
-
function useColor() {
|
|
86
|
-
return !process.env["NO_COLOR"] && !process.argv.includes("--no-color");
|
|
87
|
-
}
|
|
88
|
-
const ANSI = {
|
|
89
|
-
red: "\x1b[31m",
|
|
90
|
-
green: "\x1b[32m",
|
|
91
|
-
yellow: "\x1b[33m",
|
|
92
|
-
blue: "\x1b[34m",
|
|
93
|
-
cyan: "\x1b[36m",
|
|
94
|
-
dim: "\x1b[2m",
|
|
95
|
-
bold: "\x1b[1m",
|
|
96
|
-
reset: "\x1b[0m",
|
|
97
|
-
};
|
|
98
|
-
function c(code, text) {
|
|
99
|
-
return useColor() ? `${code}${text}${ANSI.reset}` : text;
|
|
100
|
-
}
|
|
101
|
-
function formatOutput(artifact, format) {
|
|
102
|
-
if (format === "json")
|
|
103
|
-
return JSON.stringify(artifact, null, 2);
|
|
104
|
-
if (format === "markdown")
|
|
105
|
-
return renderMarkdown(artifact);
|
|
106
|
-
if (format === "html")
|
|
107
|
-
return renderHtml(artifact);
|
|
108
|
-
if (format === "junit" && artifact.artifactType === "run")
|
|
109
|
-
return renderJUnit(artifact);
|
|
110
|
-
if (format === "sarif" && artifact.artifactType === "run")
|
|
111
|
-
return renderSarif(artifact);
|
|
112
|
-
return renderTerminal(artifact);
|
|
113
|
-
}
|
|
114
|
-
async function writeOutput(content, format, outputPath) {
|
|
115
|
-
if (outputPath !== undefined) {
|
|
116
|
-
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
117
|
-
await writeFile(outputPath, content + "\n", "utf8");
|
|
118
|
-
process.stdout.write(`Wrote ${format} report to ${outputPath}\n`);
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
process.stdout.write(`${content}\n`);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
// ── Invocation detection ─────────────────────────────────────────────────────
|
|
125
|
-
/** Returns the command the user actually typed, so tips are copy-pasteable. */
|
|
126
|
-
function getBinName() {
|
|
127
|
-
const script = process.argv[1] ?? "";
|
|
128
|
-
if (script.includes(".npm/_npx") || script.includes("npx")) {
|
|
129
|
-
return "npx @kryptosai/mcp-observatory";
|
|
130
|
-
}
|
|
131
|
-
return "mcp-observatory";
|
|
132
|
-
}
|
|
133
19
|
const MENU_GROUPS = [
|
|
134
20
|
{
|
|
135
21
|
heading: "",
|
|
@@ -301,598 +187,84 @@ async function main() {
|
|
|
301
187
|
const lines = examples.map(([cmd, desc]) => ` ${c(ANSI.dim, "$")} ${bin}${cmd}${pad(cmd)}${desc}`);
|
|
302
188
|
return ["", "Examples:", "", ...lines, ""].join("\n");
|
|
303
189
|
})());
|
|
304
|
-
//
|
|
305
|
-
|
|
190
|
+
// Register all command modules
|
|
191
|
+
registerScanCommands(program, bin);
|
|
192
|
+
registerTestCommands(program);
|
|
193
|
+
registerDiffCommands(program);
|
|
194
|
+
registerRecordReplayCommands(program, bin);
|
|
195
|
+
registerWatchCommands(program);
|
|
196
|
+
registerServeCommands(program);
|
|
197
|
+
registerSuggestCommands(program);
|
|
198
|
+
registerScoreCommands(program);
|
|
199
|
+
registerLegacyCommands(program);
|
|
200
|
+
registerTelemetryCommands(program);
|
|
201
|
+
// ── smithery ─────────────────────────────────────────────────────────
|
|
202
|
+
const smitheryCmd = program
|
|
203
|
+
.command("smithery")
|
|
204
|
+
.description("Smithery registry integration — scan, report, and batch-check servers.");
|
|
205
|
+
smitheryCmd
|
|
306
206
|
.command("scan")
|
|
307
|
-
.description("
|
|
308
|
-
.
|
|
207
|
+
.description("Resolve a Smithery server, run checks, and output a report.")
|
|
208
|
+
.argument("<qualified-name>", "Smithery qualified name (e.g. @anthropic/mcp-server-fetch)")
|
|
309
209
|
.option("--security", "Run security analysis on tool schemas.")
|
|
310
|
-
.option("--
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
.
|
|
318
|
-
|
|
319
|
-
.
|
|
320
|
-
|
|
321
|
-
.
|
|
322
|
-
// Inherit parent config option if set
|
|
323
|
-
const parentConfig = scanCmd.opts().config;
|
|
324
|
-
const parentSecurity = scanCmd.opts().security;
|
|
325
|
-
await runScan(bin, options.config ?? parentConfig, true, options.security ?? parentSecurity ?? true);
|
|
210
|
+
.option("--api-key <key>", "Smithery API key.")
|
|
211
|
+
.option("--base-url <url>", "Override Smithery API base URL.")
|
|
212
|
+
.action(async (qualifiedName, options) => {
|
|
213
|
+
const smithery = await import("./integrations/smithery.js");
|
|
214
|
+
const smitheryConfig = { apiKey: options.apiKey, baseUrl: options.baseUrl };
|
|
215
|
+
process.stdout.write(` Resolving ${qualifiedName} from Smithery...\n`);
|
|
216
|
+
const target = await smithery.resolveSmitheryTarget(qualifiedName, smitheryConfig);
|
|
217
|
+
process.stdout.write(` Running checks against ${target.targetId}...\n`);
|
|
218
|
+
const artifact = await runTarget(target, { securityCheck: options.security });
|
|
219
|
+
const submission = smithery.generateSubmission(qualifiedName, artifact);
|
|
220
|
+
const md = smithery.renderSubmissionMarkdown(submission);
|
|
221
|
+
process.stdout.write(`\n${md}\n`);
|
|
326
222
|
});
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
.
|
|
330
|
-
.
|
|
331
|
-
.
|
|
332
|
-
.argument("<command...>", "Server command and arguments to run.")
|
|
223
|
+
smitheryCmd
|
|
224
|
+
.command("report")
|
|
225
|
+
.description("Generate a formatted markdown report suitable for submitting to Smithery.")
|
|
226
|
+
.argument("<qualified-name>", "Smithery qualified name")
|
|
227
|
+
.option("--output <path>", "Write report to file instead of stdout.")
|
|
333
228
|
.option("--security", "Run security analysis on tool schemas.")
|
|
334
|
-
.option("--
|
|
335
|
-
.
|
|
336
|
-
|
|
337
|
-
|
|
229
|
+
.option("--api-key <key>", "Smithery API key.")
|
|
230
|
+
.option("--base-url <url>", "Override Smithery API base URL.")
|
|
231
|
+
.action(async (qualifiedName, options) => {
|
|
232
|
+
const smithery = await import("./integrations/smithery.js");
|
|
233
|
+
const smitheryConfig = { apiKey: options.apiKey, baseUrl: options.baseUrl };
|
|
234
|
+
process.stdout.write(` Resolving ${qualifiedName} from Smithery...\n`);
|
|
235
|
+
const target = await smithery.resolveSmitheryTarget(qualifiedName, smitheryConfig);
|
|
236
|
+
process.stdout.write(` Running checks against ${target.targetId}...\n`);
|
|
338
237
|
const artifact = await runTarget(target, { securityCheck: options.security });
|
|
339
|
-
const
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const promptCount = promptsEvidence?.evidence[0]?.itemCount ?? 0;
|
|
345
|
-
const resourceCount = resourcesEvidence?.evidence[0]?.itemCount ?? 0;
|
|
346
|
-
const gateIcon = artifact.gate === "pass" ? c(ANSI.green, "✓") : c(ANSI.red, "✗");
|
|
347
|
-
process.stdout.write(`\r ${gateIcon} ${c(ANSI.bold, target.targetId)}${" ".repeat(Math.max(1, 40 - target.targetId.length))}`);
|
|
348
|
-
process.stdout.write(`${c(ANSI.dim, `${toolCount} tools, ${promptCount} prompts, ${resourceCount} resources`)}\n`);
|
|
349
|
-
for (const check of artifact.checks) {
|
|
350
|
-
if (check.status === "fail" || check.status === "partial") {
|
|
351
|
-
process.stdout.write(` ${c(ANSI.dim, "→")} ${check.id}: ${check.message}\n`);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
process.stdout.write(`\n ${c(ANSI.dim, `Artifact: ${outPath}`)}\n\n`);
|
|
355
|
-
if (artifact.gate === "fail") {
|
|
356
|
-
process.exitCode = 1;
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
// ── diff ──────────────────────────────────────────────────────────────
|
|
360
|
-
program
|
|
361
|
-
.command("diff")
|
|
362
|
-
.description("Compare two runs and show regressions and schema drift.")
|
|
363
|
-
.argument("<base>", "Base run artifact JSON file.")
|
|
364
|
-
.argument("<head>", "Head run artifact JSON file.")
|
|
365
|
-
.option("--format <format>", "terminal, json, markdown, html, junit, or sarif", "terminal")
|
|
366
|
-
.option("--output <file>", "Write to file instead of stdout.")
|
|
367
|
-
.option("--no-color", "Disable colored output.")
|
|
368
|
-
.option("--fail-on-regression", "Exit with code 1 when regressions are present.", false)
|
|
369
|
-
.action(async (base, head, options) => {
|
|
370
|
-
const baseArtifact = await readArtifact(base);
|
|
371
|
-
const headArtifact = await readArtifact(head);
|
|
372
|
-
if (baseArtifact.artifactType !== "run" || headArtifact.artifactType !== "run") {
|
|
373
|
-
throw new Error("The diff command only accepts run artifacts.");
|
|
374
|
-
}
|
|
375
|
-
const artifact = diffArtifacts(baseArtifact, headArtifact);
|
|
376
|
-
const output = formatOutput(artifact, options.format);
|
|
377
|
-
await writeOutput(output, options.format, options.output);
|
|
378
|
-
if (options.failOnRegression && artifact.gate === "fail") {
|
|
379
|
-
process.exitCode = 1;
|
|
380
|
-
}
|
|
381
|
-
});
|
|
382
|
-
// ── watch ─────────────────────────────────────────────────────────────
|
|
383
|
-
program
|
|
384
|
-
.command("watch")
|
|
385
|
-
.description("Watch a server for changes, alert on regressions.")
|
|
386
|
-
.argument("<config>", "Path to a target config JSON file.")
|
|
387
|
-
.option("--interval <seconds>", "Check interval in seconds.", "30")
|
|
388
|
-
.option("--no-color", "Disable colored output.")
|
|
389
|
-
.action(async (configPath, options) => {
|
|
390
|
-
const target = await readTargetConfig(configPath);
|
|
391
|
-
const outDir = defaultRunsDirectory(process.cwd());
|
|
392
|
-
await runWatchMode(target, outDir, parseInt(options.interval, 10) || 30);
|
|
393
|
-
});
|
|
394
|
-
// ── serve ─────────────────────────────────────────────────────────────
|
|
395
|
-
program
|
|
396
|
-
.command("serve")
|
|
397
|
-
.description("Start as an MCP server for AI agents.")
|
|
398
|
-
.action(async () => {
|
|
399
|
-
const { startServer } = await import("./server.js");
|
|
400
|
-
await startServer();
|
|
401
|
-
});
|
|
402
|
-
// ── suggest ────────────────────────────────────────────────────────────
|
|
403
|
-
program
|
|
404
|
-
.command("suggest")
|
|
405
|
-
.description("Detect your stack and recommend MCP servers.")
|
|
406
|
-
.option("--cwd <path>", "Directory to scan for project signals.", process.cwd())
|
|
407
|
-
.option("--no-color", "Disable colored output.")
|
|
408
|
-
.action(async (options) => {
|
|
409
|
-
process.stdout.write(`${c(ANSI.dim, "⟳")} Scanning environment...\n\n`);
|
|
410
|
-
// 1. Current MCP servers
|
|
411
|
-
const targets = await scanForTargets();
|
|
412
|
-
if (targets.length > 0) {
|
|
413
|
-
process.stdout.write(c(ANSI.bold, " Configured MCP Servers\n"));
|
|
414
|
-
for (const t of targets) {
|
|
415
|
-
const detail = t.config.adapter === "http"
|
|
416
|
-
? t.config.url
|
|
417
|
-
: `${t.config.command} ${t.config.args.join(" ")}`;
|
|
418
|
-
process.stdout.write(` ${c(ANSI.cyan, "●")} ${c(ANSI.bold, t.config.targetId)} ${c(ANSI.dim, detail)} ${c(ANSI.dim, `← ${t.source}`)}\n`);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
else {
|
|
422
|
-
process.stdout.write(` ${c(ANSI.yellow, "No MCP servers configured.")}\n`);
|
|
423
|
-
}
|
|
424
|
-
process.stdout.write("\n");
|
|
425
|
-
// 2. Environment detection
|
|
426
|
-
const env = await detectEnvironment(options.cwd);
|
|
427
|
-
const hasSignals = env.languages.length > 0 || env.frameworks.length > 0 || env.databases.length > 0;
|
|
428
|
-
if (hasSignals) {
|
|
429
|
-
process.stdout.write(c(ANSI.bold, " Detected Stack\n"));
|
|
430
|
-
if (env.languages.length > 0)
|
|
431
|
-
process.stdout.write(` ${c(ANSI.dim, "Languages:")} ${env.languages.join(", ")}\n`);
|
|
432
|
-
if (env.frameworks.length > 0)
|
|
433
|
-
process.stdout.write(` ${c(ANSI.dim, "Frameworks:")} ${env.frameworks.join(", ")}\n`);
|
|
434
|
-
if (env.databases.length > 0)
|
|
435
|
-
process.stdout.write(` ${c(ANSI.dim, "Databases:")} ${env.databases.join(", ")}\n`);
|
|
436
|
-
if (env.cloud.length > 0)
|
|
437
|
-
process.stdout.write(` ${c(ANSI.dim, "Cloud:")} ${env.cloud.join(", ")}\n`);
|
|
438
|
-
if (env.cicd.length > 0)
|
|
439
|
-
process.stdout.write(` ${c(ANSI.dim, "CI/CD:")} ${env.cicd.join(", ")}\n`);
|
|
440
|
-
if (env.services.length > 0)
|
|
441
|
-
process.stdout.write(` ${c(ANSI.dim, "Services:")} ${env.services.join(", ")}\n`);
|
|
442
|
-
}
|
|
443
|
-
else {
|
|
444
|
-
process.stdout.write(` ${c(ANSI.dim, "No recognizable project signals in")} ${options.cwd}\n`);
|
|
445
|
-
}
|
|
446
|
-
process.stdout.write("\n");
|
|
447
|
-
// 3. MCP Registry — filtered by detected stack
|
|
448
|
-
try {
|
|
449
|
-
const response = await fetch("https://registry.modelcontextprotocol.io/v0/servers", {
|
|
450
|
-
signal: AbortSignal.timeout(10_000),
|
|
451
|
-
headers: { "Accept": "application/json" },
|
|
452
|
-
});
|
|
453
|
-
if (response.ok) {
|
|
454
|
-
const data = await response.json();
|
|
455
|
-
const raw = Array.isArray(data) ? data : (typeof data === "object" && data !== null
|
|
456
|
-
? (data["servers"] ?? data["results"] ?? data["items"])
|
|
457
|
-
: null);
|
|
458
|
-
if (Array.isArray(raw)) {
|
|
459
|
-
const allEntries = raw;
|
|
460
|
-
// Build keyword set from detected environment
|
|
461
|
-
const keywords = new Set([
|
|
462
|
-
...env.languages, ...env.frameworks, ...env.databases,
|
|
463
|
-
...env.services, ...env.cloud, ...env.cicd,
|
|
464
|
-
].map(s => s.toLowerCase()));
|
|
465
|
-
// Also add common aliases
|
|
466
|
-
const aliases = {
|
|
467
|
-
typescript: ["ts"], javascript: ["js", "node"], python: ["py"],
|
|
468
|
-
postgresql: ["postgres"], mongodb: ["mongo"], github: ["gh"],
|
|
469
|
-
};
|
|
470
|
-
for (const kw of [...keywords]) {
|
|
471
|
-
for (const [full, abbrs] of Object.entries(aliases)) {
|
|
472
|
-
if (kw === full)
|
|
473
|
-
for (const a of abbrs)
|
|
474
|
-
keywords.add(a);
|
|
475
|
-
if (abbrs.includes(kw))
|
|
476
|
-
keywords.add(full);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
// Score each entry against detected stack
|
|
480
|
-
const scored = allEntries.map(entry => {
|
|
481
|
-
const srv = (typeof entry["server"] === "object" && entry["server"] !== null ? entry["server"] : entry);
|
|
482
|
-
const name = typeof srv["name"] === "string" ? srv["name"] : (typeof entry["name"] === "string" ? entry["name"] : "unknown");
|
|
483
|
-
const desc = typeof srv["description"] === "string" ? srv["description"] : (typeof entry["description"] === "string" ? entry["description"] : "");
|
|
484
|
-
const text = `${name} ${desc}`.toLowerCase();
|
|
485
|
-
const matches = [...keywords].filter(k => text.includes(k)).length;
|
|
486
|
-
return { name, desc, matches };
|
|
487
|
-
});
|
|
488
|
-
const recommended = scored.filter(s => s.matches > 0).sort((a, b) => b.matches - a.matches);
|
|
489
|
-
const others = scored.filter(s => s.matches === 0);
|
|
490
|
-
if (recommended.length > 0) {
|
|
491
|
-
process.stdout.write(c(ANSI.bold, " Recommended for Your Stack\n"));
|
|
492
|
-
for (const r of recommended.slice(0, 10)) {
|
|
493
|
-
process.stdout.write(` ${c(ANSI.green, "★")} ${c(ANSI.bold, r.name)}${r.desc ? ` ${c(ANSI.dim, "—")} ${r.desc}` : ""}\n`);
|
|
494
|
-
}
|
|
495
|
-
if (recommended.length > 10) {
|
|
496
|
-
process.stdout.write(` ${c(ANSI.dim, `... and ${recommended.length - 10} more matches`)}\n`);
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
else {
|
|
500
|
-
process.stdout.write(c(ANSI.bold, " MCP Registry\n"));
|
|
501
|
-
for (const s of scored.slice(0, 10)) {
|
|
502
|
-
process.stdout.write(` ${c(ANSI.dim, "●")} ${c(ANSI.bold, s.name)}${s.desc ? ` ${c(ANSI.dim, "—")} ${s.desc}` : ""}\n`);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
if (others.length > 0) {
|
|
506
|
-
process.stdout.write(`\n ${c(ANSI.dim, `${others.length} more servers at registry.modelcontextprotocol.io`)}\n`);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
else {
|
|
510
|
-
process.stdout.write(` ${c(ANSI.dim, "Registry returned unexpected format.")}\n`);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
else {
|
|
514
|
-
process.stdout.write(` ${c(ANSI.dim, `Registry returned HTTP ${response.status}`)}\n`);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
catch (error) {
|
|
518
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
519
|
-
process.stdout.write(` ${c(ANSI.yellow, "Could not reach registry:")} ${msg}\n`);
|
|
520
|
-
}
|
|
521
|
-
process.stdout.write("\n");
|
|
522
|
-
});
|
|
523
|
-
// ── record ─────────────────────────────────────────────────────────────
|
|
524
|
-
program
|
|
525
|
-
.command("record")
|
|
526
|
-
.passThroughOptions()
|
|
527
|
-
.description("Record a server session to a cassette file for replay.")
|
|
528
|
-
.argument("[command...]", "Server command and arguments to run.")
|
|
529
|
-
.option("--target <config>", "Path to a target config JSON file.")
|
|
530
|
-
.option("--no-color", "Disable colored output.")
|
|
531
|
-
.action(async (commandArgs, options) => {
|
|
532
|
-
const target = options.target
|
|
533
|
-
? await readTargetConfig(options.target)
|
|
534
|
-
: targetFromCommand(commandArgs.length > 0 ? commandArgs : getPassthroughArgs());
|
|
535
|
-
process.stdout.write(`${c(ANSI.dim, "⟳")} Recording session with ${c(ANSI.bold, target.targetId)}...\n`);
|
|
536
|
-
const { artifact, cassetteEntries } = await runTargetRecording(target, { invokeTools: true });
|
|
537
|
-
if (!cassetteEntries || cassetteEntries.length === 0) {
|
|
538
|
-
process.stdout.write(`${c(ANSI.yellow, "⚠")} No traffic recorded.\n`);
|
|
539
|
-
process.exitCode = 1;
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
const cassette = {
|
|
543
|
-
version: 1,
|
|
544
|
-
targetId: target.targetId,
|
|
545
|
-
recordedAt: new Date().toISOString(),
|
|
546
|
-
transport: target.adapter === "http" ? "http" : "stdio",
|
|
547
|
-
entries: cassetteEntries,
|
|
548
|
-
};
|
|
549
|
-
const cassettePath = await saveCassette(cassette, defaultCassettesDirectory(process.cwd()));
|
|
550
|
-
const summary = renderTerminal(artifact);
|
|
551
|
-
process.stdout.write(`\n${summary}\n`);
|
|
552
|
-
process.stdout.write(`\n${c(ANSI.green, "✓")} Cassette saved: ${cassettePath}\n`);
|
|
553
|
-
process.stdout.write(` ${c(ANSI.dim, `${cassetteEntries.length} entries recorded`)}\n`);
|
|
554
|
-
process.stdout.write(`\n Replay offline: ${c(ANSI.cyan, `${bin} replay ${cassettePath}`)}\n`);
|
|
555
|
-
process.stdout.write(` Verify live: ${c(ANSI.cyan, `${bin} verify ${cassettePath} ${target.adapter === "http" ? `--target <config>` : commandArgs.join(" ")}`)}\n\n`);
|
|
556
|
-
if (artifact.gate === "fail") {
|
|
557
|
-
process.exitCode = 1;
|
|
558
|
-
}
|
|
559
|
-
});
|
|
560
|
-
// ── replay ─────────────────────────────────────────────────────────────
|
|
561
|
-
program
|
|
562
|
-
.command("replay")
|
|
563
|
-
.description("Replay a cassette file offline — no live server needed.")
|
|
564
|
-
.argument("<cassette>", "Path to a cassette JSON file.")
|
|
565
|
-
.option("--no-color", "Disable colored output.")
|
|
566
|
-
.action(async (cassettePath) => {
|
|
567
|
-
const cassette = await loadCassette(cassettePath);
|
|
568
|
-
process.stdout.write(`${c(ANSI.dim, "⟳")} Replaying cassette for ${c(ANSI.bold, cassette.targetId)} (${cassette.entries.length} entries)...\n`);
|
|
569
|
-
// Create a target config for the replay
|
|
570
|
-
const replayTarget = {
|
|
571
|
-
targetId: cassette.targetId,
|
|
572
|
-
adapter: "local-process",
|
|
573
|
-
command: "replay",
|
|
574
|
-
args: [],
|
|
575
|
-
};
|
|
576
|
-
// Build a ReplayTransport and run checks against it
|
|
577
|
-
const transport = new ReplayTransport(cassette.entries);
|
|
578
|
-
const client = new Client({ name: "mcp-observatory", version: TOOL_VERSION }, { capabilities: {} });
|
|
579
|
-
await client.connect(transport);
|
|
580
|
-
const serverCapabilities = client.getServerCapabilities();
|
|
581
|
-
const checkContext = {
|
|
582
|
-
client,
|
|
583
|
-
serverCapabilities,
|
|
584
|
-
target: replayTarget,
|
|
585
|
-
timeoutMs: 10_000,
|
|
586
|
-
stderrLines: [],
|
|
587
|
-
};
|
|
588
|
-
const toolsCheck = await runToolsCheck(checkContext);
|
|
589
|
-
const promptsCheck = await runPromptsCheck(checkContext);
|
|
590
|
-
const resourcesCheck = await runResourcesCheck(checkContext);
|
|
591
|
-
const invokeCheck = await runToolsInvokeCheck(checkContext);
|
|
592
|
-
await client.close();
|
|
593
|
-
const checks = [
|
|
594
|
-
toolsCheck.result,
|
|
595
|
-
promptsCheck.result,
|
|
596
|
-
resourcesCheck.result,
|
|
597
|
-
invokeCheck.result,
|
|
598
|
-
];
|
|
599
|
-
const failCount = checks.filter((ch) => ch.status === "fail").length;
|
|
600
|
-
const gate = failCount > 0 ? "fail" : "pass";
|
|
601
|
-
const artifact = {
|
|
602
|
-
artifactType: "run",
|
|
603
|
-
schemaVersion: SCHEMA_VERSION,
|
|
604
|
-
gate,
|
|
605
|
-
runId: buildRunId(),
|
|
606
|
-
createdAt: new Date().toISOString(),
|
|
607
|
-
toolVersion: TOOL_VERSION,
|
|
608
|
-
target: {
|
|
609
|
-
targetId: cassette.targetId,
|
|
610
|
-
adapter: "local-process",
|
|
611
|
-
command: "replay",
|
|
612
|
-
args: [],
|
|
613
|
-
metadata: { source: "cassette", cassettePath },
|
|
614
|
-
},
|
|
615
|
-
environment: {
|
|
616
|
-
platform: `${os.platform()} ${os.release()}`,
|
|
617
|
-
nodeVersion: process.version,
|
|
618
|
-
},
|
|
619
|
-
summary: {
|
|
620
|
-
total: checks.length,
|
|
621
|
-
pass: checks.filter((ch) => ch.status === "pass").length,
|
|
622
|
-
fail: failCount,
|
|
623
|
-
partial: checks.filter((ch) => ch.status === "partial").length,
|
|
624
|
-
unsupported: checks.filter((ch) => ch.status === "unsupported").length,
|
|
625
|
-
flaky: checks.filter((ch) => ch.status === "flaky").length,
|
|
626
|
-
skipped: checks.filter((ch) => ch.status === "skipped").length,
|
|
627
|
-
gate,
|
|
628
|
-
},
|
|
629
|
-
checks,
|
|
630
|
-
};
|
|
631
|
-
process.stdout.write(`\n${renderTerminal(artifact)}\n`);
|
|
632
|
-
process.stdout.write(`\n${c(ANSI.dim, `Replayed from: ${cassettePath}`)}\n\n`);
|
|
633
|
-
if (artifact.gate === "fail") {
|
|
634
|
-
process.exitCode = 1;
|
|
635
|
-
}
|
|
636
|
-
});
|
|
637
|
-
// ── verify ─────────────────────────────────────────────────────────────
|
|
638
|
-
program
|
|
639
|
-
.command("verify")
|
|
640
|
-
.passThroughOptions()
|
|
641
|
-
.description("Verify a live server still matches a recorded cassette.")
|
|
642
|
-
.argument("<cassette>", "Path to a cassette JSON file.")
|
|
643
|
-
.argument("[command...]", "Server command and arguments to run.")
|
|
644
|
-
.option("--target <config>", "Path to a target config JSON file.")
|
|
645
|
-
.option("--no-color", "Disable colored output.")
|
|
646
|
-
.action(async (cassettePath, commandArgs, options) => {
|
|
647
|
-
const cassette = await loadCassette(cassettePath);
|
|
648
|
-
const target = options.target
|
|
649
|
-
? await readTargetConfig(options.target)
|
|
650
|
-
: targetFromCommand(commandArgs.length > 0 ? commandArgs : getPassthroughArgs());
|
|
651
|
-
process.stdout.write(`${c(ANSI.dim, "⟳")} Verifying ${c(ANSI.bold, target.targetId)} against cassette...\n`);
|
|
652
|
-
const { cassetteEntries } = await runTargetRecording(target, { invokeTools: true });
|
|
653
|
-
if (!cassetteEntries) {
|
|
654
|
-
process.stdout.write(`${c(ANSI.red, "✗")} Failed to record live session for comparison.\n`);
|
|
655
|
-
process.exitCode = 1;
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
const verifyResult = compareResponses(cassette, cassetteEntries);
|
|
659
|
-
process.stdout.write("\n");
|
|
660
|
-
for (const entry of verifyResult.entries) {
|
|
661
|
-
if (entry.status === "pass") {
|
|
662
|
-
process.stdout.write(` ${c(ANSI.green, "✓")} ${entry.method}\n`);
|
|
663
|
-
}
|
|
664
|
-
else if (entry.status === "fail") {
|
|
665
|
-
process.stdout.write(` ${c(ANSI.red, "✗")} ${entry.method}\n`);
|
|
666
|
-
if (entry.diff) {
|
|
667
|
-
for (const line of entry.diff.split("\n")) {
|
|
668
|
-
process.stdout.write(` ${c(ANSI.dim, line)}\n`);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
else {
|
|
673
|
-
process.stdout.write(` ${c(ANSI.yellow, "?")} ${entry.method} ${c(ANSI.dim, "(missing — server did not respond)")}\n`);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
process.stdout.write("\n");
|
|
677
|
-
if (verifyResult.failed === 0 && verifyResult.missing === 0) {
|
|
678
|
-
process.stdout.write(c(ANSI.green, ` ✓ All ${verifyResult.passed} responses match cassette\n`));
|
|
238
|
+
const submission = smithery.generateSubmission(qualifiedName, artifact);
|
|
239
|
+
const md = smithery.renderSubmissionMarkdown(submission);
|
|
240
|
+
if (options.output) {
|
|
241
|
+
await writeFile(options.output, md, "utf8");
|
|
242
|
+
process.stdout.write(` Report written to ${options.output}\n`);
|
|
679
243
|
}
|
|
680
244
|
else {
|
|
681
|
-
process.stdout.write(
|
|
682
|
-
process.exitCode = 1;
|
|
245
|
+
process.stdout.write(`\n${md}\n`);
|
|
683
246
|
}
|
|
684
|
-
process.stdout.write("\n");
|
|
685
247
|
});
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
.
|
|
689
|
-
.
|
|
690
|
-
.option("--
|
|
691
|
-
.option("--
|
|
692
|
-
.option("--
|
|
693
|
-
.option("--interval <seconds>", "Interval in seconds for watch mode.", "30")
|
|
694
|
-
.option("--invoke-tools", "Actually call safe tools to verify they execute.", false)
|
|
695
|
-
.option("--no-color", "Disable colored output.")
|
|
248
|
+
smitheryCmd
|
|
249
|
+
.command("batch")
|
|
250
|
+
.description("Scan top N servers from the Smithery registry and generate a comparative report.")
|
|
251
|
+
.option("--top <n>", "Number of servers to scan.", "10")
|
|
252
|
+
.option("--output <path>", "Write report to file instead of stdout.")
|
|
253
|
+
.option("--api-key <key>", "Smithery API key.")
|
|
254
|
+
.option("--base-url <url>", "Override Smithery API base URL.")
|
|
696
255
|
.action(async (options) => {
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
}
|
|
702
|
-
const
|
|
703
|
-
const outPath = await writeRunArtifact(artifact, options.outDir);
|
|
704
|
-
const summary = renderTerminal(artifact);
|
|
705
|
-
process.stdout.write(`${summary}\nArtifact: ${outPath}\n`);
|
|
706
|
-
if (artifact.gate === "fail") {
|
|
707
|
-
process.exitCode = 1;
|
|
708
|
-
}
|
|
709
|
-
});
|
|
710
|
-
program
|
|
711
|
-
.command("check", { hidden: true })
|
|
712
|
-
.description("Run a single capability check.")
|
|
713
|
-
.argument("<capability>", "tools, prompts, resources, or tools-invoke.")
|
|
714
|
-
.option("--target <config>", "Path to a target config JSON file.")
|
|
715
|
-
.option("--no-color", "Disable colored output.")
|
|
716
|
-
.action(async (capability, options) => {
|
|
717
|
-
const validCapabilities = ["tools", "prompts", "resources", "tools-invoke"];
|
|
718
|
-
if (!validCapabilities.includes(capability)) {
|
|
719
|
-
throw new Error(`Invalid capability '${capability}'. Must be one of: ${validCapabilities.join(", ")}`);
|
|
720
|
-
}
|
|
721
|
-
const target = await resolveTarget(options);
|
|
722
|
-
const invokeTools = capability === "tools-invoke";
|
|
723
|
-
const artifact = await runTarget(target, { invokeTools });
|
|
724
|
-
const check = artifact.checks.find((ch) => ch.id === capability);
|
|
725
|
-
if (!check) {
|
|
726
|
-
throw new Error(`Check '${capability}' was not found in the run results.`);
|
|
727
|
-
}
|
|
728
|
-
const statusStr = colorStatus(check.status);
|
|
729
|
-
process.stdout.write(`${c(ANSI.bold, capability)}: ${statusStr}\n`);
|
|
730
|
-
process.stdout.write(`${check.message}\n`);
|
|
731
|
-
if (check.evidence.length > 0) {
|
|
732
|
-
for (const ev of check.evidence) {
|
|
733
|
-
if (ev.identifiers && ev.identifiers.length > 0) {
|
|
734
|
-
process.stdout.write(`Items: ${ev.identifiers.join(", ")}\n`);
|
|
735
|
-
}
|
|
736
|
-
if (ev.diagnostics && ev.diagnostics.length > 0) {
|
|
737
|
-
process.stdout.write(`Diagnostics: ${ev.diagnostics.join("; ")}\n`);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
});
|
|
742
|
-
program
|
|
743
|
-
.command("report", { hidden: true })
|
|
744
|
-
.description("Render a run artifact.")
|
|
745
|
-
.requiredOption("--run <artifact>", "Run artifact JSON.")
|
|
746
|
-
.option("--format <format>", "terminal, markdown, json, or html", "terminal")
|
|
747
|
-
.option("--output <file>", "Write to file instead of stdout.")
|
|
748
|
-
.option("--no-color", "Disable colored output.")
|
|
749
|
-
.action(async (options) => {
|
|
750
|
-
const artifact = await readArtifact(options.run);
|
|
751
|
-
if (artifact.artifactType !== "run") {
|
|
752
|
-
throw new Error("The report command only accepts run artifacts.");
|
|
753
|
-
}
|
|
754
|
-
const output = formatOutput(artifact, options.format);
|
|
755
|
-
await writeOutput(output, options.format, options.output);
|
|
756
|
-
});
|
|
757
|
-
// ── score ────────────────────────────────────────────────────────────
|
|
758
|
-
program
|
|
759
|
-
.command("score")
|
|
760
|
-
.passThroughOptions()
|
|
761
|
-
.description("Score an MCP server's health (0-100).")
|
|
762
|
-
.argument("<command...>", "Server command and arguments to run.")
|
|
763
|
-
.option("--format <format>", "terminal, json, junit, markdown, html, or sarif", "terminal")
|
|
764
|
-
.option("--output <file>", "Write to file instead of stdout.")
|
|
765
|
-
.option("--no-color", "Disable colored output.")
|
|
766
|
-
.action(async (commandArgs, options) => {
|
|
767
|
-
const target = targetFromCommand(commandArgs);
|
|
768
|
-
process.stdout.write(`${c(ANSI.dim, "⟳")} Scoring ${c(ANSI.bold, target.targetId)}...\n\n`);
|
|
769
|
-
const artifact = await runTarget(target, { invokeTools: true, securityCheck: true });
|
|
770
|
-
await writeRunArtifact(artifact, defaultRunsDirectory(process.cwd()));
|
|
771
|
-
if (options.format !== "terminal") {
|
|
772
|
-
const output = formatOutput(artifact, options.format);
|
|
773
|
-
await writeOutput(output, options.format, options.output);
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
const score = artifact.healthScore;
|
|
777
|
-
if (!score) {
|
|
778
|
-
process.stdout.write(" Could not compute health score.\n\n");
|
|
779
|
-
return;
|
|
780
|
-
}
|
|
781
|
-
const gradeColor = score.grade === "A" || score.grade === "B" ? ANSI.green
|
|
782
|
-
: score.grade === "C" ? ANSI.yellow
|
|
783
|
-
: ANSI.red;
|
|
784
|
-
process.stdout.write(c(ANSI.bold, ` MCP Health Score: ${c(gradeColor, `${score.overall}/100`)} (${c(gradeColor, score.grade)})\n\n`));
|
|
785
|
-
for (const dim of score.dimensions) {
|
|
786
|
-
const filled = Math.round(dim.score / 5);
|
|
787
|
-
const empty = 20 - filled;
|
|
788
|
-
const bar = "█".repeat(filled) + "░".repeat(empty);
|
|
789
|
-
const dimColor = dim.score >= 80 ? ANSI.green : dim.score >= 60 ? ANSI.yellow : ANSI.red;
|
|
790
|
-
const weightPct = Math.round(dim.weight * 100);
|
|
791
|
-
process.stdout.write(` ${dim.name.padEnd(22)} ${c(dimColor, bar)} ${String(dim.score).padStart(3)} ${c(ANSI.dim, `(weight: ${weightPct}%)`)}\n`);
|
|
792
|
-
}
|
|
793
|
-
process.stdout.write("\n");
|
|
794
|
-
// Show details for dimensions that aren't perfect
|
|
795
|
-
for (const dim of score.dimensions) {
|
|
796
|
-
if (dim.score < 100 && dim.details.length > 0) {
|
|
797
|
-
process.stdout.write(` ${c(ANSI.dim, dim.name + ":")}\n`);
|
|
798
|
-
for (const detail of dim.details) {
|
|
799
|
-
process.stdout.write(` ${c(ANSI.dim, "→")} ${detail}\n`);
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
process.stdout.write("\n");
|
|
804
|
-
if (artifact.gate === "fail") {
|
|
805
|
-
process.exitCode = 1;
|
|
806
|
-
}
|
|
807
|
-
});
|
|
808
|
-
// ── badge ───────────────────────────────────────────────────────────
|
|
809
|
-
program
|
|
810
|
-
.command("badge")
|
|
811
|
-
.passThroughOptions()
|
|
812
|
-
.description("Generate an SVG health score badge for your README.")
|
|
813
|
-
.argument("<command...>", "Server command and arguments to run.")
|
|
814
|
-
.option("--output <file>", "Write SVG to file (default: stdout).")
|
|
815
|
-
.option("--label <text>", "Badge label text.", "MCP Health")
|
|
816
|
-
.action(async (commandArgs, options) => {
|
|
817
|
-
const target = targetFromCommand(commandArgs);
|
|
818
|
-
process.stderr.write(`${c(ANSI.dim, "⟳")} Scoring ${c(ANSI.bold, target.targetId)}...\n`);
|
|
819
|
-
const artifact = await runTarget(target, { invokeTools: true, securityCheck: true });
|
|
820
|
-
const score = artifact.healthScore;
|
|
821
|
-
if (!score) {
|
|
822
|
-
process.stderr.write(" Could not compute health score.\n");
|
|
823
|
-
process.exitCode = 1;
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
const svg = generateBadgeSvg({ score: score.overall, grade: score.grade, label: options.label });
|
|
256
|
+
const smithery = await import("./integrations/smithery.js");
|
|
257
|
+
const smitheryConfig = { apiKey: options.apiKey, baseUrl: options.baseUrl };
|
|
258
|
+
const top = parseInt(options.top, 10) || 10;
|
|
259
|
+
process.stdout.write(` Scanning top ${top} servers from Smithery registry...\n`);
|
|
260
|
+
const results = await smithery.batchScanServers((target) => runTarget(target, {}), smitheryConfig, { top });
|
|
261
|
+
const md = smithery.renderBatchReportMarkdown(results);
|
|
827
262
|
if (options.output) {
|
|
828
|
-
await
|
|
829
|
-
|
|
830
|
-
process.stderr.write(` Badge written to ${options.output}\n`);
|
|
831
|
-
}
|
|
832
|
-
else {
|
|
833
|
-
process.stdout.write(svg);
|
|
834
|
-
}
|
|
835
|
-
});
|
|
836
|
-
// ── telemetry ──────────────────────────────────────────────────────────
|
|
837
|
-
program
|
|
838
|
-
.command("telemetry")
|
|
839
|
-
.description("Manage anonymous usage telemetry.")
|
|
840
|
-
.argument("[action]", "enable, disable, stats, or status (default: status)")
|
|
841
|
-
.action(async (action) => {
|
|
842
|
-
const config = await loadTelemetryConfig();
|
|
843
|
-
const envDisabled = process.env["DO_NOT_TRACK"] === "1" ||
|
|
844
|
-
process.env["MCP_OBSERVATORY_TELEMETRY_DISABLED"] === "1";
|
|
845
|
-
if (action === "enable") {
|
|
846
|
-
config.telemetryEnabled = true;
|
|
847
|
-
await saveTelemetryConfig(config);
|
|
848
|
-
process.stdout.write(" Telemetry enabled.\n\n");
|
|
849
|
-
}
|
|
850
|
-
else if (action === "disable") {
|
|
851
|
-
config.telemetryEnabled = false;
|
|
852
|
-
await saveTelemetryConfig(config);
|
|
853
|
-
process.stdout.write(" Telemetry disabled.\n\n");
|
|
854
|
-
}
|
|
855
|
-
else if (action === "stats") {
|
|
856
|
-
const endpoint = process.env["MCP_OBSERVATORY_TELEMETRY_URL"] ?? "https://mcp-observatory-telemetry.kryptosai.workers.dev";
|
|
857
|
-
const token = process.env["MCP_OBSERVATORY_STATS_TOKEN"] ?? config.statsToken;
|
|
858
|
-
if (!token) {
|
|
859
|
-
process.stderr.write(" No stats token configured.\n");
|
|
860
|
-
process.stderr.write(" Set MCP_OBSERVATORY_STATS_TOKEN or add \"statsToken\" to ~/.mcp-observatory/config.json\n\n");
|
|
861
|
-
return;
|
|
862
|
-
}
|
|
863
|
-
const authHeaders = { Authorization: `Bearer ${token}` };
|
|
864
|
-
try {
|
|
865
|
-
const [all, others] = await Promise.all([
|
|
866
|
-
fetch(`${endpoint}/v1/stats`, { headers: authHeaders }).then(r => r.json()),
|
|
867
|
-
fetch(`${endpoint}/v1/stats?exclude=${config.sessionId}`, { headers: authHeaders }).then(r => r.json()),
|
|
868
|
-
]);
|
|
869
|
-
if (all.error) {
|
|
870
|
-
process.stderr.write(` Error: ${all.error}\n\n`);
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
const totalAll = all.total ?? 0;
|
|
874
|
-
const totalOthers = others.total ?? 0;
|
|
875
|
-
const you = totalAll - totalOthers;
|
|
876
|
-
const sessionsAll = all.uniqueSessions ?? 0;
|
|
877
|
-
const sessionsOthers = others.uniqueSessions ?? 0;
|
|
878
|
-
process.stdout.write(` Total events: ${totalAll}\n`);
|
|
879
|
-
process.stdout.write(` Your events: ${you}\n`);
|
|
880
|
-
process.stdout.write(` Other events: ${totalOthers}\n`);
|
|
881
|
-
process.stdout.write(` Unique sessions: ${sessionsAll} (${sessionsOthers} excluding you)\n`);
|
|
882
|
-
process.stdout.write(` Last 24h: ${all.last24h ?? 0} (${others.last24h ?? 0} excluding you)\n\n`);
|
|
883
|
-
}
|
|
884
|
-
catch {
|
|
885
|
-
process.stderr.write(" Failed to fetch telemetry stats.\n\n");
|
|
886
|
-
}
|
|
263
|
+
await writeFile(options.output, md, "utf8");
|
|
264
|
+
process.stdout.write(` Batch report written to ${options.output}\n`);
|
|
887
265
|
}
|
|
888
266
|
else {
|
|
889
|
-
|
|
890
|
-
process.stdout.write(` Telemetry: ${effective ? "enabled" : "disabled"}\n`);
|
|
891
|
-
process.stdout.write(` Config: telemetryEnabled=${String(config.telemetryEnabled)}\n`);
|
|
892
|
-
if (envDisabled) {
|
|
893
|
-
process.stdout.write(` Override: disabled via environment variable\n`);
|
|
894
|
-
}
|
|
895
|
-
process.stdout.write(` Session: ${config.sessionId}\n\n`);
|
|
267
|
+
process.stdout.write(`\n${md}\n`);
|
|
896
268
|
}
|
|
897
269
|
});
|
|
898
270
|
// Interactive menu when invoked with no arguments
|
|
@@ -907,174 +279,6 @@ async function main() {
|
|
|
907
279
|
recordEvent(buildEvent("command_run", commandName, "cli"));
|
|
908
280
|
await program.parseAsync(process.argv);
|
|
909
281
|
}
|
|
910
|
-
// ── Scan implementation ─────────────────────────────────────────────────────
|
|
911
|
-
async function runScan(bin, configPath, invokeTools, securityCheck) {
|
|
912
|
-
process.stdout.write(useColor() ? c(ANSI.cyan, LOGO) + ` ${c(ANSI.dim, `v${TOOL_VERSION}`)}\n\n` : LOGO + ` v${TOOL_VERSION}\n\n`);
|
|
913
|
-
if (configPath) {
|
|
914
|
-
try {
|
|
915
|
-
await access(configPath);
|
|
916
|
-
}
|
|
917
|
-
catch {
|
|
918
|
-
process.stdout.write(c(ANSI.red, ` ✗ Config file not found: ${configPath}\n\n`));
|
|
919
|
-
process.exitCode = 1;
|
|
920
|
-
return;
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
const targets = await scanForTargets(configPath);
|
|
924
|
-
if (targets.length === 0) {
|
|
925
|
-
process.stdout.write(c(ANSI.yellow, " No MCP servers found.\n\n"));
|
|
926
|
-
process.stdout.write(c(ANSI.dim, " Looked in ~/.claude.json, Claude Desktop config, .mcp.json\n\n"));
|
|
927
|
-
process.stdout.write(" Test a specific server:\n");
|
|
928
|
-
process.stdout.write(` ${c(ANSI.dim, "$")} ${c(ANSI.cyan, `${bin} test npx -y @modelcontextprotocol/server-filesystem .`)}\n\n`);
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
process.stdout.write(c(ANSI.bold, ` Found ${targets.length} MCP server${targets.length === 1 ? "" : "s"}:\n`));
|
|
932
|
-
for (const t of targets) {
|
|
933
|
-
process.stdout.write(` ${c(ANSI.cyan, "●")} ${c(ANSI.bold, t.config.targetId)} ${c(ANSI.dim, `← ${t.source}`)}\n`);
|
|
934
|
-
}
|
|
935
|
-
process.stdout.write("\n");
|
|
936
|
-
const results = [];
|
|
937
|
-
let passCount = 0;
|
|
938
|
-
let failCount = 0;
|
|
939
|
-
let totalTools = 0;
|
|
940
|
-
let totalPrompts = 0;
|
|
941
|
-
let totalResources = 0;
|
|
942
|
-
for (const t of targets) {
|
|
943
|
-
process.stdout.write(` ${c(ANSI.dim, "⟳")} Checking ${c(ANSI.bold, t.config.targetId)}...`);
|
|
944
|
-
try {
|
|
945
|
-
const artifact = await runTarget(t.config, { invokeTools, securityCheck });
|
|
946
|
-
const toolsCheck = artifact.checks.find((ch) => ch.id === "tools");
|
|
947
|
-
const promptsCheck = artifact.checks.find((ch) => ch.id === "prompts");
|
|
948
|
-
const resourcesCheck = artifact.checks.find((ch) => ch.id === "resources");
|
|
949
|
-
const toolCount = toolsCheck?.evidence[0]?.itemCount ?? 0;
|
|
950
|
-
const promptCount = promptsCheck?.evidence[0]?.itemCount ?? 0;
|
|
951
|
-
const resourceCount = resourcesCheck?.evidence[0]?.itemCount ?? 0;
|
|
952
|
-
totalTools += toolCount;
|
|
953
|
-
totalPrompts += promptCount;
|
|
954
|
-
totalResources += resourceCount;
|
|
955
|
-
const diagnostics = [];
|
|
956
|
-
for (const check of artifact.checks) {
|
|
957
|
-
if (check.status === "fail" || check.status === "partial") {
|
|
958
|
-
diagnostics.push(`${check.id}: ${check.message}`);
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
const gateIcon = artifact.gate === "pass" ? c(ANSI.green, " ✓") : c(ANSI.red, " ✗");
|
|
962
|
-
process.stdout.write(`\r ${gateIcon} ${c(ANSI.bold, t.config.targetId)}${" ".repeat(Math.max(1, 40 - t.config.targetId.length))}`);
|
|
963
|
-
process.stdout.write(`${c(ANSI.dim, `${toolCount} tools, ${promptCount} prompts, ${resourceCount} resources`)}\n`);
|
|
964
|
-
if (artifact.fatalError) {
|
|
965
|
-
process.stdout.write(` ${c(ANSI.red, "→")} ${artifact.fatalError.split("\n")[0]}\n`);
|
|
966
|
-
}
|
|
967
|
-
else if (artifact.gate === "fail" && diagnostics.length > 0) {
|
|
968
|
-
process.stdout.write(` ${c(ANSI.dim, "→")} ${diagnostics[0]}\n`);
|
|
969
|
-
}
|
|
970
|
-
results.push({ targetId: t.config.targetId, gate: artifact.gate, toolCount, promptCount, resourceCount, diagnostics });
|
|
971
|
-
if (artifact.gate === "pass")
|
|
972
|
-
passCount++;
|
|
973
|
-
else
|
|
974
|
-
failCount++;
|
|
975
|
-
}
|
|
976
|
-
catch (error) {
|
|
977
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
978
|
-
let friendlyMsg = msg;
|
|
979
|
-
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
980
|
-
const cmd = t.config.adapter === "http" ? t.config.url : t.config.command;
|
|
981
|
-
friendlyMsg = `Could not start server — "${cmd}" not found. Is it installed?`;
|
|
982
|
-
}
|
|
983
|
-
else if (msg.includes("ECONNREFUSED")) {
|
|
984
|
-
friendlyMsg = `Server is not running or refused the connection.`;
|
|
985
|
-
}
|
|
986
|
-
else if (msg.includes("timed out") || msg.includes("timeout")) {
|
|
987
|
-
friendlyMsg = `Server took too long to respond.`;
|
|
988
|
-
}
|
|
989
|
-
process.stdout.write(`\r ${c(ANSI.red, "✗")} ${c(ANSI.bold, t.config.targetId)}\n`);
|
|
990
|
-
process.stdout.write(` ${c(ANSI.red, friendlyMsg)}\n`);
|
|
991
|
-
results.push({ targetId: t.config.targetId, gate: "fail", toolCount: 0, promptCount: 0, resourceCount: 0, error: friendlyMsg, diagnostics: [] });
|
|
992
|
-
failCount++;
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
// ── Summary ──────────────────────────────────────────────────────────
|
|
996
|
-
process.stdout.write("\n");
|
|
997
|
-
if (failCount === 0) {
|
|
998
|
-
process.stdout.write(c(ANSI.green, ` ✓ All ${passCount} server${passCount === 1 ? "" : "s"} healthy`));
|
|
999
|
-
process.stdout.write(c(ANSI.dim, ` — ${totalTools} tools, ${totalPrompts} prompts, ${totalResources} resources\n`));
|
|
1000
|
-
}
|
|
1001
|
-
else {
|
|
1002
|
-
process.stdout.write(c(ANSI.red, ` ✗ ${failCount} of ${passCount + failCount} server${passCount + failCount === 1 ? "" : "s"} failing`));
|
|
1003
|
-
if (totalTools > 0 || totalPrompts > 0 || totalResources > 0) {
|
|
1004
|
-
process.stdout.write(c(ANSI.dim, ` — ${totalTools} tools, ${totalPrompts} prompts, ${totalResources} resources found\n`));
|
|
1005
|
-
}
|
|
1006
|
-
else {
|
|
1007
|
-
process.stdout.write("\n");
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
// Show diagnostics for failures or notable partials
|
|
1011
|
-
const issues = results.filter((r) => r.diagnostics.length > 0 && !r.error);
|
|
1012
|
-
if (issues.length > 0) {
|
|
1013
|
-
process.stdout.write("\n");
|
|
1014
|
-
for (const r of issues) {
|
|
1015
|
-
process.stdout.write(` ${c(ANSI.yellow, r.targetId)}:\n`);
|
|
1016
|
-
for (const d of r.diagnostics.slice(0, 3)) {
|
|
1017
|
-
process.stdout.write(` ${c(ANSI.dim, "→")} ${d}\n`);
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
// ── Next step ────────────────────────────────────────────────────────
|
|
1022
|
-
process.stdout.write("\n");
|
|
1023
|
-
if (!invokeTools && totalTools > 0) {
|
|
1024
|
-
process.stdout.write(c(ANSI.dim, ` Next: ${c(ANSI.cyan, `${bin} scan deep`)} to also test that tools run\n`));
|
|
1025
|
-
}
|
|
1026
|
-
else {
|
|
1027
|
-
process.stdout.write(c(ANSI.dim, ` Run ${c(ANSI.cyan, `${bin} --help`)} for more commands\n`));
|
|
1028
|
-
}
|
|
1029
|
-
process.stdout.write("\n");
|
|
1030
|
-
if (failCount > 0) {
|
|
1031
|
-
process.exitCode = 1;
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
1035
|
-
function colorStatus(status) {
|
|
1036
|
-
switch (status) {
|
|
1037
|
-
case "pass":
|
|
1038
|
-
return c(ANSI.green, status);
|
|
1039
|
-
case "fail":
|
|
1040
|
-
return c(ANSI.red, status);
|
|
1041
|
-
case "partial":
|
|
1042
|
-
case "flaky":
|
|
1043
|
-
return c(ANSI.yellow, status);
|
|
1044
|
-
case "unsupported":
|
|
1045
|
-
case "skipped":
|
|
1046
|
-
return c(ANSI.dim, status);
|
|
1047
|
-
default:
|
|
1048
|
-
return status;
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
async function runWatchMode(target, outDir, intervalSeconds) {
|
|
1052
|
-
const { diffArtifacts: diff } = await import("./diff.js");
|
|
1053
|
-
process.stdout.write(`Watch mode: checking every ${intervalSeconds}s. Press Ctrl+C to stop.\n\n`);
|
|
1054
|
-
let previousArtifact = await runTarget(target);
|
|
1055
|
-
await writeRunArtifact(previousArtifact, outDir);
|
|
1056
|
-
process.stdout.write(`${renderTerminal(previousArtifact)}\n\n`);
|
|
1057
|
-
const loop = async () => {
|
|
1058
|
-
await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000));
|
|
1059
|
-
const currentArtifact = await runTarget(target);
|
|
1060
|
-
const diffResult = diff(previousArtifact, currentArtifact);
|
|
1061
|
-
if (diffResult.summary.regressions > 0 || diffResult.summary.recoveries > 0 || diffResult.summary.added > 0 || diffResult.summary.removed > 0) {
|
|
1062
|
-
const outPath = await writeRunArtifact(currentArtifact, outDir);
|
|
1063
|
-
process.stdout.write(`\n--- Change detected at ${currentArtifact.createdAt} ---\n`);
|
|
1064
|
-
process.stdout.write(`${renderTerminal(diffResult)}\n`);
|
|
1065
|
-
process.stdout.write(`Artifact: ${outPath}\n\n`);
|
|
1066
|
-
}
|
|
1067
|
-
previousArtifact = currentArtifact;
|
|
1068
|
-
void loop();
|
|
1069
|
-
};
|
|
1070
|
-
void loop();
|
|
1071
|
-
await new Promise((resolve) => {
|
|
1072
|
-
process.on("SIGINT", () => {
|
|
1073
|
-
process.stdout.write("\nWatch mode stopped.\n");
|
|
1074
|
-
resolve();
|
|
1075
|
-
});
|
|
1076
|
-
});
|
|
1077
|
-
}
|
|
1078
282
|
void main().catch((error) => {
|
|
1079
283
|
const message = error instanceof Error ? error.message : String(error);
|
|
1080
284
|
let friendly = message;
|