mcp-medic 1.0.0 → 1.0.2
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 +99 -37
- 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 +186 -23
- 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 +11 -3
package/README.md
CHANGED
|
@@ -1,27 +1,43 @@
|
|
|
1
|
-
# mcp-
|
|
1
|
+
# mcp-medic
|
|
2
|
+
|
|
3
|
+
[](https://github.com/shivam039/mcp-doctor/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/mcp-medic)
|
|
5
|
+
[](./LICENSE)
|
|
2
6
|
|
|
3
7
|
Diagnose broken MCP (Model Context Protocol) server configs before they break your agent silently.
|
|
4
8
|
|
|
5
|
-
`mcp-
|
|
9
|
+
`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 and CI-ready exit codes.
|
|
10
|
+
|
|
11
|
+
## Why this exists
|
|
12
|
+
|
|
13
|
+
A broken MCP server config usually doesn't fail loudly — it fails as your agent silently missing a tool, retrying a handshake forever, or getting a malformed schema it can't reason about. Those bugs are miserable to track down after the fact. `mcp-medic` catches them at the config level, before an agent ever touches the server: it actually connects (real `initialize` handshake, real `tools/list`), so "the config parses" and "the server actually works" are checked together, in CI, with a real exit code.
|
|
14
|
+
|
|
15
|
+
## ⚠️ How this works — please read before pointing it at a config
|
|
16
|
+
|
|
17
|
+
`mcp-medic` validates a config by **actually connecting to the servers in it**:
|
|
18
|
+
- `stdio` transport → it **spawns the configured `command`** as a real child process on your machine.
|
|
19
|
+
- `sse`/`http` transport → it **makes real network requests** to the configured `url`, including any `headers` you've set (e.g. auth tokens).
|
|
20
|
+
|
|
21
|
+
This is the whole point (a real handshake, not a schema guess) — but it means you should only run it against configs you trust, the same way you'd only `npm install` a `package.json` you trust. See [SECURITY.md](./SECURITY.md) for the full threat model.
|
|
6
22
|
|
|
7
23
|
---
|
|
8
24
|
|
|
9
25
|
## Features
|
|
10
26
|
|
|
11
|
-
- 🔍 **Auto-Discovery**: Run `mcp-
|
|
27
|
+
- 🔍 **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
28
|
- 💡 **Auto-Fix Suggestions**: Diagnose issues with clear, actionable fix suggestions using `--show-fixes`.
|
|
13
|
-
- 🌐 **Registry Validation**: Validate published registry entries directly using `mcp-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
- 📜 **Policy-as-Code**: Enforce organizational constraints (e.g., banned transports, domain allowlists, minimum description lengths) via `.mcp-
|
|
29
|
+
- 🌐 **Registry Validation**: Validate published registry entries directly using `mcp-medic check --registry <server-id>`.
|
|
30
|
+
- 🧪 **Fleet Validation** (experimental): Scan and validate monorepos or multi-team configurations with `mcp-medic check-all "<glob>"`.
|
|
31
|
+
- 🧪 **Drift Detection** (experimental): Catch environment divergence between staging and production configs with `mcp-medic diff <configA> <configB>`.
|
|
32
|
+
- 📜 **Policy-as-Code**: Enforce organizational constraints (e.g., banned transports, domain allowlists, minimum description lengths) via `.mcp-medic-policy.json` / `--policy`.
|
|
17
33
|
- 📸 **Snapshot Baseline Mode**: Filter out legacy diagnostics with `--snapshot <baseline.json>` to gate only on newly introduced regressions.
|
|
18
34
|
- 📊 **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-
|
|
35
|
+
- 👀 **Watch Mode**: Re-run validation on save using `mcp-medic watch <path>`.
|
|
20
36
|
- ⚡ **Transport Hardening**: Full handshake validation across stdio, HTTP (with OAuth token refresh), and SSE (with automatic retry resilience).
|
|
21
|
-
-
|
|
37
|
+
- 🧪 **VS Code Extension** (experimental, not yet on the Marketplace): in-editor squiggles and hover tooltips — runnable from source today, see [vscode-extension/](./vscode-extension/).
|
|
22
38
|
- 🚦 **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
|
|
39
|
+
- 🤖 **GitHub Action**: Drop-in CI integration via `shivam039/mcp-doctor@main` (or `mcp-medic-action`).
|
|
40
|
+
- 🧩 **Community Checks** (framework ready, no packages published yet): a conformance test helper (`runCheckConformanceSuite`) so anyone can build and publish their own `mcp-medic-check-*` plugin.
|
|
25
41
|
|
|
26
42
|
---
|
|
27
43
|
|
|
@@ -29,29 +45,29 @@ Diagnose broken MCP (Model Context Protocol) server configs before they break yo
|
|
|
29
45
|
|
|
30
46
|
```bash
|
|
31
47
|
# Run against auto-discovered configs in current project / Claude Desktop
|
|
32
|
-
npx mcp-
|
|
48
|
+
npx mcp-medic
|
|
33
49
|
|
|
34
50
|
# Run check on a specific configuration file
|
|
35
|
-
npx mcp-
|
|
51
|
+
npx mcp-medic check path/to/config.json
|
|
36
52
|
|
|
37
53
|
# Validate all configs across a monorepo
|
|
38
|
-
npx mcp-
|
|
54
|
+
npx mcp-medic check-all "configs/**/*.json"
|
|
39
55
|
|
|
40
56
|
# Compare two configs to detect drift
|
|
41
|
-
npx mcp-
|
|
57
|
+
npx mcp-medic diff staging.mcp.json prod.mcp.json
|
|
42
58
|
|
|
43
59
|
# Validate a published registry server directly without a local config
|
|
44
|
-
npx mcp-
|
|
45
|
-
npx mcp-
|
|
60
|
+
npx mcp-medic check --registry @modelcontextprotocol/server-memory
|
|
61
|
+
npx mcp-medic check --registry smithery:username/my-server
|
|
46
62
|
|
|
47
63
|
# Apply organizational policy rules and export to JUnit XML
|
|
48
|
-
npx mcp-
|
|
64
|
+
npx mcp-medic check path/to/config.json --policy .mcp-medic-policy.json --export-junit results.xml
|
|
49
65
|
|
|
50
66
|
# Display suggested fixes for flagged diagnostics
|
|
51
|
-
npx mcp-
|
|
67
|
+
npx mcp-medic check path/to/config.json --show-fixes
|
|
52
68
|
|
|
53
69
|
# Watch mode (re-runs checks on save)
|
|
54
|
-
npx mcp-
|
|
70
|
+
npx mcp-medic watch path/to/config.json
|
|
55
71
|
```
|
|
56
72
|
|
|
57
73
|
---
|
|
@@ -65,8 +81,9 @@ npx mcp-doctor watch path/to/config.json
|
|
|
65
81
|
| `diff <configA> <configB>` | Detect drift between two configuration files |
|
|
66
82
|
| `check --registry <id>` | Validate a published registry server directly |
|
|
67
83
|
| `watch <path>` | Watch configuration file and re-run checks on file save |
|
|
84
|
+
| `fix <path>` | Interactively apply mechanical suggested fixes (see [Auto-Fix](#auto-fix-mcp-medic-fix) below) |
|
|
68
85
|
| `--config <path>` | Explicit configuration path |
|
|
69
|
-
| `--policy <path>` | Apply organizational policy rules (`.mcp-
|
|
86
|
+
| `--policy <path>` | Apply organizational policy rules (`.mcp-medic-policy.json`) |
|
|
70
87
|
| `--snapshot <path>` | Compare against baseline snapshot, reporting regressions only |
|
|
71
88
|
| `--update-snapshot <path>` | Save diagnostic report as new baseline snapshot |
|
|
72
89
|
| `--export-junit <file>` | Export report in JUnit XML format |
|
|
@@ -76,6 +93,8 @@ npx mcp-doctor watch path/to/config.json
|
|
|
76
93
|
| `--verbose`, `-v` | Output raw JSON-RPC traffic and debug messages |
|
|
77
94
|
| `--json` | Output full diagnostic report in JSON |
|
|
78
95
|
| `--timeout <ms>` | Per-server handshake timeout in milliseconds (default: `5000`) |
|
|
96
|
+
| `--help`, `-h` | Show usage help |
|
|
97
|
+
| `--version`, `-V` | Print the installed version |
|
|
79
98
|
|
|
80
99
|
### Exit Codes
|
|
81
100
|
|
|
@@ -85,7 +104,27 @@ npx mcp-doctor watch path/to/config.json
|
|
|
85
104
|
|
|
86
105
|
---
|
|
87
106
|
|
|
88
|
-
##
|
|
107
|
+
## Auto-Fix (`mcp-medic fix`)
|
|
108
|
+
|
|
109
|
+
For the small subset of diagnostics that carry a mechanical fix (today: upgrading a `security.untrusted-remote` server's `http://` URL to `https://`), `mcp-medic fix` will show you a diff and ask for confirmation before touching your config file:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
mcp-medic fix path/to/config.json
|
|
113
|
+
|
|
114
|
+
# Preview every available fix without prompting or writing anything
|
|
115
|
+
mcp-medic fix path/to/config.json --dry-run
|
|
116
|
+
|
|
117
|
+
# Only offer fixes from one specific check
|
|
118
|
+
mcp-medic fix path/to/config.json --check security.untrusted-remote
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- Fixes are **never bulk-applied** — each one is shown as a diff and requires an explicit `y`/`N`.
|
|
122
|
+
- A `.bak` copy of the original file is written before any change, unconditionally.
|
|
123
|
+
- Most diagnostics (schema issues, the two other security checks) don't have a mechanical fix — `fix` reports "No auto-fixable diagnostics found" for those rather than guessing.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Policy-as-Code (`.mcp-medic-policy.json`)
|
|
89
128
|
|
|
90
129
|
Define organization-wide policies that compose with built-in checks:
|
|
91
130
|
|
|
@@ -99,11 +138,9 @@ Define organization-wide policies that compose with built-in checks:
|
|
|
99
138
|
|
|
100
139
|
---
|
|
101
140
|
|
|
102
|
-
## VS Code Extension
|
|
141
|
+
## VS Code Extension (experimental)
|
|
103
142
|
|
|
104
|
-
The `mcp
|
|
105
|
-
- Inline squiggles on `.mcp.json`, `mcp.json`, and `claude_desktop_config.json` files as you edit.
|
|
106
|
-
- Hover tooltips showing the full diagnostic explanation and actionable suggested fix.
|
|
143
|
+
**Not published on the VS Code Marketplace yet.** The diagnostics logic (inline squiggles + hover tooltips on `.mcp.json`, `mcp.json`, and `claude_desktop_config.json` files) is real and working, but it's currently only runnable from source as an Extension Development Host, or packaged locally as a `.vsix`. See [vscode-extension/README.md](./vscode-extension/README.md) for setup — it takes about five minutes.
|
|
107
144
|
|
|
108
145
|
---
|
|
109
146
|
|
|
@@ -133,25 +170,47 @@ jobs:
|
|
|
133
170
|
|
|
134
171
|
| Check ID | Package | Scope | Description |
|
|
135
172
|
|---|---|---|---|
|
|
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
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
173
|
+
| `schema.malformed` | `mcp-medic` | **Official** | Verifies inputSchema is a valid JSON schema object |
|
|
174
|
+
| `schema.missing-required` | `mcp-medic` | **Official** | Flags required fields missing from properties |
|
|
175
|
+
| `schema.type-mismatch` | `mcp-medic` | **Official** | Flags invalid JSON schema types and enum mismatches |
|
|
176
|
+
| `schema.missing-description` | `mcp-medic` | **Official** | Flags tools and properties missing documentation |
|
|
177
|
+
| `schema.sample-call-simulation` | `mcp-medic` | **Official** | Simulates and validates synthetic call payloads |
|
|
178
|
+
| `security.untrusted-remote` | `mcp-medic` | **Official** (heuristic) | Flags non-HTTPS or raw-IP SSE/HTTP server URLs |
|
|
179
|
+
| `security.overbroad-permissions` | `mcp-medic` | **Official** (heuristic) | Flags tools with unscoped shell/filesystem/network parameters |
|
|
180
|
+
| `security.prompt-injection-risk` | `mcp-medic` | **Official** (heuristic) | Flags instruction-like language in tool descriptions aimed at the model |
|
|
181
|
+
| `policy.*` | `mcp-medic` | **Official** | Evaluates policy-as-code rules (transports, domains, length) |
|
|
182
|
+
| `community.strict-typing` | `mcp-medic-check-strict-typing` | *Planned / example* | Would enforce strict property type annotations |
|
|
183
|
+
| `community.no-empty-enums` | `mcp-medic-check-no-empty-enums` | *Planned / example* | Would ensure non-empty enum option lists |
|
|
184
|
+
|
|
185
|
+
The two `community.*` rows above are examples of what a check plugin could look like — **those packages aren't published yet**. `runCheckConformanceSuite()` (used by the official checks' own tests) is the tool for validating a plugin conforms to the `Check` interface; see [Authoring Custom Checks](./docs/AUTHORING_CHECKS.md) if you want to build and publish one.
|
|
186
|
+
|
|
187
|
+
The `security.*` checks are heuristic — they pattern-match on what a server *declares* (URLs, tool descriptions, schemas), not what it actually does at runtime. Every diagnostic they produce says so explicitly; they're a signal to investigate, not proof of a problem.
|
|
188
|
+
|
|
189
|
+
---
|
|
144
190
|
|
|
145
|
-
|
|
191
|
+
## Known Limitations
|
|
192
|
+
|
|
193
|
+
- **Handshake timeout defaults to 5000ms** per server (`--timeout <ms>` to change it). A slow-starting stdio server or a server behind a slow network path can fail with `status: 'timeout'` even though it would eventually respond.
|
|
194
|
+
- **`schema.sample-call-simulation`** builds synthetic payloads from a tool's declared JSON Schema and checks the schema is internally consistent (e.g. catches an empty `enum`, or conflicting `minimum`/`maximum`) — it does **not** actually invoke the tool, and it does not validate business logic, side effects, or whether the tool's real output matches its declared schema.
|
|
195
|
+
- **`security.*` checks are heuristic pattern-matching**, not a security audit — see the note above. They can both miss real issues and flag benign configs (e.g. a legitimate local dev server on plain `http://`).
|
|
196
|
+
- **Fleet commands (`check-all`, `diff`) are newer and less battle-tested** than `check`/`watch` — the core check pipeline they're built on is the same, but edge cases in glob matching or drift diffing are more likely.
|
|
197
|
+
- **The VS Code extension and community check packages are not shipped/published** — see the sections above.
|
|
198
|
+
- **First run via `npx`** pays a one-time cost to resolve and download the package; once installed (or on a warm npx cache), `--help`/`--version` return in well under 100ms.
|
|
146
199
|
|
|
147
200
|
---
|
|
148
201
|
|
|
202
|
+
## Contributing & Support
|
|
203
|
+
|
|
204
|
+
- 🐛 [Report a bug](https://github.com/shivam039/mcp-doctor/issues/new?template=bug_report.md) / 💡 [Request a feature](https://github.com/shivam039/mcp-doctor/issues/new?template=feature_request.md)
|
|
205
|
+
- 📋 See [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a PR
|
|
206
|
+
- 📝 [CHANGELOG](./CHANGELOG.md)
|
|
207
|
+
|
|
149
208
|
## Governance, Stability & Security
|
|
150
209
|
|
|
151
210
|
- 🏛️ [Governance & Project Sustainability](./GOVERNANCE.md)
|
|
152
|
-
- 📜 [
|
|
211
|
+
- 📜 [Stability & Deprecation Policy](./docs/STABILITY_POLICY.md) — v1.0's initial stable surface
|
|
153
212
|
- 🔒 [Security Policy & Threat Model](./SECURITY.md)
|
|
154
|
-
- 💡 [RFC Process](./docs/RFC_PROCESS.md)
|
|
213
|
+
- 💡 [RFC Process](./docs/RFC_PROCESS.md) (for changes to the frozen 1.0 surface)
|
|
155
214
|
|
|
156
215
|
---
|
|
157
216
|
|
|
@@ -164,6 +223,9 @@ npm run typecheck
|
|
|
164
223
|
npm run test
|
|
165
224
|
```
|
|
166
225
|
|
|
226
|
+
The VS Code extension (`vscode-extension/`) is a separate, independently-installed package — see [vscode-extension/README.md](./vscode-extension/README.md).
|
|
227
|
+
|
|
167
228
|
## License
|
|
168
229
|
|
|
169
230
|
MIT
|
|
231
|
+
|
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' | 'version';
|
|
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>;
|