mcp-medic 1.0.0

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/action.yml +57 -0
  4. package/dist/checks/index.d.ts +6 -0
  5. package/dist/checks/index.js +17 -0
  6. package/dist/checks/malformed-schema.d.ts +2 -0
  7. package/dist/checks/malformed-schema.js +63 -0
  8. package/dist/checks/missing-description.d.ts +2 -0
  9. package/dist/checks/missing-description.js +71 -0
  10. package/dist/checks/missing-required-fields.d.ts +2 -0
  11. package/dist/checks/missing-required-fields.js +55 -0
  12. package/dist/checks/sample-call-simulation.d.ts +2 -0
  13. package/dist/checks/sample-call-simulation.js +239 -0
  14. package/dist/checks/type-mismatch.d.ts +2 -0
  15. package/dist/checks/type-mismatch.js +154 -0
  16. package/dist/cli.d.ts +20 -0
  17. package/dist/cli.js +457 -0
  18. package/dist/config-loader.d.ts +6 -0
  19. package/dist/config-loader.js +77 -0
  20. package/dist/conformance.d.ts +10 -0
  21. package/dist/conformance.js +112 -0
  22. package/dist/discovery.d.ts +9 -0
  23. package/dist/discovery.js +76 -0
  24. package/dist/extension/index.d.ts +79 -0
  25. package/dist/extension/index.js +125 -0
  26. package/dist/fleet.d.ts +48 -0
  27. package/dist/fleet.js +153 -0
  28. package/dist/index.d.ts +20 -0
  29. package/dist/index.js +13 -0
  30. package/dist/junit.d.ts +10 -0
  31. package/dist/junit.js +87 -0
  32. package/dist/orchestrator.d.ts +6 -0
  33. package/dist/orchestrator.js +60 -0
  34. package/dist/policy.d.ts +16 -0
  35. package/dist/policy.js +143 -0
  36. package/dist/protocol/connect.d.ts +2 -0
  37. package/dist/protocol/connect.js +417 -0
  38. package/dist/protocol/index.d.ts +3 -0
  39. package/dist/protocol/index.js +6 -0
  40. package/dist/registry.d.ts +16 -0
  41. package/dist/registry.js +87 -0
  42. package/dist/report.d.ts +7 -0
  43. package/dist/report.js +30 -0
  44. package/dist/types.d.ts +70 -0
  45. package/dist/types.js +4 -0
  46. package/dist/watch.d.ts +12 -0
  47. package/dist/watch.js +85 -0
  48. package/package.json +56 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Resolves a published MCP server descriptor or registry identifier into an MCPServerConfig.
