Skip to content

Commit 9de93f1

Browse files
Merge pull request #6 from ccronca/feat/stdio-transport-support
Add stdio transport support
2 parents d1c7b76 + 5e9f0e5 commit 9de93f1

File tree

7 files changed

+2061
-67
lines changed

7 files changed

+2061
-67
lines changed

README.md

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MCP Security Scanner
22

3-
This is a Python-based penetration testing tool for Model Context Protocol (MCP) servers. It supports Streamable HTTP and SSE transports, runs a suite of checks mapped to `scanner_specs.schema` (auth, transport, tools, prompts, resources), and includes a deliberately insecure MCP-like server for testing.
3+
This is a Python-based penetration testing tool for Model Context Protocol (MCP) servers. It supports HTTP, stdio, and experimental SSE transports, runs a suite of checks mapped to `scanner_specs.schema` (auth, transport, tools, prompts, resources), and includes a deliberately insecure MCP-like server for testing.
44

55
**Note: SSE transport is discontinued in the latest version of MCP. Support for SSE in this tool is purely experimental and may not work!!!**
66

@@ -54,7 +54,7 @@ insecure-mcp-server --host 127.0.0.1 --port 9001
5454
insecure-mcp-server --host 127.0.0.1 --port 9001 --test 0/1/2/3/4/5/6/7
5555
```
5656

57-
### Scan the server (HTTP or SSE)
57+
### Scan the server (HTTP, stdio, or SSE)
5858
```bash
5959
# HTTP: Text report (no discovery; --url is the JSON-RPC endpoint)
6060
mcp-scan scan --url http://127.0.0.1:9001/mcp --format text
@@ -65,16 +65,21 @@ mcp-scan scan --url http://127.0.0.1:9001/mcp --format json --output report.json
6565
# HTTP: Verbose tracing (real-time)
6666
mcp-scan scan --url http://127.0.0.1:9001/mcp --verbose
6767

68+
# stdio: Scan local MCP servers via stdin/stdout
69+
mcp-scan scan --transport stdio --command "npx -y @modelcontextprotocol/server-memory" --format json
70+
6871
# SSE: connect to explicit SSE endpoint, then scan via emitted /messages?sessionId=...
6972
mcp-scan scan --url https://your-mcp.example.com --transport sse --sse-endpoint /sse --timeout 30 --verbose
7073
```
7174

7275
### New: RPC passthrough (Inspector-like)
76+
**Note: RPC commands only support HTTP and SSE transports, not stdio.**
77+
7378
```bash
74-
# List tools
79+
# List tools (HTTP)
7580
mcp-scan rpc --url https://your-mcp.example.com/mcp --method tools/list --transport http
7681

77-
# Call a tool
82+
# Call a tool (HTTP)
7883
mcp-scan rpc --url https://your-mcp.example.com/mcp \
7984
--method tools/call \
8085
--params '{"name":"weather","arguments":{"city":"Paris"}}' \
@@ -99,14 +104,17 @@ mcp-scan scan --url https://your-mcp.example.com/mcp --explain X-01
99104

100105
### Only health
101106
- `--only-health` prints server details and enumerations without running the full scan.
102-
- Works for HTTP and SSE (SSE uses the provided endpoint and the stream-emitted POST path).
107+
- Works for HTTP, stdio, and SSE transports.
103108
- Supports `--format text` and `--format json`.
104109

105110
Examples:
106111
```bash
107112
# HTTP
108113
mcp-scan scan --url https://your-mcp.example.com/mcp --only-health --format text
109114

115+
# stdio
116+
mcp-scan scan --transport stdio --command "npx -y @modelcontextprotocol/server-memory" --only-health --format json
117+
110118
# SSE
111119
mcp-scan scan --url https://your-mcp.example.com --transport sse --sse-endpoint /sse --only-health --format json
112120
```
@@ -129,11 +137,35 @@ mcp-scan scan \
129137
```
130138

131139
### Transport, timeouts, session
132-
- **--transport auto|http|sse**: Hint preferred transport; no dynamic discovery. Provide working URLs.
140+
- **--transport auto|http|stdio|sse**: Hint preferred transport; no dynamic discovery.
141+
- `http`: Requires `--url` for JSON-RPC endpoint
142+
- `stdio`: Requires `--command` for local MCP server process
143+
- `sse`: Requires `--url` and `--sse-endpoint` (experimental)
133144
- **--timeout <seconds>**: Per-request read timeout (default 12s). Increase for slow streams.
134145
- **--session-id <SID>**: Pre-established session (`Mcp-Session-Id` header).
135146

136147

148+
## Testing
149+
150+
### Running Tests
151+
```bash
152+
# Set up environment (if not already done)
153+
python -m venv .venv
154+
source .venv/bin/activate
155+
pip install -r requirements.txt
156+
pip install -e .
157+
158+
# Run all tests
159+
python -m pytest tests/ -v
160+
161+
# Run specific test module
162+
python -m pytest tests/test_stdio_scanner.py -v
163+
python -m pytest tests/test_security_checks.py -v
164+
165+
# Run specific test class
166+
python -m pytest tests/test_stdio_scanner.py::TestStdioIntegration -v
167+
```
168+
137169
### Running with Container
138170

139171
```bash

