mcp-medic 1.0.0 → 1.0.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/README.md +31 -30
- package/action.yml +4 -4
- package/dist/checks/index.d.ts +3 -0
- package/dist/checks/index.js +9 -0
- package/dist/checks/security-overbroad-permissions.d.ts +2 -0
- package/dist/checks/security-overbroad-permissions.js +117 -0
- package/dist/checks/security-prompt-injection-risk.d.ts +2 -0
- package/dist/checks/security-prompt-injection-risk.js +94 -0
- package/dist/checks/security-shared.d.ts +8 -0
- package/dist/checks/security-shared.js +8 -0
- package/dist/checks/security-untrusted-remote.d.ts +2 -0
- package/dist/checks/security-untrusted-remote.js +71 -0
- package/dist/cli.d.ts +9 -2
- package/dist/cli.js +163 -21
- package/dist/conformance.d.ts +1 -1
- package/dist/conformance.js +1 -1
- package/dist/extension/index.d.ts +2 -2
- package/dist/extension/index.js +7 -7
- package/dist/fix.d.ts +44 -0
- package/dist/fix.js +85 -0
- package/dist/junit.js +2 -2
- package/dist/policy.d.ts +4 -3
- package/dist/policy.js +9 -3
- package/dist/protocol/connect.js +1 -1
- package/dist/report.js +1 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
# mcp-
|
|
1
|
+
# mcp-medic
|
|
2
2
|
|
|
3
3
|
Diagnose broken MCP (Model Context Protocol) server configs before they break your agent silently.
|
|
4
4
|
|
|
5
|
-
`mcp-
|
|
5
|
+
`mcp-medic` validates MCP server configurations, executes full protocol initialization handshakes across stdio/SSE/HTTP transports, checks all exposed tool JSON schemas against standard specifications, and simulates sample calls — providing actionable suggestions, in-editor VS Code diagnostics, fleet management, and CI-ready exit codes.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
- 🔍 **Auto-Discovery**: Run `mcp-
|
|
11
|
+
- 🔍 **Auto-Discovery**: Run `mcp-medic check` with no arguments to auto-discover Claude Desktop, `.mcp.json`, and VS Code/Cursor MCP configuration paths across macOS, Windows, and Linux.
|
|
12
12
|
- 💡 **Auto-Fix Suggestions**: Diagnose issues with clear, actionable fix suggestions using `--show-fixes`.
|
|
13
|
-
- 🌐 **Registry Validation**: Validate published registry entries directly using `mcp-
|
|
14
|
-
- 🏢 **Fleet Validation**: Scan and validate monorepos or multi-team configurations with `mcp-
|
|
15
|
-
- ⚖️ **Drift Detection**: Catch environment divergence between staging and production configs with `mcp-
|
|
16
|
-
- 📜 **Policy-as-Code**: Enforce organizational constraints (e.g., banned transports, domain allowlists, minimum description lengths) via `.mcp-
|
|
13
|
+
- 🌐 **Registry Validation**: Validate published registry entries directly using `mcp-medic check --registry <server-id>`.
|
|
14
|
+
- 🏢 **Fleet Validation**: Scan and validate monorepos or multi-team configurations with `mcp-medic check-all "<glob>"`.
|
|
15
|
+
- ⚖️ **Drift Detection**: Catch environment divergence between staging and production configs with `mcp-medic diff <configA> <configB>`.
|
|
16
|
+
- 📜 **Policy-as-Code**: Enforce organizational constraints (e.g., banned transports, domain allowlists, minimum description lengths) via `.mcp-medic-policy.json` / `--policy`.
|
|
17
17
|
- 📸 **Snapshot Baseline Mode**: Filter out legacy diagnostics with `--snapshot <baseline.json>` to gate only on newly introduced regressions.
|
|
18
18
|
- 📊 **CI Reporting**: Export standard JUnit XML (`--export-junit <file.xml>`) and JSON (`--export-json <file.json>`) for seamless CI dashboard visualization.
|
|
19
|
-
- 👀 **Watch Mode**: Re-run validation on save using `mcp-
|
|
19
|
+
- 👀 **Watch Mode**: Re-run validation on save using `mcp-medic watch <path>`.
|
|
20
20
|
- ⚡ **Transport Hardening**: Full handshake validation across stdio, HTTP (with OAuth token refresh), and SSE (with automatic retry resilience).
|
|
21
21
|
- 💻 **VS Code Extension**: In-editor squiggles and hover tooltips showing diagnostics and suggested fixes.
|
|
22
22
|
- 🚦 **CI Usability & Exit Codes**: Strict exit code taxonomy (`0` clean, `1` diagnostic failures, `2` usage/syntax errors) and `--fail-on <error|warning>`.
|
|
23
|
-
- 🤖 **GitHub Action**: Drop-in CI integration via `shivam039/mcp-doctor@main` (or `mcp-
|
|
24
|
-
- 🧩 **Community Checks**: Conformance test helper (`runCheckConformanceSuite`) to build custom `mcp-
|
|
23
|
+
- 🤖 **GitHub Action**: Drop-in CI integration via `shivam039/mcp-doctor@main` (or `mcp-medic-action`).
|
|
24
|
+
- 🧩 **Community Checks**: Conformance test helper (`runCheckConformanceSuite`) to build custom `mcp-medic-check-*` check plugins.
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
@@ -29,29 +29,29 @@ Diagnose broken MCP (Model Context Protocol) server configs before they break yo
|
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
31
|
# Run against auto-discovered configs in current project / Claude Desktop
|
|
32
|
-
npx mcp-
|
|
32
|
+
npx mcp-medic
|
|
33
33
|
|
|
34
34
|
# Run check on a specific configuration file
|
|
35
|
-
npx mcp-
|
|
35
|
+
npx mcp-medic check path/to/config.json
|
|
36
36
|
|
|
37
37
|
# Validate all configs across a monorepo
|
|
38
|
-
npx mcp-
|
|
38
|
+
npx mcp-medic check-all "configs/**/*.json"
|
|
39
39
|
|
|
40
40
|
# Compare two configs to detect drift
|
|
41
|
-
npx mcp-
|
|
41
|
+
npx mcp-medic diff staging.mcp.json prod.mcp.json
|
|
42
42
|
|
|
43
43
|
# Validate a published registry server directly without a local config
|
|
44
|
-
npx mcp-
|
|
45
|
-
npx mcp-
|
|
44
|
+
npx mcp-medic check --registry @modelcontextprotocol/server-memory
|
|
45
|
+
npx mcp-medic check --registry smithery:username/my-server
|
|
46
46
|
|
|
47
47
|
# Apply organizational policy rules and export to JUnit XML
|
|
48
|
-
npx mcp-
|
|
48
|
+
npx mcp-medic check path/to/config.json --policy .mcp-medic-policy.json --export-junit results.xml
|
|
49
49
|
|
|
50
50
|
# Display suggested fixes for flagged diagnostics
|
|
51
|
-
npx mcp-
|
|
51
|
+
npx mcp-medic check path/to/config.json --show-fixes
|
|
52
52
|
|
|
53
53
|
# Watch mode (re-runs checks on save)
|
|
54
|
-
npx mcp-
|
|
54
|
+
npx mcp-medic watch path/to/config.json
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
---
|
|
@@ -66,7 +66,7 @@ npx mcp-doctor watch path/to/config.json
|
|
|
66
66
|
| `check --registry <id>` | Validate a published registry server directly |
|
|
67
67
|
| `watch <path>` | Watch configuration file and re-run checks on file save |
|
|
68
68
|
| `--config <path>` | Explicit configuration path |
|
|
69
|
-
| `--policy <path>` | Apply organizational policy rules (`.mcp-
|
|
69
|
+
| `--policy <path>` | Apply organizational policy rules (`.mcp-medic-policy.json`) |
|
|
70
70
|
| `--snapshot <path>` | Compare against baseline snapshot, reporting regressions only |
|
|
71
71
|
| `--update-snapshot <path>` | Save diagnostic report as new baseline snapshot |
|
|
72
72
|
| `--export-junit <file>` | Export report in JUnit XML format |
|
|
@@ -85,7 +85,7 @@ npx mcp-doctor watch path/to/config.json
|
|
|
85
85
|
|
|
86
86
|
---
|
|
87
87
|
|
|
88
|
-
## Policy-as-Code (`.mcp-
|
|
88
|
+
## Policy-as-Code (`.mcp-medic-policy.json`)
|
|
89
89
|
|
|
90
90
|
Define organization-wide policies that compose with built-in checks:
|
|
91
91
|
|
|
@@ -101,7 +101,7 @@ Define organization-wide policies that compose with built-in checks:
|
|
|
101
101
|
|
|
102
102
|
## VS Code Extension
|
|
103
103
|
|
|
104
|
-
The `mcp-
|
|
104
|
+
The `mcp-medic` VS Code extension provides:
|
|
105
105
|
- Inline squiggles on `.mcp.json`, `mcp.json`, and `claude_desktop_config.json` files as you edit.
|
|
106
106
|
- Hover tooltips showing the full diagnostic explanation and actionable suggested fix.
|
|
107
107
|
|
|
@@ -133,14 +133,14 @@ jobs:
|
|
|
133
133
|
|
|
134
134
|
| Check ID | Package | Scope | Description |
|
|
135
135
|
|---|---|---|---|
|
|
136
|
-
| `schema.malformed` | `mcp-
|
|
137
|
-
| `schema.missing-required` | `mcp-
|
|
138
|
-
| `schema.type-mismatch` | `mcp-
|
|
139
|
-
| `schema.missing-description` | `mcp-
|
|
140
|
-
| `schema.sample-call-simulation` | `mcp-
|
|
141
|
-
| `policy.*` | `mcp-
|
|
142
|
-
| `community.strict-typing` | `mcp-
|
|
143
|
-
| `community.no-empty-enums` | `mcp-
|
|
136
|
+
| `schema.malformed` | `mcp-medic` | **Official** | Verifies inputSchema is a valid JSON schema object |
|
|
137
|
+
| `schema.missing-required` | `mcp-medic` | **Official** | Flags required fields missing from properties |
|
|
138
|
+
| `schema.type-mismatch` | `mcp-medic` | **Official** | Flags invalid JSON schema types and enum mismatches |
|
|
139
|
+
| `schema.missing-description` | `mcp-medic` | **Official** | Flags tools and properties missing documentation |
|
|
140
|
+
| `schema.sample-call-simulation` | `mcp-medic` | **Official** | Simulates and validates synthetic call payloads |
|
|
141
|
+
| `policy.*` | `mcp-medic` | **Official** | Evaluates policy-as-code rules (transports, domains, length) |
|
|
142
|
+
| `community.strict-typing` | `mcp-medic-check-strict-typing` | *Community* | Enforces strict property type annotations |
|
|
143
|
+
| `community.no-empty-enums` | `mcp-medic-check-no-empty-enums` | *Community* | Ensures non-empty enum option lists |
|
|
144
144
|
|
|
145
145
|
To write and publish your own check plugin, see [Authoring Custom Checks](./docs/AUTHORING_CHECKS.md).
|
|
146
146
|
|
|
@@ -167,3 +167,4 @@ npm run test
|
|
|
167
167
|
## License
|
|
168
168
|
|
|
169
169
|
MIT
|
|
170
|
+
|
package/action.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
name: 'mcp-
|
|
1
|
+
name: 'mcp-medic-action'
|
|
2
2
|
description: 'Diagnose broken MCP (Model Context Protocol) server configs in CI before they break your agent silently'
|
|
3
|
-
author: '
|
|
3
|
+
author: 'Shivam Dixit'
|
|
4
4
|
branding:
|
|
5
5
|
icon: 'check-circle'
|
|
6
6
|
color: 'green'
|
|
@@ -35,7 +35,7 @@ runs:
|
|
|
35
35
|
with:
|
|
36
36
|
node-version: '20'
|
|
37
37
|
|
|
38
|
-
- name: Run mcp-
|
|
38
|
+
- name: Run mcp-medic
|
|
39
39
|
shell: bash
|
|
40
40
|
run: |
|
|
41
41
|
ARGS="check"
|
|
@@ -54,4 +54,4 @@ runs:
|
|
|
54
54
|
if [ "${{ inputs.verbose }}" = "true" ]; then
|
|
55
55
|
ARGS="$ARGS --verbose"
|
|
56
56
|
fi
|
|
57
|
-
npx --yes mcp-
|
|
57
|
+
npx --yes mcp-medic $ARGS
|
package/dist/checks/index.d.ts
CHANGED
|
@@ -3,4 +3,7 @@ export { missingRequiredFieldsCheck } from './missing-required-fields.js';
|
|
|
3
3
|
export { typeMismatchCheck } from './type-mismatch.js';
|
|
4
4
|
export { missingDescriptionCheck } from './missing-description.js';
|
|
5
5
|
export { sampleCallSimulationCheck } from './sample-call-simulation.js';
|
|
6
|
+
export { securityUntrustedRemoteCheck } from './security-untrusted-remote.js';
|
|
7
|
+
export { securityOverbroadPermissionsCheck } from './security-overbroad-permissions.js';
|
|
8
|
+
export { securityPromptInjectionRiskCheck } from './security-prompt-injection-risk.js';
|
|
6
9
|
export declare const allChecks: import("../types.js").Check[];
|
package/dist/checks/index.js
CHANGED
|
@@ -3,15 +3,24 @@ export { missingRequiredFieldsCheck } from './missing-required-fields.js';
|
|
|
3
3
|
export { typeMismatchCheck } from './type-mismatch.js';
|
|
4
4
|
export { missingDescriptionCheck } from './missing-description.js';
|
|
5
5
|
export { sampleCallSimulationCheck } from './sample-call-simulation.js';
|
|
6
|
+
export { securityUntrustedRemoteCheck } from './security-untrusted-remote.js';
|
|
7
|
+
export { securityOverbroadPermissionsCheck } from './security-overbroad-permissions.js';
|
|
8
|
+
export { securityPromptInjectionRiskCheck } from './security-prompt-injection-risk.js';
|
|
6
9
|
import { malformedSchemaCheck } from './malformed-schema.js';
|
|
7
10
|
import { missingRequiredFieldsCheck } from './missing-required-fields.js';
|
|
8
11
|
import { typeMismatchCheck } from './type-mismatch.js';
|
|
9
12
|
import { missingDescriptionCheck } from './missing-description.js';
|
|
10
13
|
import { sampleCallSimulationCheck } from './sample-call-simulation.js';
|
|
14
|
+
import { securityUntrustedRemoteCheck } from './security-untrusted-remote.js';
|
|
15
|
+
import { securityOverbroadPermissionsCheck } from './security-overbroad-permissions.js';
|
|
16
|
+
import { securityPromptInjectionRiskCheck } from './security-prompt-injection-risk.js';
|
|
11
17
|
export const allChecks = [
|
|
12
18
|
malformedSchemaCheck,
|
|
13
19
|
missingRequiredFieldsCheck,
|
|
14
20
|
typeMismatchCheck,
|
|
15
21
|
missingDescriptionCheck,
|
|
16
22
|
sampleCallSimulationCheck,
|
|
23
|
+
securityUntrustedRemoteCheck,
|
|
24
|
+
securityOverbroadPermissionsCheck,
|
|
25
|
+
securityPromptInjectionRiskCheck,
|
|
17
26
|
];
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { HEURISTIC_DISCLAIMER } from './security-shared.js';
|
|
2
|
+
// Ordered by specificity; matched against each top-level inputSchema property name.
|
|
3
|
+
const PROPERTY_RULES = [
|
|
4
|
+
{
|
|
5
|
+
category: 'shell-command',
|
|
6
|
+
namePattern: /^(cmd|command|shell|bash|sh|exec|script|code)$/i,
|
|
7
|
+
hint: 'accepts an unconstrained shell/command string',
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
category: 'filesystem-path',
|
|
11
|
+
namePattern: /^(path|filepath|file_path|dir|directory|folder)$/i,
|
|
12
|
+
hint: 'accepts an unconstrained filesystem path',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
category: 'network-request',
|
|
16
|
+
namePattern: /^(url|endpoint|host|target|uri)$/i,
|
|
17
|
+
hint: 'accepts an unconstrained network destination',
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
const DESCRIPTION_RULES = [
|
|
21
|
+
{
|
|
22
|
+
category: 'shell-command',
|
|
23
|
+
pattern: /\b(execute|executes|run|runs)\s+(any|arbitrary)\s+(shell|command|code)\b|\barbitrary\s+shell\s+command/i,
|
|
24
|
+
hint: 'description states it executes arbitrary shell commands or code',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
category: 'filesystem-path',
|
|
28
|
+
pattern: /\bfull\s+filesystem\s+access\b|\baccess(?:es)?\s+(?:to\s+)?any\s+file\b|\b(?:read|write|delete)\s+any\s+file\b/i,
|
|
29
|
+
hint: 'description states it can access any file on the filesystem',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
category: 'network-request',
|
|
33
|
+
pattern: /\b(unrestricted|arbitrary)\s+(network|http|url)\s+(access|request)\b/i,
|
|
34
|
+
hint: 'description states it allows unrestricted network access',
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
function isUnconstrainedString(propDef) {
|
|
38
|
+
if (!propDef || typeof propDef !== 'object' || Array.isArray(propDef))
|
|
39
|
+
return false;
|
|
40
|
+
const prop = propDef;
|
|
41
|
+
if (prop.type !== 'string')
|
|
42
|
+
return false;
|
|
43
|
+
if (Array.isArray(prop.enum) && prop.enum.length > 0)
|
|
44
|
+
return false;
|
|
45
|
+
if (typeof prop.pattern === 'string' && prop.pattern.length > 0)
|
|
46
|
+
return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
function checkTool(tool, serverName) {
|
|
50
|
+
const results = [];
|
|
51
|
+
if (typeof tool.description === 'string') {
|
|
52
|
+
for (const rule of DESCRIPTION_RULES) {
|
|
53
|
+
if (rule.pattern.test(tool.description)) {
|
|
54
|
+
results.push({
|
|
55
|
+
checkId: 'security.overbroad-permissions',
|
|
56
|
+
severity: 'warning',
|
|
57
|
+
message: `Tool "${tool.name}" ${rule.hint} — review before granting it broad access. (${HEURISTIC_DISCLAIMER})`,
|
|
58
|
+
serverName,
|
|
59
|
+
toolName: tool.name,
|
|
60
|
+
details: { category: rule.category, source: 'description' },
|
|
61
|
+
suggestedFix: {
|
|
62
|
+
description: 'Confirm this scope is intentional after reviewing the server\'s source; prefer a server that exposes narrower, purpose-built tools instead of one broad tool.',
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const schema = tool.inputSchema;
|
|
69
|
+
if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
|
|
70
|
+
const schemaObj = schema;
|
|
71
|
+
const properties = schemaObj.properties && typeof schemaObj.properties === 'object' && !Array.isArray(schemaObj.properties)
|
|
72
|
+
? schemaObj.properties
|
|
73
|
+
: {};
|
|
74
|
+
for (const [propName, propDef] of Object.entries(properties)) {
|
|
75
|
+
if (!isUnconstrainedString(propDef))
|
|
76
|
+
continue;
|
|
77
|
+
const rule = PROPERTY_RULES.find((r) => r.namePattern.test(propName));
|
|
78
|
+
if (!rule)
|
|
79
|
+
continue;
|
|
80
|
+
results.push({
|
|
81
|
+
checkId: 'security.overbroad-permissions',
|
|
82
|
+
severity: 'warning',
|
|
83
|
+
message: `Tool "${tool.name}" parameter "${propName}" ${rule.hint} (no enum or pattern constraining it). (${HEURISTIC_DISCLAIMER})`,
|
|
84
|
+
serverName,
|
|
85
|
+
toolName: tool.name,
|
|
86
|
+
details: { category: rule.category, source: 'schema', property: propName },
|
|
87
|
+
suggestedFix: {
|
|
88
|
+
description: `Constrain "${propName}" with an enum of allowed values or a validating pattern, or split it into narrower parameters, if you control this server.`,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return results;
|
|
94
|
+
}
|
|
95
|
+
export const securityOverbroadPermissionsCheck = {
|
|
96
|
+
id: 'security.overbroad-permissions',
|
|
97
|
+
description: `Flags tools whose description or schema implies unscoped filesystem/shell/network access (${HEURISTIC_DISCLAIMER}).`,
|
|
98
|
+
run(connection) {
|
|
99
|
+
const results = [];
|
|
100
|
+
try {
|
|
101
|
+
if (!connection.tools || !Array.isArray(connection.tools))
|
|
102
|
+
return results;
|
|
103
|
+
for (const tool of connection.tools) {
|
|
104
|
+
results.push(...checkTool(tool, connection.server.name));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
results.push({
|
|
109
|
+
checkId: 'security.overbroad-permissions',
|
|
110
|
+
severity: 'error',
|
|
111
|
+
message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
|
|
112
|
+
serverName: connection.server.name,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return results;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { HEURISTIC_DISCLAIMER } from './security-shared.js';
|
|
2
|
+
// Instruction-like language aimed at the model reading the tool description,
|
|
3
|
+
// rather than at a human deciding whether to use the tool — a known MCP
|
|
4
|
+
// supply-chain risk pattern (tool descriptions are injected into the
|
|
5
|
+
// model's context).
|
|
6
|
+
const INJECTION_PATTERNS = [
|
|
7
|
+
/\balways\s+call\s+this\s+(tool\s+)?first\b/i,
|
|
8
|
+
/\bignore\s+(all\s+)?(previous|prior|above)\s+instructions\b/i,
|
|
9
|
+
/\bdisregard\s+(the\s+)?(system\s+prompt|previous\s+instructions)\b/i,
|
|
10
|
+
/\bdo\s+not\s+(tell|inform)\s+the\s+user\b/i,
|
|
11
|
+
/\bnever\s+(mention|tell)\s+.*\bthe\s+user\b/i,
|
|
12
|
+
/\byou\s+must\s+(always|never)\s+\w/i,
|
|
13
|
+
/\bthis\s+is\s+a\s+system\s+(prompt|instruction)\b/i,
|
|
14
|
+
/\boverride\s+(your|any)\s+(previous|prior)\s+(instructions|guidelines)\b/i,
|
|
15
|
+
/\bact\s+as\s+(if\s+you\s+are|though\s+you\s+are)\b/i,
|
|
16
|
+
];
|
|
17
|
+
function findMatch(text) {
|
|
18
|
+
for (const pattern of INJECTION_PATTERNS) {
|
|
19
|
+
const match = pattern.exec(text);
|
|
20
|
+
if (match) {
|
|
21
|
+
return { pattern: pattern.source, snippet: match[0] };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
export const securityPromptInjectionRiskCheck = {
|
|
27
|
+
id: 'security.prompt-injection-risk',
|
|
28
|
+
description: `Flags tool/parameter descriptions containing instruction-like language aimed at the model rather than a human (${HEURISTIC_DISCLAIMER}).`,
|
|
29
|
+
run(connection) {
|
|
30
|
+
const results = [];
|
|
31
|
+
try {
|
|
32
|
+
if (!connection.tools || !Array.isArray(connection.tools))
|
|
33
|
+
return results;
|
|
34
|
+
for (const tool of connection.tools) {
|
|
35
|
+
if (typeof tool.description === 'string') {
|
|
36
|
+
const match = findMatch(tool.description);
|
|
37
|
+
if (match) {
|
|
38
|
+
results.push({
|
|
39
|
+
checkId: 'security.prompt-injection-risk',
|
|
40
|
+
severity: 'warning',
|
|
41
|
+
message: `Tool "${tool.name}" description contains instruction-like language aimed at the model ` +
|
|
42
|
+
`("${match.snippet}") rather than describing the tool to a human — a known MCP supply-chain ` +
|
|
43
|
+
`risk pattern. (${HEURISTIC_DISCLAIMER})`,
|
|
44
|
+
serverName: connection.server.name,
|
|
45
|
+
toolName: tool.name,
|
|
46
|
+
details: { source: 'description', matchedPattern: match.pattern, snippet: match.snippet },
|
|
47
|
+
suggestedFix: {
|
|
48
|
+
description: "Review this tool's description and the server's source before trusting it; rewrite or remove instruction-like language if you control this server.",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const schema = tool.inputSchema;
|
|
54
|
+
if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
|
|
55
|
+
const properties = schema.properties;
|
|
56
|
+
if (properties && typeof properties === 'object' && !Array.isArray(properties)) {
|
|
57
|
+
for (const [propName, propDef] of Object.entries(properties)) {
|
|
58
|
+
if (!propDef || typeof propDef !== 'object' || Array.isArray(propDef))
|
|
59
|
+
continue;
|
|
60
|
+
const description = propDef.description;
|
|
61
|
+
if (typeof description !== 'string')
|
|
62
|
+
continue;
|
|
63
|
+
const match = findMatch(description);
|
|
64
|
+
if (match) {
|
|
65
|
+
results.push({
|
|
66
|
+
checkId: 'security.prompt-injection-risk',
|
|
67
|
+
severity: 'warning',
|
|
68
|
+
message: `Tool "${tool.name}" parameter "${propName}" description contains instruction-like language ` +
|
|
69
|
+
`aimed at the model ("${match.snippet}") rather than describing the parameter to a human. ` +
|
|
70
|
+
`(${HEURISTIC_DISCLAIMER})`,
|
|
71
|
+
serverName: connection.server.name,
|
|
72
|
+
toolName: tool.name,
|
|
73
|
+
details: { source: 'parameter-description', property: propName, matchedPattern: match.pattern, snippet: match.snippet },
|
|
74
|
+
suggestedFix: {
|
|
75
|
+
description: "Review this tool's description and the server's source before trusting it; rewrite or remove instruction-like language if you control this server.",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
results.push({
|
|
86
|
+
checkId: 'security.prompt-injection-risk',
|
|
87
|
+
severity: 'error',
|
|
88
|
+
message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
|
|
89
|
+
serverName: connection.server.name,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return results;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All security.* checks are heuristic pattern-matching over what a server
|
|
3
|
+
* *declares* (config URL, tool descriptions, tool schemas) — they cannot
|
|
4
|
+
* see what a server actually does. Every diagnostic they emit must say so
|
|
5
|
+
* (FR3-2.4): this is not a substitute for reading a third-party MCP
|
|
6
|
+
* server's source before trusting it.
|
|
7
|
+
*/
|
|
8
|
+
export declare const HEURISTIC_DISCLAIMER = "heuristic flag, not a guarantee \u2014 review this server's source before trusting it";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All security.* checks are heuristic pattern-matching over what a server
|
|
3
|
+
* *declares* (config URL, tool descriptions, tool schemas) — they cannot
|
|
4
|
+
* see what a server actually does. Every diagnostic they emit must say so
|
|
5
|
+
* (FR3-2.4): this is not a substitute for reading a third-party MCP
|
|
6
|
+
* server's source before trusting it.
|
|
7
|
+
*/
|
|
8
|
+
export const HEURISTIC_DISCLAIMER = "heuristic flag, not a guarantee — review this server's source before trusting it";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { HEURISTIC_DISCLAIMER } from './security-shared.js';
|
|
2
|
+
function toHttps(url) {
|
|
3
|
+
return url.replace(/^http:\/\//i, 'https://');
|
|
4
|
+
}
|
|
5
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1']);
|
|
6
|
+
function isIpLiteralHost(hostname) {
|
|
7
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname))
|
|
8
|
+
return true; // IPv4
|
|
9
|
+
if (hostname.includes(':') && /^[0-9a-fA-F:]+$/.test(hostname))
|
|
10
|
+
return true; // IPv6 (URL strips brackets)
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
export const securityUntrustedRemoteCheck = {
|
|
14
|
+
id: 'security.untrusted-remote',
|
|
15
|
+
description: `For SSE/HTTP servers, flags non-HTTPS URLs or raw IP-address hosts instead of domain names (${HEURISTIC_DISCLAIMER}).`,
|
|
16
|
+
run(connection) {
|
|
17
|
+
const results = [];
|
|
18
|
+
try {
|
|
19
|
+
const { server } = connection;
|
|
20
|
+
if (server.transport !== 'sse' && server.transport !== 'http')
|
|
21
|
+
return results;
|
|
22
|
+
if (typeof server.url !== 'string' || server.url.length === 0)
|
|
23
|
+
return results;
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = new URL(server.url);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return results; // malformed URLs are the config-loader/protocol layer's concern, not this check's
|
|
30
|
+
}
|
|
31
|
+
if (parsed.protocol === 'http:') {
|
|
32
|
+
results.push({
|
|
33
|
+
checkId: 'security.untrusted-remote',
|
|
34
|
+
severity: 'warning',
|
|
35
|
+
message: `Server "${server.name}" connects over unencrypted "http://"; requests, responses, and any headers ` +
|
|
36
|
+
`(including auth tokens) travel in plaintext. Not blocking — legitimate local dev configs exist. ` +
|
|
37
|
+
`(${HEURISTIC_DISCLAIMER})`,
|
|
38
|
+
serverName: server.name,
|
|
39
|
+
details: { url: server.url, reason: 'non-https' },
|
|
40
|
+
suggestedFix: {
|
|
41
|
+
description: `Switch to "${toHttps(server.url)}" if the server supports TLS.`,
|
|
42
|
+
patch: { serverName: server.name, set: { url: toHttps(server.url) } },
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (isIpLiteralHost(parsed.hostname) && !LOOPBACK_HOSTS.has(parsed.hostname)) {
|
|
47
|
+
results.push({
|
|
48
|
+
checkId: 'security.untrusted-remote',
|
|
49
|
+
severity: 'warning',
|
|
50
|
+
message: `Server "${server.name}" points at a raw IP address ("${parsed.hostname}") instead of a domain name, ` +
|
|
51
|
+
`making it hard to verify who actually operates the endpoint or notice if it changes hands. ` +
|
|
52
|
+
`Not blocking — legitimate local dev configs exist. (${HEURISTIC_DISCLAIMER})`,
|
|
53
|
+
serverName: server.name,
|
|
54
|
+
details: { url: server.url, reason: 'ip-literal-host' },
|
|
55
|
+
suggestedFix: {
|
|
56
|
+
description: 'Point this server at a stable domain name you control or trust, if one is available.',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
results.push({
|
|
63
|
+
checkId: 'security.untrusted-remote',
|
|
64
|
+
severity: 'error',
|
|
65
|
+
message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
|
|
66
|
+
serverName: connection.server.name,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return results;
|
|
70
|
+
},
|
|
71
|
+
};
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
export interface ParsedArgs {
|
|
3
|
-
command: 'check' | 'watch' | 'check-all' | 'diff' | 'help';
|
|
3
|
+
command: 'check' | 'watch' | 'check-all' | 'diff' | 'fix' | 'help';
|
|
4
4
|
configPath?: string;
|
|
5
5
|
configPathB?: string;
|
|
6
6
|
globPattern?: string;
|
|
@@ -15,6 +15,13 @@ export interface ParsedArgs {
|
|
|
15
15
|
showFixes: boolean;
|
|
16
16
|
verbose: boolean;
|
|
17
17
|
failOn: 'error' | 'warning';
|
|
18
|
+
checkFilter?: string;
|
|
19
|
+
dryRun: boolean;
|
|
18
20
|
}
|
|
21
|
+
/** Prompts the user with `question` and resolves true for an explicit "y"/"yes" answer. */
|
|
22
|
+
export type ConfirmFn = (question: string) => Promise<boolean>;
|
|
19
23
|
export declare function parseArgs(argv: string[]): ParsedArgs;
|
|
20
|
-
export
|
|
24
|
+
export interface CliDeps {
|
|
25
|
+
confirm?: ConfirmFn;
|
|
26
|
+
}
|
|
27
|
+
export declare function main(argv?: string[], deps?: CliDeps): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import * as readline from 'node:readline/promises';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
4
5
|
import { runChecks, registerConnectImpl } from './orchestrator.js';
|
|
5
6
|
import { formatReportHuman, formatReportJSON } from './report.js';
|
|
@@ -7,6 +8,7 @@ import { loadConfig } from './config-loader.js';
|
|
|
7
8
|
import { discoverConfigFiles } from './discovery.js';
|
|
8
9
|
import { watchFileDebounced } from './watch.js';
|
|
9
10
|
import { resolveRegistryServer } from './registry.js';
|
|
11
|
+
import { isConfigPatch, diffConfigPatch, applyConfigPatch } from './fix.js';
|
|
10
12
|
import { loadPolicy, createPolicyChecks } from './policy.js';
|
|
11
13
|
import { runFleetChecks, diffConfigs, filterDiagnosticsByBaseline } from './fleet.js';
|
|
12
14
|
import { formatReportJUnit, formatFleetReportJUnit } from './junit.js';
|
|
@@ -18,6 +20,7 @@ export function parseArgs(argv) {
|
|
|
18
20
|
showFixes: false,
|
|
19
21
|
verbose: false,
|
|
20
22
|
failOn: 'error',
|
|
23
|
+
dryRun: false,
|
|
21
24
|
};
|
|
22
25
|
const positional = [];
|
|
23
26
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -31,6 +34,9 @@ export function parseArgs(argv) {
|
|
|
31
34
|
else if (arg === '--verbose' || arg === '-v') {
|
|
32
35
|
args.verbose = true;
|
|
33
36
|
}
|
|
37
|
+
else if (arg === '--dry-run') {
|
|
38
|
+
args.dryRun = true;
|
|
39
|
+
}
|
|
34
40
|
else if (arg === '--help' || arg === '-h') {
|
|
35
41
|
args.command = 'help';
|
|
36
42
|
}
|
|
@@ -41,6 +47,13 @@ export function parseArgs(argv) {
|
|
|
41
47
|
}
|
|
42
48
|
args.failOn = val;
|
|
43
49
|
}
|
|
50
|
+
else if (arg === '--check') {
|
|
51
|
+
const val = argv[++i];
|
|
52
|
+
if (!val) {
|
|
53
|
+
throw new Error('--check requires a check id, e.g. --check security.untrusted-remote');
|
|
54
|
+
}
|
|
55
|
+
args.checkFilter = val;
|
|
56
|
+
}
|
|
44
57
|
else if (arg === '--policy') {
|
|
45
58
|
args.policyPath = argv[++i];
|
|
46
59
|
}
|
|
@@ -84,7 +97,7 @@ export function parseArgs(argv) {
|
|
|
84
97
|
}
|
|
85
98
|
if (args.command !== 'help') {
|
|
86
99
|
const first = positional[0];
|
|
87
|
-
if (first === 'check' || first === 'watch' || first === 'check-all' || first === 'diff') {
|
|
100
|
+
if (first === 'check' || first === 'watch' || first === 'check-all' || first === 'diff' || first === 'fix') {
|
|
88
101
|
args.command = first;
|
|
89
102
|
if (first === 'diff') {
|
|
90
103
|
args.configPath = positional[1];
|
|
@@ -135,20 +148,21 @@ async function loadProtocol() {
|
|
|
135
148
|
}
|
|
136
149
|
}
|
|
137
150
|
function printHelp() {
|
|
138
|
-
|
|
139
|
-
${pc.bold('mcp-
|
|
151
|
+
const helpText = `
|
|
152
|
+
${pc.bold('mcp-medic')} — Diagnose broken MCP server configs before they break your agent.
|
|
140
153
|
|
|
141
|
-
|
|
142
|
-
$ mcp-
|
|
143
|
-
$ mcp-
|
|
144
|
-
$ mcp-
|
|
145
|
-
$ mcp-
|
|
146
|
-
$ mcp-
|
|
154
|
+
USAGE
|
|
155
|
+
$ mcp-medic [check] [path/to/config.json] [options]
|
|
156
|
+
$ mcp-medic check --registry <server-id> [options]
|
|
157
|
+
$ mcp-medic check-all "<glob-pattern>" [options]
|
|
158
|
+
$ mcp-medic diff <configA.json> <configB.json>
|
|
159
|
+
$ mcp-medic watch <path/to/config.json> [options]
|
|
160
|
+
$ mcp-medic fix <path/to/config.json> [--check <id>] [--dry-run]
|
|
147
161
|
|
|
148
|
-
|
|
162
|
+
OPTIONS
|
|
149
163
|
--config <path> Specify path to MCP configuration file
|
|
150
164
|
--registry <id/url> Validate published registry entry directly
|
|
151
|
-
--policy <path> Apply organizational policy rules (.mcp-
|
|
165
|
+
--policy <path> Apply organizational policy rules (.mcp-medic-policy.json)
|
|
152
166
|
--snapshot <path> Filter report against baseline snapshot, reporting regressions only
|
|
153
167
|
--update-snapshot <p> Save diagnostic report as new baseline snapshot
|
|
154
168
|
--export-junit <file> Export report in JUnit XML format
|
|
@@ -160,11 +174,23 @@ ${pc.bold('OPTIONS')}
|
|
|
160
174
|
--timeout <ms> Per-server handshake timeout in milliseconds (default: 5000)
|
|
161
175
|
--help, -h Show help
|
|
162
176
|
|
|
163
|
-
|
|
164
|
-
|
|
177
|
+
FIX OPTIONS (mcp-medic fix)
|
|
178
|
+
--check <id> Only offer fixes from this check id (e.g. security.untrusted-remote)
|
|
179
|
+
--dry-run Show every available fix as a diff; apply nothing, prompt for nothing
|
|
180
|
+
|
|
181
|
+
FIX BEHAVIOR
|
|
182
|
+
Only diagnostics that carry a mechanical suggestedFix.patch can be
|
|
183
|
+
auto-applied (most diagnostics are description-only and must be fixed by
|
|
184
|
+
hand). Each one is shown as a diff and requires an explicit y/n
|
|
185
|
+
confirmation — fixes are never bulk-applied silently. Before writing
|
|
186
|
+
anything, the original file is copied to <path>.bak.
|
|
187
|
+
|
|
188
|
+
EXIT CODES
|
|
189
|
+
0 All checks passed cleanly / fix completed (including "nothing to fix")
|
|
165
190
|
1 Diagnostics failed (errors found, or warnings when --fail-on warning)
|
|
166
191
|
2 Usage or configuration error (invalid flags, missing/malformed config)
|
|
167
|
-
|
|
192
|
+
`;
|
|
193
|
+
console.log(helpText);
|
|
168
194
|
}
|
|
169
195
|
function colorizeHumanReport(text) {
|
|
170
196
|
return text
|
|
@@ -275,25 +301,135 @@ async function loadConfigFromPath(configPath) {
|
|
|
275
301
|
}
|
|
276
302
|
return { exitCode: 2 };
|
|
277
303
|
}
|
|
278
|
-
return { config };
|
|
304
|
+
return { config, rawText, rawJson };
|
|
305
|
+
}
|
|
306
|
+
async function defaultConfirm(question) {
|
|
307
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
308
|
+
try {
|
|
309
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
310
|
+
return answer === 'y' || answer === 'yes';
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
rl.close();
|
|
314
|
+
}
|
|
279
315
|
}
|
|
280
|
-
|
|
316
|
+
/**
|
|
317
|
+
* `mcp-doctor fix`: runs the same checks as `check`, then offers to apply
|
|
318
|
+
* every diagnostic whose suggestedFix carries a mechanical `patch` (see
|
|
319
|
+
* src/fix.ts). Per FR3-1: always shows a diff, always asks y/n per fix
|
|
320
|
+
* (never bulk-applies), always writes a `.bak` before touching the file,
|
|
321
|
+
* and `--dry-run` shows every diff without prompting or writing anything.
|
|
322
|
+
*/
|
|
323
|
+
async function executeFix(configPath, rawText, rawJson, config, args, confirm) {
|
|
324
|
+
const [checks] = await Promise.all([loadChecks(args.policyPath), loadProtocol()]);
|
|
325
|
+
const report = await runChecks(config, { timeoutMs: args.timeoutMs, checks, verbose: args.verbose });
|
|
326
|
+
let fixable = report.diagnostics.filter((d) => isConfigPatch(d.suggestedFix?.patch));
|
|
327
|
+
if (args.checkFilter) {
|
|
328
|
+
fixable = fixable.filter((d) => d.checkId === args.checkFilter);
|
|
329
|
+
}
|
|
330
|
+
// Multiple diagnostics can suggest the exact same patch; only offer it once.
|
|
331
|
+
const seen = new Set();
|
|
332
|
+
const uniqueFixable = [];
|
|
333
|
+
for (const diagnostic of fixable) {
|
|
334
|
+
const patch = diagnostic.suggestedFix.patch;
|
|
335
|
+
const key = JSON.stringify(patch);
|
|
336
|
+
if (seen.has(key))
|
|
337
|
+
continue;
|
|
338
|
+
seen.add(key);
|
|
339
|
+
uniqueFixable.push({ diagnostic, patch });
|
|
340
|
+
}
|
|
341
|
+
if (uniqueFixable.length === 0) {
|
|
342
|
+
console.log(pc.green('No auto-fixable diagnostics found.'));
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
let working = rawJson;
|
|
346
|
+
let applied = 0;
|
|
347
|
+
let skipped = 0;
|
|
348
|
+
const dryRunEntries = [];
|
|
349
|
+
for (const { diagnostic, patch } of uniqueFixable) {
|
|
350
|
+
const diffs = diffConfigPatch(working, patch);
|
|
351
|
+
if (diffs.length === 0)
|
|
352
|
+
continue; // already fixed — nothing to show or confirm
|
|
353
|
+
if (args.dryRun) {
|
|
354
|
+
dryRunEntries.push({
|
|
355
|
+
checkId: diagnostic.checkId,
|
|
356
|
+
serverName: diagnostic.serverName,
|
|
357
|
+
toolName: diagnostic.toolName,
|
|
358
|
+
message: diagnostic.message,
|
|
359
|
+
diff: diffs,
|
|
360
|
+
});
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
console.log('');
|
|
364
|
+
console.log(pc.bold(`[${diagnostic.severity}] ${diagnostic.serverName} — ${diagnostic.message} (${diagnostic.checkId})`));
|
|
365
|
+
for (const d of diffs) {
|
|
366
|
+
console.log(pc.red(` - ${d.field}: ${JSON.stringify(d.before)}`));
|
|
367
|
+
console.log(pc.green(` + ${d.field}: ${JSON.stringify(d.after)}`));
|
|
368
|
+
}
|
|
369
|
+
const proceed = await confirm(`Apply this fix to ${configPath}? [y/N] `);
|
|
370
|
+
if (proceed) {
|
|
371
|
+
working = applyConfigPatch(working, patch);
|
|
372
|
+
applied++;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
skipped++;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (args.dryRun) {
|
|
379
|
+
if (args.json) {
|
|
380
|
+
console.log(JSON.stringify({ fixable: dryRunEntries.length, fixes: dryRunEntries }, null, 2));
|
|
381
|
+
}
|
|
382
|
+
else if (dryRunEntries.length === 0) {
|
|
383
|
+
console.log(pc.green('Dry run: nothing to fix (every fixable diagnostic is already applied).'));
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
for (const entry of dryRunEntries) {
|
|
387
|
+
console.log('');
|
|
388
|
+
console.log(pc.bold(`[dry-run] ${entry.serverName} — ${entry.message} (${entry.checkId})`));
|
|
389
|
+
for (const d of entry.diff) {
|
|
390
|
+
console.log(pc.red(` - ${d.field}: ${JSON.stringify(d.before)}`));
|
|
391
|
+
console.log(pc.green(` + ${d.field}: ${JSON.stringify(d.after)}`));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
console.log(pc.dim(`\nDry run: ${dryRunEntries.length} fix(es) shown, 0 applied.`));
|
|
395
|
+
}
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
if (applied > 0) {
|
|
399
|
+
const backupPath = `${configPath}.bak`;
|
|
400
|
+
writeFileSync(backupPath, rawText, 'utf-8');
|
|
401
|
+
writeFileSync(configPath, `${JSON.stringify(working, null, 2)}\n`, 'utf-8');
|
|
402
|
+
console.log(pc.green(`\nApplied ${applied} fix(es). Original saved to ${backupPath}.`));
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
console.log(pc.dim('\nNo fixes applied.'));
|
|
406
|
+
}
|
|
407
|
+
if (skipped > 0) {
|
|
408
|
+
console.log(pc.yellow(`Skipped ${skipped} fix(es).`));
|
|
409
|
+
}
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
281
413
|
let args;
|
|
282
414
|
try {
|
|
283
415
|
args = parseArgs(argv);
|
|
284
416
|
}
|
|
285
417
|
catch (err) {
|
|
286
|
-
console.error(pc.red(`mcp-
|
|
418
|
+
console.error(pc.red(`mcp-medic usage error: ${err instanceof Error ? err.message : String(err)}`));
|
|
287
419
|
return 2;
|
|
288
420
|
}
|
|
289
421
|
if (args.command === 'help') {
|
|
290
422
|
printHelp();
|
|
291
423
|
return 0;
|
|
292
424
|
}
|
|
425
|
+
if (args.command === 'fix' && args.registryServer) {
|
|
426
|
+
console.error(pc.red('mcp-doctor fix does not support --registry — there is no local file to write the fix to.'));
|
|
427
|
+
return 2;
|
|
428
|
+
}
|
|
293
429
|
// Handle diff command
|
|
294
430
|
if (args.command === 'diff') {
|
|
295
431
|
if (!args.configPath || !args.configPathB) {
|
|
296
|
-
console.error(pc.red('mcp-
|
|
432
|
+
console.error(pc.red('mcp-medic diff requires two config paths: mcp-medic diff <configA> <configB>'));
|
|
297
433
|
return 2;
|
|
298
434
|
}
|
|
299
435
|
const [resA, resB] = await Promise.all([
|
|
@@ -353,7 +489,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
353
489
|
console.log(JSON.stringify(fleetReport, null, 2));
|
|
354
490
|
}
|
|
355
491
|
else {
|
|
356
|
-
console.log(pc.bold(`\nMCP
|
|
492
|
+
console.log(pc.bold(`\nMCP Medic Fleet Report`));
|
|
357
493
|
console.log(`Files scanned: ${fleetReport.totalFiles} (${fleetReport.successfulFiles} valid, ${fleetReport.failedFiles} invalid)`);
|
|
358
494
|
console.log(`Servers checked: ${fleetReport.totalServers}`);
|
|
359
495
|
console.log(`Results: ${fleetReport.totalErrors} error(s), ${fleetReport.totalWarnings} warning(s)\n`);
|
|
@@ -441,17 +577,23 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
441
577
|
});
|
|
442
578
|
});
|
|
443
579
|
}
|
|
580
|
+
if (args.command === 'fix') {
|
|
581
|
+
const { config, rawText, rawJson, exitCode } = await loadConfigFromPath(targetPath);
|
|
582
|
+
if (exitCode !== undefined || !config || rawText === undefined)
|
|
583
|
+
return exitCode ?? 2;
|
|
584
|
+
return executeFix(targetPath, rawText, rawJson, config, args, deps.confirm ?? defaultConfirm);
|
|
585
|
+
}
|
|
444
586
|
const { config, exitCode } = await loadConfigFromPath(targetPath);
|
|
445
587
|
if (exitCode !== undefined || !config)
|
|
446
588
|
return exitCode ?? 2;
|
|
447
589
|
return executeCheck(config, args);
|
|
448
590
|
}
|
|
449
591
|
// Only invoke automatically when run as CLI entry point
|
|
450
|
-
if (process.argv[1] && (process.argv[1].endsWith('/cli.js') || process.argv[1].endsWith('/cli.ts') || process.argv[1].endsWith('/mcp-doctor'))) {
|
|
592
|
+
if (process.argv[1] && (process.argv[1].endsWith('/cli.js') || process.argv[1].endsWith('/cli.ts') || process.argv[1].endsWith('/mcp-medic') || process.argv[1].endsWith('/mcp-doctor') || process.argv[1].endsWith('/mcpmedic') || process.argv[1].endsWith('/mcpdoctor'))) {
|
|
451
593
|
main()
|
|
452
594
|
.then((code) => process.exit(code))
|
|
453
595
|
.catch((err) => {
|
|
454
|
-
console.error(pc.red(`mcp-
|
|
596
|
+
console.error(pc.red(`mcp-medic: unexpected error: ${err instanceof Error ? err.message : String(err)}`));
|
|
455
597
|
process.exit(2);
|
|
456
598
|
});
|
|
457
599
|
}
|
package/dist/conformance.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export interface ConformanceResult {
|
|
|
4
4
|
errors: string[];
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
|
-
* Conformance test helper for community check packages (mcp-
|
|
7
|
+
* Conformance test helper for community check packages (mcp-medic-check-*).
|
|
8
8
|
* Validates that a Check implementation conforms strictly to CONTRACT.md.
|
|
9
9
|
*/
|
|
10
10
|
export declare function runCheckConformanceSuite(check: Check): Promise<ConformanceResult>;
|
package/dist/conformance.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const VALID_SEVERITIES = new Set(['error', 'warning', 'info']);
|
|
2
2
|
/**
|
|
3
|
-
* Conformance test helper for community check packages (mcp-
|
|
3
|
+
* Conformance test helper for community check packages (mcp-medic-check-*).
|
|
4
4
|
* Validates that a Check implementation conforms strictly to CONTRACT.md.
|
|
5
5
|
*/
|
|
6
6
|
export async function runCheckConformanceSuite(check) {
|
|
@@ -64,11 +64,11 @@ export interface MinimalVSCodeAPI {
|
|
|
64
64
|
export declare function isMCPConfigFile(fileName: string): boolean;
|
|
65
65
|
export declare function mapSeverity(severity: Severity, vscode: MinimalVSCodeAPI): number;
|
|
66
66
|
/**
|
|
67
|
-
* Validates a document content using mcp-
|
|
67
|
+
* Validates a document content using mcp-medic core and returns VS Code diagnostics.
|
|
68
68
|
*/
|
|
69
69
|
export declare function validateMCPDocument(document: MinimalTextDocument, vscode: MinimalVSCodeAPI): Promise<MinimalDiagnostic[]>;
|
|
70
70
|
/**
|
|
71
|
-
* Activates the lightweight MCP
|
|
71
|
+
* Activates the lightweight MCP Medic extension.
|
|
72
72
|
*/
|
|
73
73
|
export declare function activateExtension(context: {
|
|
74
74
|
subscriptions: {
|
package/dist/extension/index.js
CHANGED
|
@@ -33,7 +33,7 @@ export function mapSeverity(severity, vscode) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* Validates a document content using mcp-
|
|
36
|
+
* Validates a document content using mcp-medic core and returns VS Code diagnostics.
|
|
37
37
|
*/
|
|
38
38
|
export async function validateMCPDocument(document, vscode) {
|
|
39
39
|
if (!isMCPConfigFile(document.fileName)) {
|
|
@@ -50,7 +50,7 @@ export async function validateMCPDocument(document, vscode) {
|
|
|
50
50
|
range: new vscode.Range(0, 0, 0, 1),
|
|
51
51
|
message: `JSON syntax error in MCP config: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
|
|
52
52
|
severity: vscode.DiagnosticSeverity.Error,
|
|
53
|
-
source: 'mcp-
|
|
53
|
+
source: 'mcp-medic',
|
|
54
54
|
code: 'config.parse-error',
|
|
55
55
|
},
|
|
56
56
|
];
|
|
@@ -61,7 +61,7 @@ export async function validateMCPDocument(document, vscode) {
|
|
|
61
61
|
range: new vscode.Range(0, 0, 0, 1),
|
|
62
62
|
message: errMessage,
|
|
63
63
|
severity: vscode.DiagnosticSeverity.Error,
|
|
64
|
-
source: 'mcp-
|
|
64
|
+
source: 'mcp-medic',
|
|
65
65
|
code: 'config.invalid-schema',
|
|
66
66
|
}));
|
|
67
67
|
}
|
|
@@ -79,7 +79,7 @@ export async function validateMCPDocument(document, vscode) {
|
|
|
79
79
|
range: new vscode.Range(line, 0, line, endChar),
|
|
80
80
|
message,
|
|
81
81
|
severity: mapSeverity(d.severity, vscode),
|
|
82
|
-
source: 'mcp-
|
|
82
|
+
source: 'mcp-medic',
|
|
83
83
|
code: d.checkId,
|
|
84
84
|
suggestedFix: d.suggestedFix?.description,
|
|
85
85
|
});
|
|
@@ -87,10 +87,10 @@ export async function validateMCPDocument(document, vscode) {
|
|
|
87
87
|
return diagnostics;
|
|
88
88
|
}
|
|
89
89
|
/**
|
|
90
|
-
* Activates the lightweight MCP
|
|
90
|
+
* Activates the lightweight MCP Medic extension.
|
|
91
91
|
*/
|
|
92
92
|
export function activateExtension(context, vscode) {
|
|
93
|
-
const collection = vscode.languages.createDiagnosticCollection('mcp-
|
|
93
|
+
const collection = vscode.languages.createDiagnosticCollection('mcp-medic');
|
|
94
94
|
context.subscriptions.push(collection);
|
|
95
95
|
let debounceTimer;
|
|
96
96
|
const triggerValidation = (document) => {
|
|
@@ -114,7 +114,7 @@ export function activateExtension(context, vscode) {
|
|
|
114
114
|
const currentLineText = lines[line] || '';
|
|
115
115
|
return {
|
|
116
116
|
contents: [
|
|
117
|
-
`**mcp-
|
|
117
|
+
`**mcp-medic diagnostic** (Line ${line + 1})`,
|
|
118
118
|
`Inspecting: \`${currentLineText.trim()}\``,
|
|
119
119
|
],
|
|
120
120
|
};
|
package/dist/fix.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic for `mcp-doctor fix`: applying a DiagnosticResult's
|
|
3
|
+
* `suggestedFix.patch` to the raw (untyped) config JSON that was read from
|
|
4
|
+
* disk. Deliberately has no file I/O or prompting — see src/cli.ts for the
|
|
5
|
+
* interactive `fix` command that wires this up to reads/writes/confirmation.
|
|
6
|
+
*
|
|
7
|
+
* `SuggestedFix.patch` is typed `unknown` in CONTRACT.md so any check can
|
|
8
|
+
* carry one without a CONTRACT change; this module defines and validates
|
|
9
|
+
* the one shape mcp-doctor's own checks emit and `fix` knows how to apply:
|
|
10
|
+
* a named-server, shallow field patch.
|
|
11
|
+
*/
|
|
12
|
+
export interface ConfigPatch {
|
|
13
|
+
/** Matches MCPServerConfig.name / config.servers[i].name in the raw JSON. */
|
|
14
|
+
serverName: string;
|
|
15
|
+
/** Shallow field values to merge into that server's raw config object. */
|
|
16
|
+
set: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export declare function isConfigPatch(value: unknown): value is ConfigPatch;
|
|
19
|
+
export interface RawMcpConfig {
|
|
20
|
+
servers: Array<Record<string, unknown>>;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
export declare function isRawMcpConfig(value: unknown): value is RawMcpConfig;
|
|
24
|
+
export interface FieldDiff {
|
|
25
|
+
field: string;
|
|
26
|
+
before: unknown;
|
|
27
|
+
after: unknown;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Computes what a patch would change, without mutating `rawConfig`.
|
|
31
|
+
* Returns [] when the target server can't be found, or when every field in
|
|
32
|
+
* `patch.set` already matches — i.e. the config is already fixed. This is
|
|
33
|
+
* what makes `fix` idempotent: applying an already-applied patch is a no-op
|
|
34
|
+
* with nothing to confirm.
|
|
35
|
+
*/
|
|
36
|
+
export declare function diffConfigPatch(rawConfig: unknown, patch: ConfigPatch): FieldDiff[];
|
|
37
|
+
/**
|
|
38
|
+
* Returns a new raw config with `patch.set` fields merged into the named
|
|
39
|
+
* server. Never mutates `rawConfig`. Throws only on a structurally invalid
|
|
40
|
+
* config (not a config-shaped object) — callers should have already run it
|
|
41
|
+
* through loadConfig() before ever reaching a patch.
|
|
42
|
+
*/
|
|
43
|
+
export declare function applyConfigPatch(rawConfig: unknown, patch: ConfigPatch): RawMcpConfig;
|
|
44
|
+
export declare function formatFieldDiff(diff: FieldDiff): string;
|
package/dist/fix.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic for `mcp-doctor fix`: applying a DiagnosticResult's
|
|
3
|
+
* `suggestedFix.patch` to the raw (untyped) config JSON that was read from
|
|
4
|
+
* disk. Deliberately has no file I/O or prompting — see src/cli.ts for the
|
|
5
|
+
* interactive `fix` command that wires this up to reads/writes/confirmation.
|
|
6
|
+
*
|
|
7
|
+
* `SuggestedFix.patch` is typed `unknown` in CONTRACT.md so any check can
|
|
8
|
+
* carry one without a CONTRACT change; this module defines and validates
|
|
9
|
+
* the one shape mcp-doctor's own checks emit and `fix` knows how to apply:
|
|
10
|
+
* a named-server, shallow field patch.
|
|
11
|
+
*/
|
|
12
|
+
export function isConfigPatch(value) {
|
|
13
|
+
if (!value || typeof value !== 'object')
|
|
14
|
+
return false;
|
|
15
|
+
const v = value;
|
|
16
|
+
return (typeof v.serverName === 'string' &&
|
|
17
|
+
v.serverName.length > 0 &&
|
|
18
|
+
typeof v.set === 'object' &&
|
|
19
|
+
v.set !== null &&
|
|
20
|
+
!Array.isArray(v.set));
|
|
21
|
+
}
|
|
22
|
+
export function isRawMcpConfig(value) {
|
|
23
|
+
return (!!value &&
|
|
24
|
+
typeof value === 'object' &&
|
|
25
|
+
!Array.isArray(value) &&
|
|
26
|
+
Array.isArray(value.servers));
|
|
27
|
+
}
|
|
28
|
+
function findServer(config, serverName) {
|
|
29
|
+
return config.servers.find((s) => typeof s === 'object' && s !== null && !Array.isArray(s) && s.name === serverName);
|
|
30
|
+
}
|
|
31
|
+
function deepEqual(a, b) {
|
|
32
|
+
if (a === b)
|
|
33
|
+
return true;
|
|
34
|
+
if (typeof a !== typeof b)
|
|
35
|
+
return false;
|
|
36
|
+
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
|
37
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Computes what a patch would change, without mutating `rawConfig`.
|
|
43
|
+
* Returns [] when the target server can't be found, or when every field in
|
|
44
|
+
* `patch.set` already matches — i.e. the config is already fixed. This is
|
|
45
|
+
* what makes `fix` idempotent: applying an already-applied patch is a no-op
|
|
46
|
+
* with nothing to confirm.
|
|
47
|
+
*/
|
|
48
|
+
export function diffConfigPatch(rawConfig, patch) {
|
|
49
|
+
if (!isRawMcpConfig(rawConfig))
|
|
50
|
+
return [];
|
|
51
|
+
const server = findServer(rawConfig, patch.serverName);
|
|
52
|
+
if (!server)
|
|
53
|
+
return [];
|
|
54
|
+
const diffs = [];
|
|
55
|
+
for (const [field, after] of Object.entries(patch.set)) {
|
|
56
|
+
const before = server[field];
|
|
57
|
+
if (!deepEqual(before, after)) {
|
|
58
|
+
diffs.push({ field, before, after });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return diffs;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Returns a new raw config with `patch.set` fields merged into the named
|
|
65
|
+
* server. Never mutates `rawConfig`. Throws only on a structurally invalid
|
|
66
|
+
* config (not a config-shaped object) — callers should have already run it
|
|
67
|
+
* through loadConfig() before ever reaching a patch.
|
|
68
|
+
*/
|
|
69
|
+
export function applyConfigPatch(rawConfig, patch) {
|
|
70
|
+
if (!isRawMcpConfig(rawConfig)) {
|
|
71
|
+
throw new Error('applyConfigPatch: expected a config object with a "servers" array');
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...rawConfig,
|
|
75
|
+
servers: rawConfig.servers.map((s) => {
|
|
76
|
+
if (typeof s === 'object' && s !== null && !Array.isArray(s) && s.name === patch.serverName) {
|
|
77
|
+
return { ...s, ...patch.set };
|
|
78
|
+
}
|
|
79
|
+
return s;
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function formatFieldDiff(diff) {
|
|
84
|
+
return ` - ${diff.field}: ${JSON.stringify(diff.before)}\n + ${diff.field}: ${JSON.stringify(diff.after)}`;
|
|
85
|
+
}
|
package/dist/junit.js
CHANGED
|
@@ -14,7 +14,7 @@ export function formatReportJUnit(report) {
|
|
|
14
14
|
const totalTests = Math.max(1, report.connections.length + report.diagnostics.length);
|
|
15
15
|
const totalErrors = report.summary.errors;
|
|
16
16
|
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
17
|
-
lines.push(`<testsuites name="mcp-
|
|
17
|
+
lines.push(`<testsuites name="mcp-medic" tests="${totalTests}" failures="${totalErrors}" errors="0">`);
|
|
18
18
|
lines.push(` <testsuite name="${escapeXml(report.configSource || 'mcp-config')}" tests="${totalTests}" failures="${totalErrors}">`);
|
|
19
19
|
// Connection testcases
|
|
20
20
|
for (const conn of report.connections) {
|
|
@@ -51,7 +51,7 @@ export function formatReportJUnit(report) {
|
|
|
51
51
|
export function formatFleetReportJUnit(fleetReport) {
|
|
52
52
|
const lines = [];
|
|
53
53
|
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
54
|
-
lines.push(`<testsuites name="mcp-
|
|
54
|
+
lines.push(`<testsuites name="mcp-medic-fleet" tests="${fleetReport.totalServers}" failures="${fleetReport.totalErrors}">`);
|
|
55
55
|
for (const fileResult of fleetReport.fileResults) {
|
|
56
56
|
if (fileResult.report) {
|
|
57
57
|
const rep = fileResult.report;
|
package/dist/policy.d.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import type { Check, TransportType } from './types.js';
|
|
2
|
-
export interface
|
|
2
|
+
export interface MCPMedicPolicy {
|
|
3
3
|
bannedTransports?: TransportType[];
|
|
4
4
|
allowedDomains?: string[];
|
|
5
5
|
minDescriptionLength?: number;
|
|
6
6
|
requireToolDescriptions?: boolean;
|
|
7
7
|
}
|
|
8
|
+
export type MCPDoctorPolicy = MCPMedicPolicy;
|
|
8
9
|
/**
|
|
9
|
-
* Loads a policy from a file path, or auto-discovers .mcp-doctor-policy.json in cwd.
|
|
10
|
+
* Loads a policy from a file path, or auto-discovers .mcp-medic-policy.json (or .mcp-doctor-policy.json) in cwd.
|
|
10
11
|
*/
|
|
11
|
-
export declare function loadPolicy(policyPath?: string, cwd?: string):
|
|
12
|
+
export declare function loadPolicy(policyPath?: string, cwd?: string): MCPMedicPolicy | undefined;
|
|
12
13
|
/**
|
|
13
14
|
* Creates composable Check instances from an organization policy.
|
|
14
15
|
* Policy checks seamlessly integrate with the standard Check interface.
|
package/dist/policy.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
/**
|
|
4
|
-
* Loads a policy from a file path, or auto-discovers .mcp-doctor-policy.json in cwd.
|
|
4
|
+
* Loads a policy from a file path, or auto-discovers .mcp-medic-policy.json (or .mcp-doctor-policy.json) in cwd.
|
|
5
5
|
*/
|
|
6
6
|
export function loadPolicy(policyPath, cwd = process.cwd()) {
|
|
7
|
-
|
|
7
|
+
let targetPath = policyPath ? resolve(policyPath) : resolve(cwd, '.mcp-medic-policy.json');
|
|
8
|
+
if (!policyPath && !existsSync(targetPath)) {
|
|
9
|
+
const fallbackPath = resolve(cwd, '.mcp-doctor-policy.json');
|
|
10
|
+
if (existsSync(fallbackPath)) {
|
|
11
|
+
targetPath = fallbackPath;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
8
14
|
if (!existsSync(targetPath)) {
|
|
9
15
|
return undefined;
|
|
10
16
|
}
|
|
@@ -81,7 +87,7 @@ export function createPolicyChecks(policy) {
|
|
|
81
87
|
serverName: connection.server.name,
|
|
82
88
|
details: { hostname, allowedDomains: allowed, url: connection.server.url },
|
|
83
89
|
suggestedFix: {
|
|
84
|
-
description: `Configure server "${connection.server.name}" to use an approved domain or update
|
|
90
|
+
description: `Configure server "${connection.server.name}" to use an approved domain or update policy configuration.`,
|
|
85
91
|
},
|
|
86
92
|
});
|
|
87
93
|
}
|
package/dist/protocol/connect.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
3
|
-
const CLIENT_INFO = { name: 'mcp-
|
|
3
|
+
const CLIENT_INFO = { name: 'mcp-medic', version: '1.0.0' };
|
|
4
4
|
function messageOf(error) {
|
|
5
5
|
return error instanceof Error ? error.message : String(error);
|
|
6
6
|
}
|
package/dist/report.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Human-readable plain text report formatter. */
|
|
2
2
|
export function formatReportHuman(report, options = {}) {
|
|
3
3
|
const lines = [];
|
|
4
|
-
lines.push(`mcp-
|
|
4
|
+
lines.push(`mcp-medic report${report.configSource ? ` — ${report.configSource}` : ''}`);
|
|
5
5
|
lines.push(`${report.summary.connected}/${report.summary.servers} servers connected, ` +
|
|
6
6
|
`${report.summary.errors} error(s), ${report.summary.warnings} warning(s)`);
|
|
7
7
|
lines.push('');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-medic",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Diagnose broken MCP (Model Context Protocol) server configs before they break your agent silently.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"prepack": "npm run build",
|
|
20
21
|
"typecheck": "tsc --noEmit",
|
|
21
22
|
"test": "vitest run",
|
|
22
|
-
"test:watch": "vitest"
|
|
23
|
+
"test:watch": "vitest",
|
|
24
|
+
"release": "node scripts/auto-release.js"
|
|
23
25
|
},
|
|
24
26
|
"keywords": [
|
|
25
27
|
"mcp",
|