mcpprobe-scanner 0.1.0__py3-none-any.whl
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.
- mcpprobe/__init__.py +21 -0
- mcpprobe/action.py +69 -0
- mcpprobe/authz.py +313 -0
- mcpprobe/catalogue.py +125 -0
- mcpprobe/checks/__init__.py +100 -0
- mcpprobe/checks/_util.py +102 -0
- mcpprobe/checks/active_stubs.py +212 -0
- mcpprobe/checks/auth.py +473 -0
- mcpprobe/checks/base.py +228 -0
- mcpprobe/checks/baseline.py +219 -0
- mcpprobe/checks/capability.py +171 -0
- mcpprobe/checks/metadata.py +341 -0
- mcpprobe/checks/poisoning.py +480 -0
- mcpprobe/checks/robustness.py +266 -0
- mcpprobe/checks/transport_conformance.py +429 -0
- mcpprobe/cli.py +295 -0
- mcpprobe/dnsutil.py +67 -0
- mcpprobe/governance.py +74 -0
- mcpprobe/models.py +264 -0
- mcpprobe/policy.py +140 -0
- mcpprobe/py.typed +0 -0
- mcpprobe/redact.py +101 -0
- mcpprobe/report/__init__.py +7 -0
- mcpprobe/report/json_report.py +33 -0
- mcpprobe/report/sarif.py +329 -0
- mcpprobe/report/terminal.py +172 -0
- mcpprobe/scanner.py +211 -0
- mcpprobe/transport/__init__.py +34 -0
- mcpprobe/transport/base.py +156 -0
- mcpprobe/transport/evidence.py +135 -0
- mcpprobe/transport/fingerprint.py +154 -0
- mcpprobe/transport/http.py +366 -0
- mcpprobe/transport/ratelimit.py +47 -0
- mcpprobe/transport/stdio.py +201 -0
- mcpprobe_scanner-0.1.0.dist-info/METADATA +240 -0
- mcpprobe_scanner-0.1.0.dist-info/RECORD +39 -0
- mcpprobe_scanner-0.1.0.dist-info/WHEEL +4 -0
- mcpprobe_scanner-0.1.0.dist-info/entry_points.txt +2 -0
- mcpprobe_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""stdio transport: spawn a local MCP server command and speak newline-delimited JSON-RPC.
|
|
2
|
+
|
|
3
|
+
Only use this on packages you run yourself. The scanner does not sandbox the
|
|
4
|
+
child process; see SANDBOX-01 in CHECKS.md for the planned sandboxed runner.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from mcpprobe.transport.base import RpcResponse, Transport, TransportError
|
|
19
|
+
from mcpprobe.transport.evidence import EvidenceLog
|
|
20
|
+
from mcpprobe.transport.ratelimit import RateLimiter
|
|
21
|
+
|
|
22
|
+
STDERR_TAIL_LINES = 200
|
|
23
|
+
#: Protect against a server that floods stdout with one enormous line.
|
|
24
|
+
MAX_LINE_BYTES = 10_000_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StdioTransport(Transport):
|
|
28
|
+
"""Child-process MCP server driven over stdin/stdout."""
|
|
29
|
+
|
|
30
|
+
name = "stdio"
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
command: str | Sequence[str],
|
|
35
|
+
*,
|
|
36
|
+
env: Mapping[str, str] | None = None,
|
|
37
|
+
cwd: str | None = None,
|
|
38
|
+
timeout: float = 15.0,
|
|
39
|
+
evidence: EvidenceLog | None = None,
|
|
40
|
+
limiter: RateLimiter | None = None,
|
|
41
|
+
inherit_env: bool = True,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(evidence=evidence, limiter=limiter)
|
|
44
|
+
self.argv: list[str] = list(shlex.split(command) if isinstance(command, str) else command)
|
|
45
|
+
if not self.argv:
|
|
46
|
+
raise TransportError("empty stdio command")
|
|
47
|
+
self.env = dict(env or {})
|
|
48
|
+
self.cwd = cwd
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self.inherit_env = inherit_env
|
|
51
|
+
self.proc: asyncio.subprocess.Process | None = None
|
|
52
|
+
self.stderr_tail: deque[str] = deque(maxlen=STDERR_TAIL_LINES)
|
|
53
|
+
self._pending: dict[int | str, asyncio.Future[dict[str, Any]]] = {}
|
|
54
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
55
|
+
self._stderr_task: asyncio.Task[None] | None = None
|
|
56
|
+
self.unsolicited: list[dict[str, Any]] = []
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def display(self) -> str:
|
|
60
|
+
return " ".join(shlex.quote(a) for a in self.argv)
|
|
61
|
+
|
|
62
|
+
async def start(self) -> None:
|
|
63
|
+
if self.proc is not None:
|
|
64
|
+
return
|
|
65
|
+
env = dict(os.environ) if self.inherit_env else {}
|
|
66
|
+
env.update(self.env)
|
|
67
|
+
try:
|
|
68
|
+
self.proc = await asyncio.create_subprocess_exec(
|
|
69
|
+
*self.argv,
|
|
70
|
+
stdin=asyncio.subprocess.PIPE,
|
|
71
|
+
stdout=asyncio.subprocess.PIPE,
|
|
72
|
+
stderr=asyncio.subprocess.PIPE,
|
|
73
|
+
env=env,
|
|
74
|
+
cwd=self.cwd,
|
|
75
|
+
limit=MAX_LINE_BYTES,
|
|
76
|
+
)
|
|
77
|
+
except (OSError, ValueError) as exc:
|
|
78
|
+
raise TransportError(f"cannot start {self.display}: {exc}") from exc
|
|
79
|
+
self._reader_task = asyncio.create_task(self._read_stdout())
|
|
80
|
+
self._stderr_task = asyncio.create_task(self._read_stderr())
|
|
81
|
+
|
|
82
|
+
async def _read_stdout(self) -> None:
|
|
83
|
+
assert self.proc and self.proc.stdout
|
|
84
|
+
while True:
|
|
85
|
+
try:
|
|
86
|
+
line = await self.proc.stdout.readline()
|
|
87
|
+
except (ValueError, asyncio.LimitOverrunError):
|
|
88
|
+
self.stderr_tail.append("[mcpprobe] stdout line exceeded limit; dropped")
|
|
89
|
+
continue
|
|
90
|
+
if not line:
|
|
91
|
+
break
|
|
92
|
+
text = line.decode("utf-8", errors="replace").strip()
|
|
93
|
+
if not text:
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
msg = json.loads(text)
|
|
97
|
+
except ValueError:
|
|
98
|
+
self.stderr_tail.append(f"[non-JSON stdout] {text[:200]}")
|
|
99
|
+
continue
|
|
100
|
+
if isinstance(msg, dict) and "id" in msg and ("result" in msg or "error" in msg):
|
|
101
|
+
fut = self._pending.pop(msg["id"], None)
|
|
102
|
+
if fut and not fut.done():
|
|
103
|
+
fut.set_result(msg)
|
|
104
|
+
continue
|
|
105
|
+
self.unsolicited.append(msg if isinstance(msg, dict) else {"raw": msg})
|
|
106
|
+
for fut in self._pending.values():
|
|
107
|
+
if not fut.done():
|
|
108
|
+
fut.set_exception(TransportError("server closed stdout"))
|
|
109
|
+
self._pending.clear()
|
|
110
|
+
|
|
111
|
+
async def _read_stderr(self) -> None:
|
|
112
|
+
assert self.proc and self.proc.stderr
|
|
113
|
+
while True:
|
|
114
|
+
try:
|
|
115
|
+
line = await self.proc.stderr.readline()
|
|
116
|
+
except (ValueError, asyncio.LimitOverrunError):
|
|
117
|
+
continue
|
|
118
|
+
if not line:
|
|
119
|
+
break
|
|
120
|
+
self.stderr_tail.append(line.decode("utf-8", errors="replace").rstrip())
|
|
121
|
+
|
|
122
|
+
async def send_raw(
|
|
123
|
+
self, text: str, *, label: str, want_id: int | str | None, note: str | None = None
|
|
124
|
+
) -> RpcResponse:
|
|
125
|
+
"""Write one line to the child's stdin and (optionally) await a reply."""
|
|
126
|
+
await self.start()
|
|
127
|
+
assert self.proc and self.proc.stdin
|
|
128
|
+
await self.limiter.acquire()
|
|
129
|
+
fut: asyncio.Future[dict[str, Any]] | None = None
|
|
130
|
+
if want_id is not None:
|
|
131
|
+
fut = asyncio.get_running_loop().create_future()
|
|
132
|
+
self._pending[want_id] = fut
|
|
133
|
+
started = time.monotonic()
|
|
134
|
+
error_text: str | None = None
|
|
135
|
+
message: dict[str, Any] | None = None
|
|
136
|
+
try:
|
|
137
|
+
self.proc.stdin.write(text.encode("utf-8") + b"\n")
|
|
138
|
+
await self.proc.stdin.drain()
|
|
139
|
+
if fut is not None:
|
|
140
|
+
message = await asyncio.wait_for(fut, self.timeout)
|
|
141
|
+
except TimeoutError:
|
|
142
|
+
error_text = "timeout waiting for response"
|
|
143
|
+
self._pending.pop(want_id, None) # type: ignore[arg-type]
|
|
144
|
+
except (BrokenPipeError, ConnectionResetError, TransportError) as exc:
|
|
145
|
+
error_text = f"{type(exc).__name__}: {exc}"
|
|
146
|
+
elapsed_ms = (time.monotonic() - started) * 1000
|
|
147
|
+
body = json.dumps(message).encode("utf-8") if message is not None else b""
|
|
148
|
+
rec = self.evidence.record(
|
|
149
|
+
transport="stdio",
|
|
150
|
+
method=label,
|
|
151
|
+
url=self.display,
|
|
152
|
+
request_body=text,
|
|
153
|
+
response_body=body,
|
|
154
|
+
elapsed_ms=elapsed_ms,
|
|
155
|
+
note=note,
|
|
156
|
+
error=error_text,
|
|
157
|
+
)
|
|
158
|
+
return RpcResponse(
|
|
159
|
+
method=label,
|
|
160
|
+
status=None,
|
|
161
|
+
headers={},
|
|
162
|
+
body=body,
|
|
163
|
+
message=message,
|
|
164
|
+
evidence=rec,
|
|
165
|
+
elapsed_ms=elapsed_ms,
|
|
166
|
+
error_text=error_text,
|
|
167
|
+
stream_messages=[message] if message else [],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async def rpc(
|
|
171
|
+
self,
|
|
172
|
+
method: str,
|
|
173
|
+
params: dict[str, Any] | None = None,
|
|
174
|
+
*,
|
|
175
|
+
note: str | None = None,
|
|
176
|
+
protocol_version: str | None = None,
|
|
177
|
+
**kwargs: Any,
|
|
178
|
+
) -> RpcResponse:
|
|
179
|
+
msg = self.build_message(method, params, protocol_version=protocol_version)
|
|
180
|
+
return await self.send_raw(json.dumps(msg), label=method, want_id=msg["id"], note=note)
|
|
181
|
+
|
|
182
|
+
async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
183
|
+
msg = self.build_message(method, params, notification=True)
|
|
184
|
+
await self.send_raw(json.dumps(msg), label=method, want_id=None, note="notification")
|
|
185
|
+
|
|
186
|
+
async def close(self) -> None:
|
|
187
|
+
if self.proc is None:
|
|
188
|
+
return
|
|
189
|
+
try:
|
|
190
|
+
if self.proc.stdin:
|
|
191
|
+
self.proc.stdin.close()
|
|
192
|
+
try:
|
|
193
|
+
await asyncio.wait_for(self.proc.wait(), 2.0)
|
|
194
|
+
except TimeoutError:
|
|
195
|
+
self.proc.kill()
|
|
196
|
+
await self.proc.wait()
|
|
197
|
+
finally:
|
|
198
|
+
for task in (self._reader_task, self._stderr_task):
|
|
199
|
+
if task:
|
|
200
|
+
task.cancel()
|
|
201
|
+
self.proc = None
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcpprobe-scanner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Security scanner for Model Context Protocol (MCP) servers: auth, transport, tool-metadata and poisoning checks with JSON and SARIF output.
|
|
5
|
+
Project-URL: Homepage, https://mcpprobe.tech
|
|
6
|
+
Project-URL: Scanning policy, https://mcpprobe.tech/scanning-policy
|
|
7
|
+
Project-URL: Source, https://github.com/mcpprobe-dev/mcpprobe
|
|
8
|
+
Project-URL: Issues, https://github.com/mcpprobe-dev/mcpprobe/issues
|
|
9
|
+
Author-email: mcpprobe contributors <hello@mcpprobe.tech>
|
|
10
|
+
Maintainer-email: mcpprobe contributors <hello@mcpprobe.tech>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: mcp,model-context-protocol,owasp,sarif,scanner,security
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: Information Technology
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: click>=8.1
|
|
26
|
+
Requires-Dist: dnspython>=2.6
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: pydantic>=2.6
|
|
29
|
+
Requires-Dist: pyjwt>=2.8
|
|
30
|
+
Requires-Dist: rich>=13.7
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
33
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# mcpprobe
|
|
40
|
+
|
|
41
|
+
Security scanner for [Model Context Protocol](https://modelcontextprotocol.io) servers.
|
|
42
|
+
It checks authentication, OAuth metadata, transport conformance, tool-description
|
|
43
|
+
poisoning, rug pulls, secrets in metadata, verbose errors and basic resource limits,
|
|
44
|
+
maps every finding to the OWASP MCP, Agentic and LLM Top 10 lists, and writes JSON,
|
|
45
|
+
SARIF 2.1.0 and a readable terminal summary.
|
|
46
|
+
|
|
47
|
+
It runs locally, sends no telemetry, and never calls a tool in its default mode.
|
|
48
|
+
|
|
49
|
+
> **Only scan servers you own or are explicitly authorised to test.**
|
|
50
|
+
> See [Ownership rule](#ownership-rule).
|
|
51
|
+
|
|
52
|
+
## 60-second quickstart
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
$ pipx run --spec mcpprobe-scanner mcpprobe https://your-server.example/mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
or install it:
|
|
59
|
+
|
|
60
|
+
```console
|
|
61
|
+
$ pipx install mcpprobe-scanner # or: pip install mcpprobe-scanner; the command is `mcpprobe`
|
|
62
|
+
$ mcpprobe https://your-server.example/mcp --sarif mcpprobe.sarif --json mcpprobe.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A local stdio server you run yourself:
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
$ mcpprobe --stdio "npx -y @your-scope/your-mcp-server@1.2.3"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Passing a bearer token you own (for checks behind your auth):
|
|
72
|
+
|
|
73
|
+
```console
|
|
74
|
+
$ mcpprobe https://your-server.example/mcp --header "Authorization: Bearer $MCP_TEST_TOKEN"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The summary lists each finding with severity, check ID, confidence, OWASP mapping,
|
|
78
|
+
an evidence excerpt and a plain-English fix, followed by a coverage table showing
|
|
79
|
+
which checks ran, which were skipped and why. `mcpprobe checks` lists every check;
|
|
80
|
+
[CHECKS.md](CHECKS.md) documents them in full.
|
|
81
|
+
|
|
82
|
+
## What it checks
|
|
83
|
+
|
|
84
|
+
| Area | Checks | Examples |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| Authentication | AUTH-01..04 | endpoint answers without credentials; missing RFC 9728 protected-resource metadata; no PKCE S256; accepts a self-minted JWT with the wrong audience; token accepted in the query string |
|
|
87
|
+
| Transport | TRANS-01..05 | `Origin` not validated (DNS rebinding); bogus `MCP-Protocol-Version` accepted; weak or forgeable `Mcp-Session-Id`; deprecated HTTP+SSE; plain HTTP, weak TLS, missing headers |
|
|
88
|
+
| Tool metadata | META-01..05 | hidden instructions, cross-tool shadowing, credential paths, URLs, base64, zero-width/bidi/homoglyph Unicode; rug-pull diff against a stored SHA-256 baseline; annotations that contradict capability; schema hygiene; secrets in descriptions or stdio env |
|
|
89
|
+
| Robustness | ERR-01, DOS-01 | stack traces and versions in errors; one oversized argument, one 64-deep nesting probe and 20 paced `tools/list` calls, never more |
|
|
90
|
+
| Active (gated stubs) | INJ-01..05, SUPPLY-01/02, SANDBOX-01 | registered and reported as `not_implemented` in this release |
|
|
91
|
+
| Governance | GOV-01..05 | listed as attestation-only: not assessable by a scan, never marked passed |
|
|
92
|
+
|
|
93
|
+
Protocol revisions: the scanner tries `server/discover` (2026-07-28) first, then
|
|
94
|
+
`initialize` with 2025-11-25 and 2025-06-18, then the deprecated 2024-11-05 HTTP+SSE
|
|
95
|
+
transport, and records which one answered.
|
|
96
|
+
|
|
97
|
+
## Passive vs active
|
|
98
|
+
|
|
99
|
+
**Passive (default)** sends only what a normal MCP client sends (negotiation, the
|
|
100
|
+
`*/list` methods, well-known metadata documents) plus a handful of benign malformed
|
|
101
|
+
requests (bad `Origin`, bogus version header, forged session ID, self-minted
|
|
102
|
+
wrong-audience JWT, truncated JSON, one oversized and one deeply nested request). It
|
|
103
|
+
never calls a tool.
|
|
104
|
+
|
|
105
|
+
**Active** (`--active`) is for checks that call tools with non-destructive canaries.
|
|
106
|
+
In 0.1.0 these are stubs, but the gate is real and enforced in code. Active mode is
|
|
107
|
+
refused unless you pass all of:
|
|
108
|
+
|
|
109
|
+
- `--authorised-by "<who approved this, e.g. name/role/ticket>"`
|
|
110
|
+
- `--scope "<what is in scope>"`
|
|
111
|
+
- `--verify dns-txt` (HTTP targets) or `--verify local` (stdio servers you run yourself)
|
|
112
|
+
|
|
113
|
+
For `dns-txt`, generate a token and publish it:
|
|
114
|
+
|
|
115
|
+
```console
|
|
116
|
+
$ mcpprobe token your-server.example
|
|
117
|
+
Publish this DNS TXT record, then run with --active --verify dns-txt:
|
|
118
|
+
_mcpprobe.your-server.example. TXT "mcpprobe-verification=<token>"
|
|
119
|
+
$ mcpprobe https://your-server.example/mcp --active --verify dns-txt \
|
|
120
|
+
--authorised-by "platform security, CHG-1234" --scope "https://your-server.example/mcp" \
|
|
121
|
+
--json scan.json
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Every active run writes an authorisation record (`scan.authorisation.json` next to the
|
|
125
|
+
report): who authorised it, the scope, the verification proof, rate cap and excluded
|
|
126
|
+
tools. Use `--exclude-tool NAME` for tools that must never be called.
|
|
127
|
+
|
|
128
|
+
## Ownership rule
|
|
129
|
+
|
|
130
|
+
Scan only MCP servers you own, operate, or have explicit written permission to test.
|
|
131
|
+
This applies to passive mode too: even benign requests are unauthorised access if
|
|
132
|
+
the system is not yours. mcpprobe identifies itself on every request and honours
|
|
133
|
+
opt-outs (below), but those are courtesy mechanisms, not permission.
|
|
134
|
+
|
|
135
|
+
## Scanner identification, opt-out and rate limits
|
|
136
|
+
|
|
137
|
+
- User-Agent: `mcpprobe/<version> (+https://mcpprobe.tech/scanning-policy)`.
|
|
138
|
+
- Opt-out, checked before any MCP request and impossible to override:
|
|
139
|
+
- DNS TXT `mcpprobe-opt-out=all` at `_mcpprobe.<host>` (or a parent domain), checked
|
|
140
|
+
before any packet reaches the host;
|
|
141
|
+
- an HTTP `200` at `/.well-known/mcpprobe-opt-out`.
|
|
142
|
+
- Rate: 5 requests/second by default (`--rate`), hard ceiling 10.
|
|
143
|
+
|
|
144
|
+
## Exit codes
|
|
145
|
+
|
|
146
|
+
| Code | Meaning |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `0` | No finding at or above `--fail-on` (default `high`) |
|
|
149
|
+
| `1` | At least one finding at or above `--fail-on` |
|
|
150
|
+
| `2` | Error: target unreachable or not MCP, bad arguments, ownership verification failed, authorisation refused, or the target opted out |
|
|
151
|
+
|
|
152
|
+
## SARIF and GitHub code scanning
|
|
153
|
+
|
|
154
|
+
As a GitHub Action (Docker container action, no Python setup needed):
|
|
155
|
+
|
|
156
|
+
```yaml
|
|
157
|
+
name: MCP security
|
|
158
|
+
on: [push, pull_request]
|
|
159
|
+
permissions:
|
|
160
|
+
contents: read
|
|
161
|
+
security-events: write
|
|
162
|
+
jobs:
|
|
163
|
+
mcpprobe:
|
|
164
|
+
runs-on: ubuntu-latest
|
|
165
|
+
steps:
|
|
166
|
+
- uses: actions/checkout@v4
|
|
167
|
+
- uses: mcpprobe-dev/mcpprobe@v0.1.0
|
|
168
|
+
with:
|
|
169
|
+
target: https://staging.your-server.example/mcp
|
|
170
|
+
fail-on: high
|
|
171
|
+
sarif-output: mcpprobe.sarif
|
|
172
|
+
sarif-location: src/server.py # optional: anchor alerts to a repo file
|
|
173
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
174
|
+
if: always()
|
|
175
|
+
with:
|
|
176
|
+
sarif_file: mcpprobe.sarif
|
|
177
|
+
category: mcpprobe
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Or from any CI with the CLI:
|
|
181
|
+
|
|
182
|
+
```console
|
|
183
|
+
$ pipx run --spec mcpprobe-scanner mcpprobe https://staging.your-server.example/mcp --sarif mcpprobe.sarif --fail-on high
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Results use `error` for critical/high, `warning` for medium and `note` for low/info,
|
|
187
|
+
with GitHub's `security-severity` on each rule and the OWASP IDs as tags. Because a
|
|
188
|
+
scanned endpoint is not a file, results point at the target URL unless you pass
|
|
189
|
+
`--sarif-location`.
|
|
190
|
+
|
|
191
|
+
## Rug-pull detection
|
|
192
|
+
|
|
193
|
+
```console
|
|
194
|
+
$ mcpprobe https://your-server.example/mcp --baseline .mcpprobe/baseline.json
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The first run stores canonical SHA-256 hashes of every tool, prompt, resource and the
|
|
198
|
+
server instructions. Later runs report anything added, removed or changed (a changed
|
|
199
|
+
description after approval is the classic rug pull) and update the file; use
|
|
200
|
+
`--no-write-baseline` to compare without updating.
|
|
201
|
+
|
|
202
|
+
## Reports and evidence
|
|
203
|
+
|
|
204
|
+
- `--json FILE`: the full report, including every request/response pair (timestamp,
|
|
205
|
+
method, status, a header subset, bodies truncated to 4 KB). `Authorization` values
|
|
206
|
+
and recognised secret patterns are redacted.
|
|
207
|
+
- `--sarif FILE`: SARIF 2.1.0.
|
|
208
|
+
- `-v`: descriptions and scan notes in the terminal; `-q`: no terminal output.
|
|
209
|
+
|
|
210
|
+
Server-supplied text is shown with control characters made visible and is never
|
|
211
|
+
interpreted as terminal markup.
|
|
212
|
+
|
|
213
|
+
## Python API and LLM-judge hook
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
import asyncio
|
|
217
|
+
from mcpprobe.checks import JudgeVerdict, ScanOptions
|
|
218
|
+
from mcpprobe.scanner import ScanConfig, run_scan
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def judge(text: str, task: str) -> JudgeVerdict | None:
|
|
222
|
+
return None # plug in your own model here; mcpprobe makes no LLM calls
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
report, evidence = asyncio.run(
|
|
226
|
+
run_scan(ScanConfig(target="https://your-server.example/mcp", options=ScanOptions(judge=judge)))
|
|
227
|
+
)
|
|
228
|
+
print(report.counts())
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Development
|
|
232
|
+
|
|
233
|
+
```console
|
|
234
|
+
$ pip install -e ".[dev]"
|
|
235
|
+
$ ruff check . && ruff format --check . && mypy && pytest
|
|
236
|
+
$ python -m tests.fake_server --preset vulnerable --port 8765 # deliberately vulnerable local server
|
|
237
|
+
$ mcpprobe http://127.0.0.1:8765/mcp
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Security issues: see [SECURITY.md](SECURITY.md). Licence: [MIT](LICENSE).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
mcpprobe/__init__.py,sha256=DY-sbjlh9JnxQG_v6UkZGtOApvi5LUU2t4oPbtEK_tc,821
|
|
2
|
+
mcpprobe/action.py,sha256=6oag4JcDPvm-_H6Pp2OMY4M3WYeS4KSCwlsSLKtypYU,2366
|
|
3
|
+
mcpprobe/authz.py,sha256=PCql53OFLQsN7oZxm2UtoAqTmT7LWRRw5nmx_50ePik,10595
|
|
4
|
+
mcpprobe/catalogue.py,sha256=j0h-O-2FQq158P97bXf3akD0S3gzxt7P4AUJYMHbYss,4729
|
|
5
|
+
mcpprobe/cli.py,sha256=UyA7KpuvKVCGV5hj-St_vU8sV267vI6RH_Xlp2NCkqs,10731
|
|
6
|
+
mcpprobe/dnsutil.py,sha256=d9AWtU96SHE7QzphZk76L9r61LpI0MuxPYLHEijHK4Y,1958
|
|
7
|
+
mcpprobe/governance.py,sha256=khs0t3jjSbwYzFYAjywnOzy_9m_qXy4ctuaFJy8UkdI,3391
|
|
8
|
+
mcpprobe/models.py,sha256=U7dM-BRp0UZV6S0SErQH8UbdqYgWr917OV1kvlpvgqE,8662
|
|
9
|
+
mcpprobe/policy.py,sha256=VWb6R3Udetp3AFk-BJqulRJREquyvtvP1VS8QhUOMdo,4865
|
|
10
|
+
mcpprobe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
mcpprobe/redact.py,sha256=8VhTwssMnU2cKKL2dm2omLfjpxDWtX1GJTS8f_iWcTM,3775
|
|
12
|
+
mcpprobe/scanner.py,sha256=XSxnyCO9Hr8JCyi5yeUSjZe4jEHw8ImFGgYuXUee1QU,8015
|
|
13
|
+
mcpprobe/checks/__init__.py,sha256=dsf5exmjJlhyeonq4wnFm0gdsfzbzNYccIb8qbhhSqY,2855
|
|
14
|
+
mcpprobe/checks/_util.py,sha256=5_8aLzoU3yfqWz6HcaHweb_j9KxuO0WgRDBiox39GIQ,3125
|
|
15
|
+
mcpprobe/checks/active_stubs.py,sha256=zI-0sw2ncmg6B2tiia0laJOPcfzMFWfagY0G6wfPMfY,9129
|
|
16
|
+
mcpprobe/checks/auth.py,sha256=q6Gu3wRfY3Gl-MyGe5tRCM7cseaqYg-t1kNLyFuw6Ww,21592
|
|
17
|
+
mcpprobe/checks/base.py,sha256=MOTPdE4u8MVPGdzmXpn_rdjIsjaH65DmQeT1iQnwwHU,7884
|
|
18
|
+
mcpprobe/checks/baseline.py,sha256=w-hwkN-OIczFephuXS7XSgjdTqOKkbTOEkHT16XWpK4,9847
|
|
19
|
+
mcpprobe/checks/capability.py,sha256=ZS8bz5GOzFZ1JpzjpzAQ46pziixuwa97AkWOVdSY2oQ,5785
|
|
20
|
+
mcpprobe/checks/metadata.py,sha256=dxoECGSxav8J-uEU0SX6R6HLVktTp0l1NtzT1ZMUik0,16554
|
|
21
|
+
mcpprobe/checks/poisoning.py,sha256=NRA676paxs8IQujSOcPWWC9frn_7BxPdKV-OUjW1yKY,19054
|
|
22
|
+
mcpprobe/checks/robustness.py,sha256=IggjqWHt21mEJk37OD2zr4w4bq7MRduaDVGEnkvMbJI,11865
|
|
23
|
+
mcpprobe/checks/transport_conformance.py,sha256=7m8Lp54m9GOLfzGW7jmSOop4aNKxJAhtcygnUNdD_90,19189
|
|
24
|
+
mcpprobe/report/__init__.py,sha256=n1Q29xF32h-8_VKiOHkvEl7uMf2VdKXyeDc9FLVO8u0,318
|
|
25
|
+
mcpprobe/report/json_report.py,sha256=HGfax7PoH0YbbxDMnySWZFxehoMEnAlb93p8XyN8aFA,1362
|
|
26
|
+
mcpprobe/report/sarif.py,sha256=NGkRiHPpNWBXBMaaAn2Xi_cOJ3hVa4z1ir3pIDcinmQ,13424
|
|
27
|
+
mcpprobe/report/terminal.py,sha256=rK6kCvnX6gJxbrW0dMvtNpA5BbqdQ-kj0u9GHLwleF0,6596
|
|
28
|
+
mcpprobe/transport/__init__.py,sha256=ZmUxTFl9Vm0MWy67Gb3BWM7LwL3ki1ZZ_8r54mK9f3U,863
|
|
29
|
+
mcpprobe/transport/base.py,sha256=UFeFi2DfG1Yf5wh1gV6TTOIXWT7rLp-9wzH39Y_hTGI,5414
|
|
30
|
+
mcpprobe/transport/evidence.py,sha256=rzeqnjc0a7olgDe-6S1QMD2v10Z1yKAcGsOF-VvJJUQ,4373
|
|
31
|
+
mcpprobe/transport/fingerprint.py,sha256=B4A5dg9I-cRk7oWLAB8qt7MOlYUVnT70W5Uc-_HeQ-8,5609
|
|
32
|
+
mcpprobe/transport/http.py,sha256=NHb-qfzAFF8A0B5HxTR7kz_pkceAjyJdOp5xAQbKZtE,13222
|
|
33
|
+
mcpprobe/transport/ratelimit.py,sha256=g6mLxbVyXVVk0ndJ2jwJuC_RqfojbyHlABUTL7ys_9w,1578
|
|
34
|
+
mcpprobe/transport/stdio.py,sha256=b_7cLDTidW5HVK_p7mT1fz0uYAdA7hh5ZlmFG65EBf4,7559
|
|
35
|
+
mcpprobe_scanner-0.1.0.dist-info/METADATA,sha256=f7bpIvTuaFzBZ9VLpM-pS8WBMx8G5EZuCzCuDYn4CTI,9832
|
|
36
|
+
mcpprobe_scanner-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
37
|
+
mcpprobe_scanner-0.1.0.dist-info/entry_points.txt,sha256=NEMxBH370sFz_Vw8812R7adD_DDQ3ZI7ixcuWZNQCHY,47
|
|
38
|
+
mcpprobe_scanner-0.1.0.dist-info/licenses/LICENSE,sha256=Tzyy4bJyMSytgfl0I9_VubP5nGRQiGO4yYJKvyKUdpE,1078
|
|
39
|
+
mcpprobe_scanner-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mcpprobe contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|