src/mcp_scanner/cli.py

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .models import Report
1313
from .spec import load_spec
1414
from .http_checks import run_full_http_checks, scan_http_base, get_server_health, rpc_call
15+
from .stdio_scanner import scan_stdio, get_stdio_health
1516
from .auth import build_auth_headers
1617
import httpx
1718

@@ -21,16 +22,16 @@
2122

2223
@click.group()
2324
def main() -> None:
24-
"""MCP Security Scanner CLI (HTTP-only)."""
25+
"""MCP Security Scanner CLI."""
2526

2627

2728
@main.command("scan")
28-
@click.option("--url", required=True, help="Target MCP server base URL (http:// or https://)")
29+
@click.option("--url", help="Target MCP server base URL (http:// or https://), not required for stdio transport")
2930
@click.option("--spec", type=click.Path(exists=True, dir_okay=False), help="Path to scanner_specs.schema")
3031
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text")
3132
@click.option("--verbose", is_flag=True, default=False, help="Print full request/response trace and leaked data")
3233
@click.option("--explain", "explain_id", help="Explain a specific finding by ID (e.g., X-01)")
33-
@click.option("--transport", type=click.Choice(["auto", "http", "sse"]), default="auto", show_default=True, help="Preferred transport hint; auto tries SSE when available")
34+
@click.option("--transport", type=click.Choice(["auto", "http", "sse", "stdio"]), default="auto", show_default=True, help="Preferred transport hint; auto tries SSE when available")
3435
@click.option("--only-health", is_flag=True, default=False, help="Dump endpoints, tools, prompts, resources and exit (no scan)")
3536
@click.option("--sse-endpoint", help="When --transport sse, append this path to --url for SSE (e.g., /sse)")
3637
@click.option("--auth-type", type=click.Choice(["bearer", "oauth2-client-credentials"]))
@@ -42,10 +43,24 @@ def main() -> None:
4243
@click.option("--output", type=click.Path(dir_okay=False), help="Write report to file")
4344
@click.option("--timeout", type=float, default=12.0, show_default=True, help="Per-request read timeout in seconds")
4445
@click.option("--session-id", help="Pre-supplied session id to include in Mcp-Session-Id header")
45-
def scan_cmd(url: str, spec: Optional[str], fmt: str, verbose: bool, explain_id: Optional[str], auth_type: Optional[str], auth_token: Optional[str], token_url: Optional[str], client_id: Optional[str], client_secret: Optional[str], scope: Optional[str], output: Optional[str], timeout: float, session_id: Optional[str], transport: str, only_health: bool, sse_endpoint: Optional[str]) -> None:
46+
@click.option("--command", help="Command to run MCP server (required for stdio transport)")
47+
def scan_cmd(url: str, spec: Optional[str], fmt: str, verbose: bool, explain_id: Optional[str], auth_type: Optional[str], auth_token: Optional[str], token_url: Optional[str], client_id: Optional[str], client_secret: Optional[str], scope: Optional[str], output: Optional[str], timeout: float, session_id: Optional[str], transport: str, only_health: bool, sse_endpoint: Optional[str], command: Optional[str]) -> None:
4648
if verbose and explain_id:
4749
console.print("--verbose and --explain are mutually exclusive; using --explain.")
4850
verbose = False
51+
52+
# Validate stdio transport requirements
53+
if transport == "stdio":
54+
if not command:
55+
raise click.ClickException("--command is required when using --transport stdio")
56+
if url and url != "stdio://command":
57+
console.print("Note: --url is ignored when using stdio transport, using provided --command instead")
58+
else:
59+
if not url:
60+
raise click.ClickException("--url is required when not using stdio transport")
61+
if command:
62+
raise click.ClickException("--command can only be used with --transport stdio")
63+
4964
if transport == "sse":
5065
console.print("SSE is deprecated in MCP!!! SSE support in the scanner is experimental and may not work!!!")
5166
class RealtimeTrace:
@@ -104,14 +119,15 @@ def __iter__(self) -> Iterator[Dict[str, Any]]:
104119
if session_id:
105120
auth_headers = {**auth_headers, "Mcp-Session-Id": session_id}
106121

