mcp-recon 0.2.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/LICENSE +19 -0
- package/README.md +271 -0
- package/dist/bin/recon.d.ts +18 -0
- package/dist/bin/recon.d.ts.map +1 -0
- package/dist/bin/recon.js +361 -0
- package/dist/bin/recon.js.map +1 -0
- package/dist/caveats/index.d.ts +46 -0
- package/dist/caveats/index.d.ts.map +1 -0
- package/dist/caveats/index.js +186 -0
- package/dist/caveats/index.js.map +1 -0
- package/dist/caveats/render.d.ts +25 -0
- package/dist/caveats/render.d.ts.map +1 -0
- package/dist/caveats/render.js +100 -0
- package/dist/caveats/render.js.map +1 -0
- package/dist/caveats/types.d.ts +94 -0
- package/dist/caveats/types.d.ts.map +1 -0
- package/dist/caveats/types.js +17 -0
- package/dist/caveats/types.js.map +1 -0
- package/dist/classify/caveat.d.ts +29 -0
- package/dist/classify/caveat.d.ts.map +1 -0
- package/dist/classify/caveat.js +103 -0
- package/dist/classify/caveat.js.map +1 -0
- package/dist/classify/index.d.ts +21 -0
- package/dist/classify/index.d.ts.map +1 -0
- package/dist/classify/index.js +186 -0
- package/dist/classify/index.js.map +1 -0
- package/dist/classify/rules.d.ts +62 -0
- package/dist/classify/rules.d.ts.map +1 -0
- package/dist/classify/rules.js +219 -0
- package/dist/classify/rules.js.map +1 -0
- package/dist/classify/types.d.ts +45 -0
- package/dist/classify/types.d.ts.map +1 -0
- package/dist/classify/types.js +9 -0
- package/dist/classify/types.js.map +1 -0
- package/dist/enumerate.d.ts +79 -0
- package/dist/enumerate.d.ts.map +1 -0
- package/dist/enumerate.js +62 -0
- package/dist/enumerate.js.map +1 -0
- package/dist/fuzz/axes/boundary.d.ts +17 -0
- package/dist/fuzz/axes/boundary.d.ts.map +1 -0
- package/dist/fuzz/axes/boundary.js +143 -0
- package/dist/fuzz/axes/boundary.js.map +1 -0
- package/dist/fuzz/axes/encoding.d.ts +17 -0
- package/dist/fuzz/axes/encoding.d.ts.map +1 -0
- package/dist/fuzz/axes/encoding.js +59 -0
- package/dist/fuzz/axes/encoding.js.map +1 -0
- package/dist/fuzz/axes/path-traversal.d.ts +17 -0
- package/dist/fuzz/axes/path-traversal.d.ts.map +1 -0
- package/dist/fuzz/axes/path-traversal.js +56 -0
- package/dist/fuzz/axes/path-traversal.js.map +1 -0
- package/dist/fuzz/axes/schema-violation.d.ts +18 -0
- package/dist/fuzz/axes/schema-violation.d.ts.map +1 -0
- package/dist/fuzz/axes/schema-violation.js +74 -0
- package/dist/fuzz/axes/schema-violation.js.map +1 -0
- package/dist/fuzz/axes/type-confusion.d.ts +17 -0
- package/dist/fuzz/axes/type-confusion.d.ts.map +1 -0
- package/dist/fuzz/axes/type-confusion.js +67 -0
- package/dist/fuzz/axes/type-confusion.js.map +1 -0
- package/dist/fuzz/axes/url-hostility.d.ts +17 -0
- package/dist/fuzz/axes/url-hostility.d.ts.map +1 -0
- package/dist/fuzz/axes/url-hostility.js +61 -0
- package/dist/fuzz/axes/url-hostility.js.map +1 -0
- package/dist/fuzz/index.d.ts +41 -0
- package/dist/fuzz/index.d.ts.map +1 -0
- package/dist/fuzz/index.js +147 -0
- package/dist/fuzz/index.js.map +1 -0
- package/dist/fuzz/prng.d.ts +26 -0
- package/dist/fuzz/prng.d.ts.map +1 -0
- package/dist/fuzz/prng.js +52 -0
- package/dist/fuzz/prng.js.map +1 -0
- package/dist/fuzz/schema.d.ts +46 -0
- package/dist/fuzz/schema.d.ts.map +1 -0
- package/dist/fuzz/schema.js +84 -0
- package/dist/fuzz/schema.js.map +1 -0
- package/dist/fuzz/types.d.ts +53 -0
- package/dist/fuzz/types.d.ts.map +1 -0
- package/dist/fuzz/types.js +11 -0
- package/dist/fuzz/types.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/report/index.d.ts +25 -0
- package/dist/report/index.d.ts.map +1 -0
- package/dist/report/index.js +133 -0
- package/dist/report/index.js.map +1 -0
- package/dist/scan/index.d.ts +52 -0
- package/dist/scan/index.d.ts.map +1 -0
- package/dist/scan/index.js +81 -0
- package/dist/scan/index.js.map +1 -0
- package/dist/transport.d.ts +43 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +74 -0
- package/dist/transport.js.map +1 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
|
16
|
+
|
|
17
|
+
Copyright 2026 Euan McRosson
|
|
18
|
+
|
|
19
|
+
Full license text: https://www.apache.org/licenses/LICENSE-2.0.txt
|
package/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# mcp-recon
|
|
2
|
+
|
|
3
|
+
[](https://github.com/euanmcrosson-dotcom/mcp-recon/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](docs/SPEC.md)
|
|
6
|
+
[](#tests)
|
|
7
|
+
[](https://github.com/euanmcrosson-dotcom/capnagent)
|
|
8
|
+
|
|
9
|
+
> **Reverse-engineer any MCP server's tool surface in 30 seconds.**
|
|
10
|
+
> Connects to an MCP server (stdio or HTTP), enumerates its tools,
|
|
11
|
+
> runs a schema-aware adversarial fuzzer, classifies the authority
|
|
12
|
+
> each tool exposes against OWASP LLM Top 10 and MITRE ATLAS, and
|
|
13
|
+
> emits a structured threat profile — JSON for machines, Markdown
|
|
14
|
+
> for humans.
|
|
15
|
+
|
|
16
|
+
The thesis: every team adopting MCP right now is asking *"what
|
|
17
|
+
does this server actually do?"* and there's no tooling for it. The
|
|
18
|
+
agentic ecosystem grew faster than its security tooling. mcp-recon
|
|
19
|
+
is the recon side of that gap. [capnagent](https://github.com/euanmcrosson-dotcom/capnagent)
|
|
20
|
+
is the defensive side: take a recon report, derive a tight
|
|
21
|
+
capability caveat, deny everything outside it.
|
|
22
|
+
|
|
23
|
+
> **Status:** v0.1.2 shipped 2026-04-30. Public dataset of every
|
|
24
|
+
> stdio TypeScript MCP server in Anthropic's `@modelcontextprotocol/*`
|
|
25
|
+
> namespace audited. See [`docs/WRITEUP.md`](docs/WRITEUP.md) for the
|
|
26
|
+
> headline findings (DoS surface on `everything`,
|
|
27
|
+
> missing-bounds finding on `filesystem` example wrapper, full
|
|
28
|
+
> server-maturity ranking).
|
|
29
|
+
|
|
30
|
+
## Contents
|
|
31
|
+
|
|
32
|
+
- [At a glance](#at-a-glance)
|
|
33
|
+
- [What you get](#what-you-get)
|
|
34
|
+
- [Command cheatsheet](#command-cheatsheet)
|
|
35
|
+
- [Sample output](#sample-output)
|
|
36
|
+
- [Recon → capnagent in one pipe](#recon--capnagent-in-one-pipe)
|
|
37
|
+
- [Why this exists](#why-this-exists)
|
|
38
|
+
- [Installation](#installation)
|
|
39
|
+
- [How it compares](#how-it-compares)
|
|
40
|
+
- [What this is NOT](#what-this-is-not)
|
|
41
|
+
- [Tests](#tests)
|
|
42
|
+
- [Companion project — capnagent](#companion-project--capnagent)
|
|
43
|
+
- [License](#license)
|
|
44
|
+
|
|
45
|
+
## At a glance
|
|
46
|
+
|
|
47
|
+
| Coverage | Surface | Performance |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| **4 / 4** Anthropic reference servers scanned | **5** commands · **4** schema-tagged artefacts | scan budget=200 in <60s on 14-tool server |
|
|
50
|
+
| **37 tools classified** across the public dataset | enumerate · fuzz · classify · report · scan | deterministic (seeded PRNG, default `0xC0FFEE`) |
|
|
51
|
+
| **1374 fuzz calls** across the dataset (1 confirmed DoS finding) | rules-based, not LLM-mediated | <256MB memory on 100-tool server |
|
|
52
|
+
|
|
53
|
+
Maps tools to **OWASP LLM01 / LLM06 / LLM08** and **MITRE ATLAS** categories. Every output ships with a copy-pasteable [capnagent](https://github.com/euanmcrosson-dotcom/capnagent) caveat per tool. Reproducibility contract in
|
|
54
|
+
[capnagent's `docs/EVALUATION.md`](https://github.com/euanmcrosson-dotcom/capnagent/blob/master/docs/EVALUATION.md).
|
|
55
|
+
|
|
56
|
+
## What you get
|
|
57
|
+
|
|
58
|
+
Run `mcp-recon scan` against any MCP server (stdio or HTTP) and get a
|
|
59
|
+
folder of evidence: a tool inventory, a fuzz transcript, a
|
|
60
|
+
classification, and a Markdown threat profile that a security reviewer
|
|
61
|
+
or developer-on-call can actually read. The JSON files are the
|
|
62
|
+
machine-parseable evidence the writeup links to. Run against any of
|
|
63
|
+
the 4 servers in the public dataset and your output matches
|
|
64
|
+
`examples/public-servers/server-<name>/` byte-for-byte.
|
|
65
|
+
|
|
66
|
+
## Command cheatsheet
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
mcp-recon enumerate <server-spec> # → inventory.json
|
|
70
|
+
mcp-recon fuzz <server-spec> [--budget=N] [--seed=N] # → fuzz.json
|
|
71
|
+
mcp-recon classify <inventory.json> [--fuzz=<fuzz.json>] # → classification.json
|
|
72
|
+
mcp-recon report <inventory.json> <classification.json> [--fuzz=<fuzz.json>] # → report.md
|
|
73
|
+
mcp-recon scan <server-spec> --out=<dir> [--budget=N] [--seed=N] # → 4 artefacts
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Server-spec forms: `stdio:<command> [args...]` (spawn process, talk
|
|
77
|
+
over stdio) or `http://host:port` (HTTP transport).
|
|
78
|
+
|
|
79
|
+
## Sample output
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
$ mcp-recon scan "stdio:npx -y @modelcontextprotocol/server-filesystem /tmp" \
|
|
83
|
+
--out=./reports/filesystem --budget=200
|
|
84
|
+
|
|
85
|
+
mcp-recon: 14 tools, 4 confused-deputy candidates
|
|
86
|
+
mcp-recon: fuzz — ok=4 protocol_error=719 runtime_error=0
|
|
87
|
+
mcp-recon: wrote 4 artefacts to ./reports/filesystem/
|
|
88
|
+
|
|
89
|
+
$ ls ./reports/filesystem/
|
|
90
|
+
inventory.json fuzz.json classification.json report.md
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A snippet from the resulting `classification.json` — every tool gets
|
|
94
|
+
a class, an authority level, a confused-deputy verdict, and a
|
|
95
|
+
copy-pasteable capnagent caveat:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"tool": "edit_file",
|
|
100
|
+
"data_class": "filesystem",
|
|
101
|
+
"authority_level": "write",
|
|
102
|
+
"confused_deputy_candidate": true,
|
|
103
|
+
"confidence": 0.91,
|
|
104
|
+
"rationale": "name match \"\\b(write[_-]?file|edit[_-]?file|create[_-]?directory|move[_-]?file)\\b\" → filesystem/write (0.70); description match → filesystem/read (0.50); schema: arg \"path\" is path-shaped → filesystem (0.40); user-controllable string arg + non-read authority → confused-deputy candidate",
|
|
105
|
+
"recommended_caveat": "tool == \"edit_file\" AND caller == \"<your-caller-id>\" AND arg.path starts_with \"<your-sandbox-prefix>/\" AND now <= @<your-cap-expiry> // WRITE filesystem"
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The full headline findings — including the `everything` server's DoS
|
|
110
|
+
surface and the `filesystem` wrapper's missing-bounds — are in
|
|
111
|
+
[`docs/WRITEUP.md`](docs/WRITEUP.md).
|
|
112
|
+
|
|
113
|
+
## Recon → capnagent in one pipe
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
┌──────────────┐ inventory.json ┌──────────────┐
|
|
117
|
+
│ │ fuzz.json │ │
|
|
118
|
+
│ MCP server │ ──▶ classification ──▶│ capnagent │ ──▶ deny anything
|
|
119
|
+
│ │ .json │ issuer │ outside scope
|
|
120
|
+
│ │ report.md │ │
|
|
121
|
+
└──────────────┘ └──────────────┘
|
|
122
|
+
▲ │
|
|
123
|
+
│ ▼
|
|
124
|
+
└────────── scoped caller ◀────── signed capability
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
mcp-recon documents the tool surface; capnagent enforces the bound.
|
|
128
|
+
Each project stands alone. Together they're a single security
|
|
129
|
+
posture for any MCP-shaped agent. Run mcp-recon first, paste the
|
|
130
|
+
suggested caveats into your capnagent issuer, ship.
|
|
131
|
+
|
|
132
|
+
### From recon to a capnagent issuer in one pipe
|
|
133
|
+
|
|
134
|
+
`classification.json` ships a copy-pasteable caveat per tool, but
|
|
135
|
+
manual paste is its own foot-gun. The `caveats` command produces a
|
|
136
|
+
machine-readable issuance plan ready to feed straight into a capnagent
|
|
137
|
+
issuer:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
$ mcp-recon caveats ./reports/filesystem/classification.json \
|
|
141
|
+
--caller=agent:planner \
|
|
142
|
+
--sandbox-prefix=/var/agent-sandbox/tenant-42 \
|
|
143
|
+
--expiry=2026-12-31T23:59:59Z \
|
|
144
|
+
> ./reports/filesystem/caveats.json
|
|
145
|
+
|
|
146
|
+
mcp-recon: 14 plans (14 ready, 0 flagged) — schema=mcp-recon/v0.1/caveats
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The output document (schema `mcp-recon/v0.1/caveats`) is one entry per
|
|
150
|
+
tool, with `caveats: string[]` already split into individual capnagent
|
|
151
|
+
DSL predicates and operator bindings substituted. Plans get flagged
|
|
152
|
+
with a structured reason set (`classification_unknown`, `low_confidence`,
|
|
153
|
+
`cdc_without_arg_constraint`, `unsubstituted_placeholder`) so the
|
|
154
|
+
review surface is machine-checkable.
|
|
155
|
+
|
|
156
|
+
Run with no bindings to get a "review pass" — every plan is flagged,
|
|
157
|
+
but you can see exactly which placeholders need binding before
|
|
158
|
+
committing values. Per-tool overrides (`per_tool_overrides` in the
|
|
159
|
+
library API) let you tighten confused-deputy candidates the
|
|
160
|
+
classifier didn't constrain.
|
|
161
|
+
|
|
162
|
+
## Why this exists
|
|
163
|
+
|
|
164
|
+
**For the developer adopting MCP.** Before you wire a third-party
|
|
165
|
+
MCP server into your agent, run mcp-recon against it. You get an
|
|
166
|
+
honest threat profile in 30 seconds — what does this thing
|
|
167
|
+
*actually* let an agent do, and what's the smallest cap that
|
|
168
|
+
preserves utility?
|
|
169
|
+
|
|
170
|
+
**For the security team auditing an agent stack.** mcp-recon turns
|
|
171
|
+
"we depend on N MCP servers" into "here's the consolidated tool
|
|
172
|
+
surface, here's what each one is classified as, here's where the
|
|
173
|
+
confused-deputy candidates are." A printable artifact you can
|
|
174
|
+
review.
|
|
175
|
+
|
|
176
|
+
**For the AI-security researcher.** mcp-recon's reports are the
|
|
177
|
+
input to round-N writeups in the
|
|
178
|
+
[capnagent purple-team corpus](https://github.com/euanmcrosson-dotcom/capnagent/tree/master/docs/purple-team).
|
|
179
|
+
Recon → capability gap → attack PoC → fix → CLOSED.
|
|
180
|
+
|
|
181
|
+
## Installation
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
# From source (the recommended path today; npm package is post-v0.2)
|
|
185
|
+
git clone https://github.com/euanmcrosson-dotcom/mcp-recon
|
|
186
|
+
cd mcp-recon
|
|
187
|
+
npm install
|
|
188
|
+
npm run -w @mcp-recon/cli build
|
|
189
|
+
|
|
190
|
+
# Run the CLI directly via tsx (no build step needed for development)
|
|
191
|
+
npx tsx packages/mcp-recon-cli/src/bin/recon.ts scan \
|
|
192
|
+
"stdio:npx -y @modelcontextprotocol/server-filesystem $HOME/sandbox" \
|
|
193
|
+
--out=./reports/filesystem --budget=200
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
> **Windows / Git Bash users:** prefix path-shaped flags with `MSYS_NO_PATHCONV=1` to prevent leading-slash path mangling. Example: `MSYS_NO_PATHCONV=1 mcp-recon caveats classification.json --sandbox-prefix=/var/sandbox --expiry=2026-12-31T23:59:59Z`
|
|
197
|
+
|
|
198
|
+
## Documentation
|
|
199
|
+
|
|
200
|
+
- [`docs/SPEC.md`](docs/SPEC.md) — v0.1 surface, server-spec syntax, output schemas
|
|
201
|
+
- [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md) — classifier rules, fuzz axes, signals, falsifiability
|
|
202
|
+
- [`docs/WRITEUP.md`](docs/WRITEUP.md) — public-dataset findings + headline observations
|
|
203
|
+
- [`schemas/`](schemas/) — formal JSON Schema files for the four wire formats
|
|
204
|
+
- [`findings/`](findings/) — corpus of documented findings (F001–F006)
|
|
205
|
+
- [`SECURITY.md`](SECURITY.md) — vulnerability reporting policy
|
|
206
|
+
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to add classifier rules, fuzz axes, dataset entries
|
|
207
|
+
|
|
208
|
+
## How it compares
|
|
209
|
+
|
|
210
|
+
| | mcp-recon | [NVIDIA garak](https://github.com/NVIDIA/garak) | Burp / ZAP | manual review |
|
|
211
|
+
|---|---|---|---|---|
|
|
212
|
+
| **Scope** | MCP server tool surfaces | model-behavior testing | HTTP fuzzing | everything |
|
|
213
|
+
| **Output** | structured JSON + Markdown | reports | proxy logs | human prose |
|
|
214
|
+
| **Determinism** | yes (seeded PRNG) | partial | no | no |
|
|
215
|
+
| **LLM in the loop** | no (rules-based) | yes | no | yes |
|
|
216
|
+
| **OWASP LLM / MITRE ATLAS mapping** | yes (per-tool) | partial | no | author-dependent |
|
|
217
|
+
| **Companion enforcement** | [capnagent](https://github.com/euanmcrosson-dotcom/capnagent) | none | none | none |
|
|
218
|
+
|
|
219
|
+
mcp-recon is **not** a replacement for any of those — it's the
|
|
220
|
+
piece nobody else is building: a deterministic, schema-aware
|
|
221
|
+
characterization of an MCP server's tool surface, in a format
|
|
222
|
+
that wires straight into a capability-bounded enforcement layer.
|
|
223
|
+
|
|
224
|
+
## What this is NOT
|
|
225
|
+
|
|
226
|
+
- **Not a replacement for capnagent.** mcp-recon documents what's
|
|
227
|
+
there; capnagent enforces what's allowed. You want both.
|
|
228
|
+
- **Not a vulnerability scanner for the model itself.** Use
|
|
229
|
+
[NVIDIA garak](https://github.com/NVIDIA/garak) for that. We
|
|
230
|
+
test the *tool surface*, not model behavior.
|
|
231
|
+
- **Not an exploitation framework.** We send adversarial schemas
|
|
232
|
+
to characterize handling, not actual exploits.
|
|
233
|
+
- **Not a proxy / MITM tool.** Out of scope. See
|
|
234
|
+
[`docs/SPEC.md`](docs/SPEC.md) §"What v0.1 does NOT do."
|
|
235
|
+
|
|
236
|
+
## Tests
|
|
237
|
+
|
|
238
|
+
The workspace has **68 unit + property-based tests** passing today
|
|
239
|
+
(`npm test`), covering schema parsing, the seeded PRNG, fuzz
|
|
240
|
+
generators along all six adversarial axes, the classification
|
|
241
|
+
rules, the Markdown report renderer, and end-to-end `scan` flow.
|
|
242
|
+
Two additional integration test files (`enumerate.integration.test.ts`,
|
|
243
|
+
`fuzz.integration.test.ts`) exercise live transport against a
|
|
244
|
+
locally-spawned MCP server when the dev environment provides one.
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
npm test # all packages, vitest
|
|
248
|
+
npm run typecheck # tsc --noEmit, strict mode
|
|
249
|
+
npm run lint # biome check
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Companion project — capnagent
|
|
253
|
+
|
|
254
|
+
mcp-recon is the offensive complement to
|
|
255
|
+
[capnagent](https://github.com/euanmcrosson-dotcom/capnagent),
|
|
256
|
+
which provides capability-bounded authorization for AI agent tool
|
|
257
|
+
calls. Together they implement the standard
|
|
258
|
+
*recon-then-bound* security workflow:
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
[ mcp-recon ] → threat profile → [ capnagent ]
|
|
262
|
+
"what is "what should "deny anything
|
|
263
|
+
here?" we allow?" outside that"
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Each project stands alone. Together they're a single security
|
|
267
|
+
posture for any MCP-shaped agent.
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
[Apache-2.0](LICENSE).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `mcp-recon` CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* v0.1 surface (see docs/SPEC.md §"What v0.1 does"):
|
|
6
|
+
*
|
|
7
|
+
* mcp-recon enumerate <server-spec> [implemented in scaffold]
|
|
8
|
+
* mcp-recon fuzz <server-spec> [stub — v0.1 week 2]
|
|
9
|
+
* mcp-recon classify <inventory.json> [stub — v0.1 week 3]
|
|
10
|
+
* mcp-recon report <i.json> <f.json> <c.json> [stub — v0.1 week 3]
|
|
11
|
+
* mcp-recon scan <server-spec> [stub — v0.1 week 4]
|
|
12
|
+
*
|
|
13
|
+
* Output is JSON to stdout for machine-parseable commands; logs to
|
|
14
|
+
* stderr so a piped invocation (`mcp-recon enumerate ... | jq ...`)
|
|
15
|
+
* works without contamination.
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=recon.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recon.d.ts","sourceRoot":"","sources":["../../src/bin/recon.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;GAcG"}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `mcp-recon` CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* v0.1 surface (see docs/SPEC.md §"What v0.1 does"):
|
|
6
|
+
*
|
|
7
|
+
* mcp-recon enumerate <server-spec> [implemented in scaffold]
|
|
8
|
+
* mcp-recon fuzz <server-spec> [stub — v0.1 week 2]
|
|
9
|
+
* mcp-recon classify <inventory.json> [stub — v0.1 week 3]
|
|
10
|
+
* mcp-recon report <i.json> <f.json> <c.json> [stub — v0.1 week 3]
|
|
11
|
+
* mcp-recon scan <server-spec> [stub — v0.1 week 4]
|
|
12
|
+
*
|
|
13
|
+
* Output is JSON to stdout for machine-parseable commands; logs to
|
|
14
|
+
* stderr so a piped invocation (`mcp-recon enumerate ... | jq ...`)
|
|
15
|
+
* works without contamination.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import { CAVEATS_SCHEMA, CLASSIFICATION_SCHEMA, classify, closeClient, enumerate, fuzz, openClient, parseServerSpec, planCaveats, renderCaveatsMarkdown, renderMarkdown, scan, } from "../index.js";
|
|
19
|
+
function usage() {
|
|
20
|
+
process.stderr.write([
|
|
21
|
+
"mcp-recon — reverse-engineer MCP server tool surfaces",
|
|
22
|
+
"",
|
|
23
|
+
"Usage:",
|
|
24
|
+
" mcp-recon enumerate <server-spec>",
|
|
25
|
+
" mcp-recon fuzz <server-spec> [--budget=N] [--seed=N]",
|
|
26
|
+
" mcp-recon classify <inventory.json> [--fuzz=<fuzz.json>]",
|
|
27
|
+
" mcp-recon report <inventory.json> <classification.json> [--fuzz=<fuzz.json>]",
|
|
28
|
+
" mcp-recon caveats <classification.json> [--caller=ID] [--sandbox-prefix=PATH] [--expiry=ISO] [--markdown]",
|
|
29
|
+
" mcp-recon scan <server-spec> --out=<dir> [--budget=N] [--seed=N]",
|
|
30
|
+
" [--caller=ID] [--sandbox-prefix=PATH] [--expiry=ISO]",
|
|
31
|
+
"",
|
|
32
|
+
"Server-spec forms:",
|
|
33
|
+
" stdio:<command> [args...] — spawn process, talk over stdio",
|
|
34
|
+
" http://host:port — HTTP transport (v0.1 week 2)",
|
|
35
|
+
"",
|
|
36
|
+
"Examples:",
|
|
37
|
+
" mcp-recon enumerate stdio:npx -y @modelcontextprotocol/server-filesystem /tmp",
|
|
38
|
+
" mcp-recon fuzz stdio:npx -y @modelcontextprotocol/server-filesystem /tmp --budget=20",
|
|
39
|
+
" mcp-recon classify inv.json --fuzz=fz.json > class.json",
|
|
40
|
+
" mcp-recon report inv.json class.json --fuzz=fz.json > report.md",
|
|
41
|
+
" mcp-recon caveats class.json --caller=agent:planner --sandbox-prefix=/srv/sb --expiry=2026-12-31T23:59:59Z > caveats.json",
|
|
42
|
+
" mcp-recon caveats class.json --caller=agent:planner --sandbox-prefix=/srv/sb --expiry=2026-12-31T23:59:59Z --markdown > caveats.md",
|
|
43
|
+
"",
|
|
44
|
+
].join("\n"));
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
async function main() {
|
|
48
|
+
const argv = process.argv.slice(2);
|
|
49
|
+
const cmd = argv[0];
|
|
50
|
+
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
51
|
+
usage();
|
|
52
|
+
}
|
|
53
|
+
switch (cmd) {
|
|
54
|
+
case "enumerate":
|
|
55
|
+
return await runEnumerate(argv.slice(1));
|
|
56
|
+
case "fuzz":
|
|
57
|
+
return await runFuzz(argv.slice(1));
|
|
58
|
+
case "classify":
|
|
59
|
+
return runClassify(argv.slice(1));
|
|
60
|
+
case "report":
|
|
61
|
+
return runReport(argv.slice(1));
|
|
62
|
+
case "caveats":
|
|
63
|
+
return runCaveats(argv.slice(1));
|
|
64
|
+
case "scan":
|
|
65
|
+
return await runScan(argv.slice(1));
|
|
66
|
+
default:
|
|
67
|
+
process.stderr.write(`mcp-recon: unknown command '${cmd}'.\n`);
|
|
68
|
+
usage();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function runEnumerate(args) {
|
|
72
|
+
const spec = args[0];
|
|
73
|
+
if (!spec) {
|
|
74
|
+
process.stderr.write("mcp-recon enumerate: missing <server-spec>\n");
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
const parsed = parseServerSpec(spec);
|
|
78
|
+
process.stderr.write(`mcp-recon: connecting to ${spec}...\n`);
|
|
79
|
+
const client = await openClient(parsed);
|
|
80
|
+
try {
|
|
81
|
+
const inventory = await enumerate(client);
|
|
82
|
+
process.stderr.write(`mcp-recon: enumerated ${inventory.tools.length} tools from ${inventory.server.name ?? "unknown server"}\n`);
|
|
83
|
+
process.stdout.write(`${JSON.stringify(inventory, null, 2)}\n`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await closeClient(client);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function runFuzz(args) {
|
|
91
|
+
// Pull --budget=N and --seed=N flags out; the first positional is the spec.
|
|
92
|
+
let budget;
|
|
93
|
+
let seed;
|
|
94
|
+
let spec;
|
|
95
|
+
for (const arg of args) {
|
|
96
|
+
if (arg.startsWith("--budget=")) {
|
|
97
|
+
const n = Number.parseInt(arg.slice("--budget=".length), 10);
|
|
98
|
+
if (Number.isNaN(n) || n <= 0) {
|
|
99
|
+
process.stderr.write(`mcp-recon fuzz: invalid --budget value\n`);
|
|
100
|
+
return 2;
|
|
101
|
+
}
|
|
102
|
+
budget = n;
|
|
103
|
+
}
|
|
104
|
+
else if (arg.startsWith("--seed=")) {
|
|
105
|
+
const n = Number.parseInt(arg.slice("--seed=".length), 10);
|
|
106
|
+
if (Number.isNaN(n)) {
|
|
107
|
+
process.stderr.write(`mcp-recon fuzz: invalid --seed value\n`);
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
seed = n;
|
|
111
|
+
}
|
|
112
|
+
else if (!spec) {
|
|
113
|
+
spec = arg;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
process.stderr.write(`mcp-recon fuzz: unexpected argument ${arg}\n`);
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!spec) {
|
|
121
|
+
process.stderr.write("mcp-recon fuzz: missing <server-spec>\n");
|
|
122
|
+
return 2;
|
|
123
|
+
}
|
|
124
|
+
const parsed = parseServerSpec(spec);
|
|
125
|
+
process.stderr.write(`mcp-recon: connecting to ${spec}...\n`);
|
|
126
|
+
const client = await openClient(parsed);
|
|
127
|
+
try {
|
|
128
|
+
const inventory = await enumerate(client);
|
|
129
|
+
process.stderr.write(`mcp-recon: fuzzing ${inventory.tools.length} tools (budget=${budget ?? 200}, seed=${seed ?? "default"})...\n`);
|
|
130
|
+
const opts = {};
|
|
131
|
+
if (budget !== undefined)
|
|
132
|
+
opts.budget = budget;
|
|
133
|
+
if (seed !== undefined)
|
|
134
|
+
opts.seed = seed;
|
|
135
|
+
const results = await fuzz(client, inventory, opts);
|
|
136
|
+
const totalCalls = results.calls.length;
|
|
137
|
+
const totalOk = results.summary.reduce((acc, s) => acc + s.ok, 0);
|
|
138
|
+
const totalProto = results.summary.reduce((acc, s) => acc + s.protocol_error, 0);
|
|
139
|
+
const totalRuntime = results.summary.reduce((acc, s) => acc + s.runtime_error, 0);
|
|
140
|
+
process.stderr.write(`mcp-recon: ${totalCalls} calls — ok=${totalOk} protocol_error=${totalProto} runtime_error=${totalRuntime}\n`);
|
|
141
|
+
process.stdout.write(`${JSON.stringify(results, null, 2)}\n`);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await closeClient(client);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function runClassify(args) {
|
|
149
|
+
let inventoryPath;
|
|
150
|
+
let fuzzPath;
|
|
151
|
+
for (const arg of args) {
|
|
152
|
+
if (arg.startsWith("--fuzz=")) {
|
|
153
|
+
fuzzPath = arg.slice("--fuzz=".length);
|
|
154
|
+
}
|
|
155
|
+
else if (!inventoryPath) {
|
|
156
|
+
inventoryPath = arg;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
process.stderr.write(`mcp-recon classify: unexpected argument ${arg}\n`);
|
|
160
|
+
return 2;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!inventoryPath) {
|
|
164
|
+
process.stderr.write("mcp-recon classify: missing <inventory.json>\n");
|
|
165
|
+
return 2;
|
|
166
|
+
}
|
|
167
|
+
const inventory = readJson(inventoryPath);
|
|
168
|
+
const fuzzData = fuzzPath ? readJson(fuzzPath) : undefined;
|
|
169
|
+
const result = classify(inventory, fuzzData);
|
|
170
|
+
process.stderr.write(`mcp-recon: classified ${result.classifications.length} tools (fuzz_informed=${result.fuzz_informed})\n`);
|
|
171
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
function runReport(args) {
|
|
175
|
+
let inventoryPath;
|
|
176
|
+
let classificationPath;
|
|
177
|
+
let fuzzPath;
|
|
178
|
+
for (const arg of args) {
|
|
179
|
+
if (arg.startsWith("--fuzz=")) {
|
|
180
|
+
fuzzPath = arg.slice("--fuzz=".length);
|
|
181
|
+
}
|
|
182
|
+
else if (!inventoryPath) {
|
|
183
|
+
inventoryPath = arg;
|
|
184
|
+
}
|
|
185
|
+
else if (!classificationPath) {
|
|
186
|
+
classificationPath = arg;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
process.stderr.write(`mcp-recon report: unexpected argument ${arg}\n`);
|
|
190
|
+
return 2;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!inventoryPath || !classificationPath) {
|
|
194
|
+
process.stderr.write("mcp-recon report: missing arguments — needs <inventory.json> <classification.json>\n");
|
|
195
|
+
return 2;
|
|
196
|
+
}
|
|
197
|
+
const inventory = readJson(inventoryPath);
|
|
198
|
+
const classification = readJson(classificationPath);
|
|
199
|
+
if (classification.schema !== CLASSIFICATION_SCHEMA) {
|
|
200
|
+
process.stderr.write(`mcp-recon report: classification.json schema is "${classification.schema}", expected "${CLASSIFICATION_SCHEMA}"\n`);
|
|
201
|
+
return 64;
|
|
202
|
+
}
|
|
203
|
+
const fuzzData = fuzzPath ? readJson(fuzzPath) : undefined;
|
|
204
|
+
const md = renderMarkdown(fuzzData ? { inventory, classification, fuzz: fuzzData } : { inventory, classification });
|
|
205
|
+
process.stdout.write(md);
|
|
206
|
+
if (!md.endsWith("\n"))
|
|
207
|
+
process.stdout.write("\n");
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
function readJson(filePath) {
|
|
211
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
212
|
+
return JSON.parse(text);
|
|
213
|
+
}
|
|
214
|
+
function runCaveats(args) {
|
|
215
|
+
let classificationPath;
|
|
216
|
+
let markdown = false;
|
|
217
|
+
const bindings = {};
|
|
218
|
+
for (const arg of args) {
|
|
219
|
+
if (arg === "--markdown") {
|
|
220
|
+
markdown = true;
|
|
221
|
+
}
|
|
222
|
+
else if (arg.startsWith("--caller=")) {
|
|
223
|
+
bindings.caller = arg.slice("--caller=".length);
|
|
224
|
+
}
|
|
225
|
+
else if (arg.startsWith("--sandbox-prefix=")) {
|
|
226
|
+
bindings.sandbox_prefix = arg.slice("--sandbox-prefix=".length);
|
|
227
|
+
}
|
|
228
|
+
else if (arg.startsWith("--expiry=")) {
|
|
229
|
+
const expiry = arg.slice("--expiry=".length);
|
|
230
|
+
// Light validation: must parse as a date.
|
|
231
|
+
const parsed = new Date(expiry);
|
|
232
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
233
|
+
process.stderr.write(`mcp-recon caveats: invalid --expiry value (must be ISO-8601)\n`);
|
|
234
|
+
return 2;
|
|
235
|
+
}
|
|
236
|
+
// Normalize to canonical ISO so downstream consumers see one shape.
|
|
237
|
+
bindings.expiry = parsed.toISOString();
|
|
238
|
+
}
|
|
239
|
+
else if (!classificationPath) {
|
|
240
|
+
classificationPath = arg;
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
process.stderr.write(`mcp-recon caveats: unexpected argument ${arg}\n`);
|
|
244
|
+
return 2;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!classificationPath) {
|
|
248
|
+
process.stderr.write("mcp-recon caveats: missing <classification.json>\n");
|
|
249
|
+
return 2;
|
|
250
|
+
}
|
|
251
|
+
const classification = readJson(classificationPath);
|
|
252
|
+
if (classification.schema !== CLASSIFICATION_SCHEMA) {
|
|
253
|
+
process.stderr.write(`mcp-recon caveats: classification.json schema is "${classification.schema}", expected "${CLASSIFICATION_SCHEMA}"\n`);
|
|
254
|
+
return 64;
|
|
255
|
+
}
|
|
256
|
+
const result = planCaveats(classification, bindings);
|
|
257
|
+
process.stderr.write(`mcp-recon: ${result.summary.total} plans (${result.summary.ready} ready, ${result.summary.flagged} flagged) — schema=${CAVEATS_SCHEMA}\n`);
|
|
258
|
+
if (markdown) {
|
|
259
|
+
const md = renderCaveatsMarkdown(result);
|
|
260
|
+
process.stdout.write(md);
|
|
261
|
+
if (!md.endsWith("\n"))
|
|
262
|
+
process.stdout.write("\n");
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
266
|
+
}
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
async function runScan(args) {
|
|
270
|
+
let spec;
|
|
271
|
+
let outDir;
|
|
272
|
+
let budget;
|
|
273
|
+
let seed;
|
|
274
|
+
const bindings = {};
|
|
275
|
+
for (const arg of args) {
|
|
276
|
+
if (arg.startsWith("--out=")) {
|
|
277
|
+
outDir = arg.slice("--out=".length);
|
|
278
|
+
}
|
|
279
|
+
else if (arg.startsWith("--budget=")) {
|
|
280
|
+
const n = Number.parseInt(arg.slice("--budget=".length), 10);
|
|
281
|
+
if (Number.isNaN(n) || n <= 0) {
|
|
282
|
+
process.stderr.write("mcp-recon scan: invalid --budget value\n");
|
|
283
|
+
return 2;
|
|
284
|
+
}
|
|
285
|
+
budget = n;
|
|
286
|
+
}
|
|
287
|
+
else if (arg.startsWith("--seed=")) {
|
|
288
|
+
const n = Number.parseInt(arg.slice("--seed=".length), 10);
|
|
289
|
+
if (Number.isNaN(n)) {
|
|
290
|
+
process.stderr.write("mcp-recon scan: invalid --seed value\n");
|
|
291
|
+
return 2;
|
|
292
|
+
}
|
|
293
|
+
seed = n;
|
|
294
|
+
}
|
|
295
|
+
else if (arg.startsWith("--caller=")) {
|
|
296
|
+
bindings.caller = arg.slice("--caller=".length);
|
|
297
|
+
}
|
|
298
|
+
else if (arg.startsWith("--sandbox-prefix=")) {
|
|
299
|
+
bindings.sandbox_prefix = arg.slice("--sandbox-prefix=".length);
|
|
300
|
+
}
|
|
301
|
+
else if (arg.startsWith("--expiry=")) {
|
|
302
|
+
const expiry = arg.slice("--expiry=".length);
|
|
303
|
+
const parsed = new Date(expiry);
|
|
304
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
305
|
+
process.stderr.write("mcp-recon scan: invalid --expiry value (must be ISO-8601)\n");
|
|
306
|
+
return 2;
|
|
307
|
+
}
|
|
308
|
+
bindings.expiry = parsed.toISOString();
|
|
309
|
+
}
|
|
310
|
+
else if (!spec) {
|
|
311
|
+
spec = arg;
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
process.stderr.write(`mcp-recon scan: unexpected argument ${arg}\n`);
|
|
315
|
+
return 2;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (!spec) {
|
|
319
|
+
process.stderr.write("mcp-recon scan: missing <server-spec>\n");
|
|
320
|
+
return 2;
|
|
321
|
+
}
|
|
322
|
+
if (!outDir) {
|
|
323
|
+
process.stderr.write("mcp-recon scan: --out=<dir> is required\n");
|
|
324
|
+
return 2;
|
|
325
|
+
}
|
|
326
|
+
const hasBindings = bindings.caller !== undefined ||
|
|
327
|
+
bindings.sandbox_prefix !== undefined ||
|
|
328
|
+
bindings.expiry !== undefined;
|
|
329
|
+
const parsed = parseServerSpec(spec);
|
|
330
|
+
process.stderr.write(`mcp-recon: connecting to ${spec}...\n`);
|
|
331
|
+
const client = await openClient(parsed);
|
|
332
|
+
try {
|
|
333
|
+
const opts = {
|
|
334
|
+
outDir,
|
|
335
|
+
...(budget !== undefined ? { budget } : {}),
|
|
336
|
+
...(seed !== undefined ? { seed } : {}),
|
|
337
|
+
...(hasBindings ? { bindings } : {}),
|
|
338
|
+
};
|
|
339
|
+
process.stderr.write(`mcp-recon: running enumerate → fuzz → classify → report...\n`);
|
|
340
|
+
const result = await scan(client, opts);
|
|
341
|
+
const totalOk = result.fuzz.summary.reduce((acc, s) => acc + s.ok, 0);
|
|
342
|
+
const totalProto = result.fuzz.summary.reduce((acc, s) => acc + s.protocol_error, 0);
|
|
343
|
+
const totalRuntime = result.fuzz.summary.reduce((acc, s) => acc + s.runtime_error, 0);
|
|
344
|
+
const cdpCount = result.classification.classifications.filter((c) => c.confused_deputy_candidate).length;
|
|
345
|
+
process.stderr.write(`mcp-recon: ${result.inventory.tools.length} tools, ${cdpCount} confused-deputy candidates\n`);
|
|
346
|
+
process.stderr.write(`mcp-recon: fuzz — ok=${totalOk} protocol_error=${totalProto} runtime_error=${totalRuntime}\n`);
|
|
347
|
+
const artefactCount = result.caveats !== undefined ? 5 : 4;
|
|
348
|
+
process.stderr.write(`mcp-recon: wrote ${artefactCount} artefacts to ${outDir}/\n`);
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
await closeClient(client);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
main()
|
|
356
|
+
.then((code) => process.exit(code))
|
|
357
|
+
.catch((err) => {
|
|
358
|
+
process.stderr.write(`mcp-recon: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
359
|
+
process.exit(1);
|
|
360
|
+
});
|
|
361
|
+
//# sourceMappingURL=recon.js.map
|