3
+ *
4
+ * Supported formats:
5
+ * - Direct HTTP/SSE URL: "https://mcp.example.com/sse" -> sse/http config
6
+ * - NPM/Npx package: "npm:@modelcontextprotocol/server-memory" or "@modelcontextprotocol/server-memory" -> stdio npx
7
+ * - PyPI/Uvx package: "pypi:mcp-server-git" or "uvx:mcp-server-git" -> stdio uvx
8
+ * - Registry URL returning JSON server config: "https://registry.example.com/servers/my-tool.json"
9
+ * - Smithery / Glama server ID: "smithery:username/server-name"
10
+ */
11
+ export async function resolveRegistryServer(registryId, options = {}) {
12
+ const trimmed = registryId.trim();
13
+ const fetchImpl = options.fetchFn ?? fetch;
14
+ if (!trimmed) {
15
+ throw new Error('Registry server identifier cannot be empty');
16
+ }
17
+ // 1. Direct URL (HTTP / SSE / JSON manifest)
18
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
19
+ const url = new URL(trimmed);
20
+ // If URL points to a JSON manifest
21
+ if (url.pathname.endsWith('.json')) {
22
+ const res = await fetchImpl(trimmed, {
23
+ headers: { Accept: 'application/json' },
24
+ signal: AbortSignal.timeout(options.timeoutMs ?? 5000),
25
+ });
26
+ if (!res.ok) {
27
+ throw new Error(`Failed to fetch registry descriptor from ${trimmed}: HTTP ${res.status}`);
28
+ }
29
+ const data = (await res.json());
30
+ if (typeof data.command === 'string' || typeof data.url === 'string') {
31
+ return {
32
+ name: (typeof data.name === 'string' && data.name) || url.pathname.split('/').pop()?.replace('.json', '') || 'registry-server',
33
+ transport: data.transport || (data.url ? (data.url.toString().includes('sse') ? 'sse' : 'http') : 'stdio'),
34
+ command: typeof data.command === 'string' ? data.command : undefined,
35
+ args: Array.isArray(data.args) ? data.args : undefined,
36
+ env: data.env || undefined,
37
+ url: typeof data.url === 'string' ? data.url : undefined,
38
+ headers: data.headers || undefined,
39
+ };
40
+ }
41
+ }
42
+ // Default URL endpoint transport (SSE if contains 'sse', otherwise HTTP)
43
+ const isSse = url.pathname.includes('sse') || url.searchParams.has('sse');
44
+ return {
45
+ name: url.hostname + url.pathname.replace(/\/$/, ''),
46
+ transport: isSse ? 'sse' : 'http',
47
+ url: trimmed,
48
+ };
49
+ }
50
+ // 2. PyPI / uvx package prefix
51
+ if (trimmed.startsWith('pypi:') || trimmed.startsWith('uvx:')) {
52
+ const pkg = trimmed.replace(/^(pypi:|uvx:)/, '');
53
+ return {
54
+ name: pkg,
55
+ transport: 'stdio',
56
+ command: 'uvx',
57
+ args: [pkg],
58
+ };
59
+ }
60
+ // 3. NPM package prefix
61
+ if (trimmed.startsWith('npm:')) {
62
+ const pkg = trimmed.slice(4);
63
+ return {
64
+ name: pkg,
65
+ transport: 'stdio',
66
+ command: 'npx',
67
+ args: ['-y', pkg],
68
+ };
69
+ }
70
+ // 4. Smithery / Glama community identifier
71
+ if (trimmed.startsWith('smithery:')) {
72
+ const serverName = trimmed.slice(9);
73
+ return {
74
+ name: `smithery/${serverName}`,
75
+ transport: 'stdio',
76
+ command: 'npx',
77
+ args: ['-y', `@smithery/cli@latest`, 'run', serverName],
78
+ };
79
+ }
80
+ // 5. Default scoped or standard npm package identifier (e.g. "@modelcontextprotocol/server-filesystem")
81
+ return {
82
+ name: trimmed,
83
+ transport: 'stdio',
84
+ command: 'npx',
85
+ args: ['-y', trimmed],
86
+ };
87
+ }
@@ -0,0 +1,7 @@
1
+ import type { RunReport } from './types.js';
2
+ export interface FormatReportOptions {
3
+ showFixes?: boolean;
4
+ }
5
+ /** Human-readable plain text report formatter. */
6
+ export declare function formatReportHuman(report: RunReport, options?: FormatReportOptions): string;
7
+ export declare function formatReportJSON(report: RunReport): string;
package/dist/report.js ADDED
@@ -0,0 +1,30 @@
1
+ /** Human-readable plain text report formatter. */
2
+ export function formatReportHuman(report, options = {}) {
3
+ const lines = [];
4
+ lines.push(`mcp-doctor report${report.configSource ? ` — ${report.configSource}` : ''}`);
5
+ lines.push(`${report.summary.connected}/${report.summary.servers} servers connected, ` +
6
+ `${report.summary.errors} error(s), ${report.summary.warnings} warning(s)`);
7
+ lines.push('');
8
+ for (const conn of report.connections) {
9
+ const status = conn.status === 'connected' ? 'OK' : conn.status.toUpperCase();
10
+ lines.push(`[${status}] ${conn.server.name} (${conn.server.transport})`);
11
+ if (conn.error) {
12
+ lines.push(` ${conn.error.stage}: ${conn.error.message}`);
13
+ }
14
+ }
15
+ if (report.diagnostics.length > 0) {
16
+ lines.push('');
17
+ lines.push('Diagnostics:');
18
+ for (const d of report.diagnostics) {
19
+ const scope = d.toolName ? `${d.serverName}/${d.toolName}` : d.serverName;
20
+ lines.push(` [${d.severity}] ${scope} — ${d.message} (${d.checkId})`);
21
+ if (options.showFixes && d.suggestedFix?.description) {
22
+ lines.push(` Suggested fix: ${d.suggestedFix.description}`);
23
+ }
24
+ }
25
+ }
26
+ return lines.join('\n');
27
+ }
28
+ export function formatReportJSON(report) {
29
+ return JSON.stringify(report, null, 2);
30
+ }
@@ -0,0 +1,70 @@
1
+ export type TransportType = 'stdio' | 'sse' | 'http';
2
+ export interface MCPServerConfig {
3
+ name: string;
4
+ transport: TransportType;
5
+ command?: string;
6
+ args?: string[];
7
+ env?: Record<string, string>;
8
+ url?: string;
9
+ headers?: Record<string, string>;
10
+ tokenRefreshUrl?: string;
11
+ tokenRefreshBody?: Record<string, unknown>;
12
+ }
13
+ export interface MCPConfig {
14
+ servers: MCPServerConfig[];
15
+ sourcePath?: string;
16
+ }
17
+ export interface MCPToolDefinition {
18
+ name: string;
19
+ description?: string;
20
+ inputSchema: unknown;
21
+ }
22
+ export interface MCPConnection {
23
+ server: MCPServerConfig;
24
+ status: 'connected' | 'failed' | 'timeout';
25
+ capabilities?: Record<string, unknown>;
26
+ tools?: MCPToolDefinition[];
27
+ error?: {
28
+ stage: 'spawn' | 'handshake' | 'capability-negotiation' | 'list-tools';
29
+ message: string;
30
+ raw?: unknown;
31
+ };
32
+ latencyMs?: number;
33
+ }
34
+ export type Severity = 'error' | 'warning' | 'info';
35
+ export interface SuggestedFix {
36
+ description: string;
37
+ patch?: unknown;
38
+ }
39
+ export interface DiagnosticResult {
40
+ checkId: string;
41
+ severity: Severity;
42
+ message: string;
43
+ serverName: string;
44
+ toolName?: string;
45
+ details?: unknown;
46
+ suggestedFix?: SuggestedFix;
47
+ }
48
+ export interface Check {
49
+ id: string;
50
+ description: string;
51
+ run(connection: MCPConnection): Promise<DiagnosticResult[]> | DiagnosticResult[];
52
+ }
53
+ export interface RunOptions {
54
+ timeoutMs?: number;
55
+ checks?: Check[];
56
+ verbose?: boolean;
57
+ onLog?: (message: string) => void;
58
+ }
59
+ export interface RunReport {
60
+ configSource?: string;
61
+ connections: MCPConnection[];
62
+ diagnostics: DiagnosticResult[];
63
+ summary: {
64
+ servers: number;
65
+ connected: number;
66
+ failed: number;
67
+ errors: number;
68
+ warnings: number;
69
+ };
70
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ // Mirrors .agent-room/CONTRACT.md exactly. If these ever diverge,
2
+ // CONTRACT.md is the source of truth — update both in the same commit
3
+ // and log the change in .agent-room/DECISIONS.md.
4
+ export {};
@@ -0,0 +1,12 @@
1
+ export interface WatchOptions {
2
+ debounceMs?: number;
3
+ onTrigger: () => Promise<void> | void;
4
+ onError?: (err: Error) => void;
5
+ }
6
+ export interface WatcherHandle {
7
+ close: () => void;
8
+ }
9
+ /**
10
+ * Watches a file with debouncing on rapid file system events.
11
+ */
12
+ export declare function watchFileDebounced(filePath: string, options: WatchOptions): WatcherHandle;
package/dist/watch.js ADDED
@@ -0,0 +1,85 @@
1
+ import { watch, watchFile, unwatchFile, existsSync } from 'node:fs';
2
+ import { resolve, dirname, basename } from 'node:path';
3
+ /**
4
+ * Watches a file with debouncing on rapid file system events.
5
+ */
6
+ export function watchFileDebounced(filePath, options) {
7
+ const debounceMs = options.debounceMs ?? 200;
8
+ const absPath = resolve(filePath);
9
+ const dirPath = dirname(absPath);
10
+ const targetBase = basename(absPath);
11
+ let timer;
12
+ let isClosed = false;
13
+ const run = () => {
14
+ if (isClosed)
15
+ return;
16
+ if (timer)
17
+ clearTimeout(timer);
18
+ timer = setTimeout(async () => {
19
+ try {
20
+ await options.onTrigger();
21
+ }
22
+ catch (err) {
23
+ if (options.onError) {
24
+ options.onError(err instanceof Error ? err : new Error(String(err)));
25
+ }
26
+ }
27
+ }, debounceMs);
28
+ };
29
+ // Watch directory to catch atomic file replaces as well as direct writes
30
+ let dirWatcher;
31
+ try {
32
+ if (existsSync(dirPath)) {
33
+ dirWatcher = watch(dirPath, (_eventType, filename) => {
34
+ if (!filename || filename === targetBase) {
35
+ run();
36
+ }
37
+ });
38
+ }
39
+ }
40
+ catch {
41
+ // fallback if dir watch fails
42
+ }
43
+ // Also watch file directly
44
+ let fileWatcher;
45
+ try {
46
+ if (existsSync(absPath)) {
47
+ fileWatcher = watch(absPath, () => {
48
+ run();
49
+ });
50
+ }
51
+ }
52
+ catch {
53
+ // fallback
54
+ }
55
+ // watchFile as polling backup
56
+ try {
57
+ if (existsSync(absPath)) {
58
+ watchFile(absPath, { interval: 50 }, () => {
59
+ run();
60
+ });
61
+ }
62
+ }
63
+ catch {
64
+ // fallback
65
+ }
66
+ return {
67
+ close() {
68
+ isClosed = true;
69
+ if (timer)
70
+ clearTimeout(timer);
71
+ try {
72
+ dirWatcher?.close();
73
+ }
74
+ catch { }
75
+ try {
76
+ fileWatcher?.close();
77
+ }
78
+ catch { }
79
+ try {
80
+ unwatchFile(absPath);
81
+ }
82
+ catch { }
83
+ },
84
+ };
85
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "mcp-medic",
3
+ "version": "1.0.0",
4
+ "description": "Diagnose broken MCP (Model Context Protocol) server configs before they break your agent silently.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "mcp-medic": "dist/cli.js",
10
+ "mcpmedic": "dist/cli.js",
11
+ "mcp-doctor": "dist/cli.js",
12
+ "mcpdoctor": "dist/cli.js"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "action.yml"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "model-context-protocol",
27
+ "cli",
28
+ "developer-tools",
29
+ "diagnostics",
30
+ "claude",
31
+ "claude-desktop",
32
+ "vscode",
33
+ "json-schema",
34
+ "linter",
35
+ "llm",
36
+ "agents"
37
+ ],
38
+ "author": "Shivam Dixit",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/shivam039/mcp-doctor.git"
43
+ },
44
+ "homepage": "https://github.com/shivam039/mcp-doctor#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/shivam039/mcp-doctor/issues"
47
+ },
48
+ "dependencies": {
49
+ "picocolors": "^1.1.1"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^20.0.0",
53
+ "typescript": "^5.5.0",
54
+ "vitest": "^2.0.0"
55
+ }
56
+ }