107-
# Preflight reachability check
108-
if not (url.lower().startswith("http://") or url.lower().startswith("https://")):
109-
raise click.ClickException("--url must start with http:// or https://")
110-
try:
111-
with httpx.Client(follow_redirects=True, timeout=httpx.Timeout(connect=3.0, read=timeout, write=timeout, pool=timeout)) as _c:
112-
_c.get(url, timeout=httpx.Timeout(connect=3.0, read=timeout, write=timeout, pool=timeout))
113-
except httpx.RequestError as e: # noqa: PERF203
114-
raise click.ClickException(f"Cannot reach MCP server at {url}: {type(e).__name__}: {e}")
122+
# Preflight reachability check for HTTP/HTTPS transports only
123+
if transport != "stdio":
124+
if not (url.lower().startswith("http://") or url.lower().startswith("https://")):
125+
raise click.ClickException("--url must start with http:// or https://")
126+
try:
127+
with httpx.Client(follow_redirects=True, timeout=httpx.Timeout(connect=3.0, read=timeout, write=timeout, pool=timeout)) as _c:
128+
_c.get(url, timeout=httpx.Timeout(connect=3.0, read=timeout, write=timeout, pool=timeout))
129+
except httpx.RequestError as e: # noqa: PERF203
130+
raise click.ClickException(f"Cannot reach MCP server at {url}: {type(e).__name__}: {e}")
115131

116132
spec_file = Path(spec) if spec else None
117133
if spec_file is not None:
@@ -125,23 +141,36 @@ def __iter__(self) -> Iterator[Dict[str, Any]]:
125141
elif transport == "http" and "Accept" not in auth_headers:
126142
auth_headers = {**auth_headers, "Accept": "application/json, text/event-stream"}
127143
if only_health:
128-
health = get_server_health(url, headers=auth_headers, trace=trace, verbose=verbose, timeout=timeout, transport=transport, sse_endpoint=sse_endpoint)
144+
if transport == "stdio":
145+
health = get_stdio_health(command)
146+
else:
147+
health = get_server_health(url, headers=auth_headers, trace=trace, verbose=verbose, timeout=timeout, transport=transport, sse_endpoint=sse_endpoint)
129148
if fmt == "json":
130149
console.rule("Health (JSON)")
131150
console.print_json(json.dumps(health))
132151
return
133152
# Text output
134153
console.rule("Health")
135-
base = health.get("base_url")
136-
msg_url = health.get("msg_url")
137-
sse_url = health.get("sse_url")
154+
155+
# Handle stdio vs HTTP health data
156+
if transport == "stdio":
157+
target = health.get("target", "stdio")
158+
console.print(f"Target: {target}")
159+
console.print(f"Transport: {health.get('transport', 'stdio')}")
160+
if "error" in health:
161+
console.print(f"[red]Error: {health['error']}[/red]")
162+
else:
163+
base = health.get("base_url")
164+
msg_url = health.get("msg_url")
165+
sse_url = health.get("sse_url")
166+
console.print(f"Base URL: {base}")
167+
console.print(f"Message endpoint: {msg_url}")
168+
console.print(f"SSE URL: {sse_url}")
169+
138170
init_obj = health.get("initialize") or {}
139171
tools = health.get("tools") or []
140172
prompts = health.get("prompts") or []
141173
resources = health.get("resources") or []
142-
console.print(f"Base URL: {base}")
143-
console.print(f"Message endpoint: {msg_url}")
144-
console.print(f"SSE URL: {sse_url}")
145174
if isinstance(init_obj, dict):
146175
keys = list((init_obj.get("result") or {}).keys()) if "result" in init_obj else list(init_obj.keys())
147176
console.print(f"Initialize keys: {keys}")
@@ -178,8 +207,13 @@ def __iter__(self) -> Iterator[Dict[str, Any]]:
178207
rtable.add_row("-", "No resources discovered", "")
179208
console.print(rtable)
180209
return
181-
findings = run_full_http_checks(url, spec_index, headers=auth_headers, trace=trace, verbose=verbose, timeout=timeout, transport=transport, sse_endpoint=sse_endpoint)
182-
report = Report.new(target=url, findings=findings)
210+
211+
# Run scanning based on transport type
212+
if transport == "stdio":
213+
report = scan_stdio(command, spec_index)
214+
else:
215+
findings = run_full_http_checks(url, spec_index, headers=auth_headers, trace=trace, verbose=verbose, timeout=timeout, transport=transport, sse_endpoint=sse_endpoint)
216+
report = Report.new(target=url, findings=findings)
183217

184218
if only_health:
185219
return

0 commit comments

Comments
 (0)