mcp-preflight 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jordanstarrk
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.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-preflight
3
+ Version: 0.1.0
4
+ Summary: See what an MCP server exposes before you trust or connect it.
5
+ Author: jordanstarrk
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 jordanstarrk
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/jordanstarrk/mcp-preflight
29
+ Project-URL: Repository, https://github.com/jordanstarrk/mcp-preflight
30
+ Project-URL: Issues, https://github.com/jordanstarrk/mcp-preflight/issues
31
+ Project-URL: Changelog, https://github.com/jordanstarrk/mcp-preflight/releases
32
+ Keywords: mcp,security,cli,ai,agents
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Environment :: Console
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3 :: Only
40
+ Classifier: Programming Language :: Python :: 3.10
41
+ Classifier: Programming Language :: Python :: 3.11
42
+ Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Programming Language :: Python :: 3.13
44
+ Classifier: Topic :: Security
45
+ Classifier: Topic :: Utilities
46
+ Requires-Python: >=3.10
47
+ Description-Content-Type: text/markdown
48
+ License-File: LICENSE
49
+ Requires-Dist: mcp>=1.26.0
50
+ Dynamic: license-file
51
+
52
+ # mcp-preflight
53
+
54
+ See what an MCP server exposes before you trust or connect it.
55
+
56
+ ## TLDR
57
+
58
+ Run one command and get a quick capability + risk report for an MCP server (tools, resources, prompts).
59
+
60
+ ## Install
61
+
62
+ Recommended (CLI):
63
+
64
+ ```bash
65
+ pipx install mcp-preflight
66
+ ```
67
+
68
+ Alternative:
69
+
70
+ ```bash
71
+ pip install mcp-preflight
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ ```bash
77
+ mcp-preflight "uv run server.py"
78
+ ```
79
+
80
+ ### Quick “real server” smoke test
81
+
82
+ ```bash
83
+ mcp-preflight "npx @modelcontextprotocol/server-filesystem /tmp"
84
+ ```
85
+
86
+ ### Other examples
87
+
88
+ ```bash
89
+ mcp-preflight "npx my-mcp-server"
90
+ mcp-preflight "python3 /path/to/server.py"
91
+ ```
92
+
93
+ ### Save a report (JSON)
94
+
95
+ ```bash
96
+ mcp-preflight --save report.json "uv run server.py"
97
+ ```
98
+
99
+ ### Diff two saved reports
100
+
101
+ ```bash
102
+ mcp-preflight diff before.json after.json
103
+ ```
104
+
105
+ ### JSON output
106
+
107
+ ```bash
108
+ mcp-preflight --json "uv run server.py"
109
+ ```
110
+
111
+ ### Example output
112
+
113
+ ```text
114
+ my-server (MCP 2025-03-26)
115
+
116
+ Note: this runs the server locally; it does not sandbox the process.
117
+
118
+ Tools:
119
+ 🟢 list_items "List all items in the database"
120
+ 🟢 get_item "Get a single item by ID"
121
+ 🟡 create_item "Create a new item"
122
+ 🟡 update_item "Update an existing item"
123
+ 🔴 delete_item "Permanently delete an item"
124
+
125
+ Resources:
126
+ 📄 my-server://items
127
+ 📄 my-server://items/{id}
128
+
129
+ Prompts:
130
+ 💬 analyze_items (project_name)
131
+
132
+ Signals (heuristic):
133
+ ⚠️ system prompt mention: prompt analyze_items
134
+ (may be false positives/negatives)
135
+
136
+ Notes:
137
+ ℹ️ timeout: mcp list_resources
138
+
139
+ Risk: 2 write, 1 destructive, 2 read-only
140
+ ```
141
+
142
+ ## Risk classification
143
+
144
+ Based on tool names and descriptions (conservative by default):
145
+
146
+ - 🟢 **read-only**: `get`, `list`, `search`, `read`, `fetch`, `find`, `show`, `view`
147
+ - 🟡 **write**: `create`, `add`, `update`, `set`, `send`, `write`, `upload`
148
+ - 🔴 **destructive**: `delete`, `remove`, `destroy`, `drop`, `purge`, `clear`, `reset`
149
+ - Unknown → 🟡 (assume write until proven otherwise)
150
+
151
+ ## Non-goals
152
+
153
+ - No sandboxing
154
+ - No policy enforcement
155
+ - No runtime analysis
156
+
157
+ This tool inspects exposed MCP capabilities. It does not call tools (`call_tool`).
158
+
159
+ ## Support
160
+
161
+ - Bugs / feature requests: `https://github.com/jordanstarrk/mcp-preflight/issues`
@@ -0,0 +1,110 @@
1
+ # mcp-preflight
2
+
3
+ See what an MCP server exposes before you trust or connect it.
4
+
5
+ ## TLDR
6
+
7
+ Run one command and get a quick capability + risk report for an MCP server (tools, resources, prompts).
8
+
9
+ ## Install
10
+
11
+ Recommended (CLI):
12
+
13
+ ```bash
14
+ pipx install mcp-preflight
15
+ ```
16
+
17
+ Alternative:
18
+
19
+ ```bash
20
+ pip install mcp-preflight
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ mcp-preflight "uv run server.py"
27
+ ```
28
+
29
+ ### Quick “real server” smoke test
30
+
31
+ ```bash
32
+ mcp-preflight "npx @modelcontextprotocol/server-filesystem /tmp"
33
+ ```
34
+
35
+ ### Other examples
36
+
37
+ ```bash
38
+ mcp-preflight "npx my-mcp-server"
39
+ mcp-preflight "python3 /path/to/server.py"
40
+ ```
41
+
42
+ ### Save a report (JSON)
43
+
44
+ ```bash
45
+ mcp-preflight --save report.json "uv run server.py"
46
+ ```
47
+
48
+ ### Diff two saved reports
49
+
50
+ ```bash
51
+ mcp-preflight diff before.json after.json
52
+ ```
53
+
54
+ ### JSON output
55
+
56
+ ```bash
57
+ mcp-preflight --json "uv run server.py"
58
+ ```
59
+
60
+ ### Example output
61
+
62
+ ```text
63
+ my-server (MCP 2025-03-26)
64
+
65
+ Note: this runs the server locally; it does not sandbox the process.
66
+
67
+ Tools:
68
+ 🟢 list_items "List all items in the database"
69
+ 🟢 get_item "Get a single item by ID"
70
+ 🟡 create_item "Create a new item"
71
+ 🟡 update_item "Update an existing item"
72
+ 🔴 delete_item "Permanently delete an item"
73
+
74
+ Resources:
75
+ 📄 my-server://items
76
+ 📄 my-server://items/{id}
77
+
78
+ Prompts:
79
+ 💬 analyze_items (project_name)
80
+
81
+ Signals (heuristic):
82
+ ⚠️ system prompt mention: prompt analyze_items
83
+ (may be false positives/negatives)
84
+
85
+ Notes:
86
+ ℹ️ timeout: mcp list_resources
87
+
88
+ Risk: 2 write, 1 destructive, 2 read-only
89
+ ```
90
+
91
+ ## Risk classification
92
+
93
+ Based on tool names and descriptions (conservative by default):
94
+
95
+ - 🟢 **read-only**: `get`, `list`, `search`, `read`, `fetch`, `find`, `show`, `view`
96
+ - 🟡 **write**: `create`, `add`, `update`, `set`, `send`, `write`, `upload`
97
+ - 🔴 **destructive**: `delete`, `remove`, `destroy`, `drop`, `purge`, `clear`, `reset`
98
+ - Unknown → 🟡 (assume write until proven otherwise)
99
+
100
+ ## Non-goals
101
+
102
+ - No sandboxing
103
+ - No policy enforcement
104
+ - No runtime analysis
105
+
106
+ This tool inspects exposed MCP capabilities. It does not call tools (`call_tool`).
107
+
108
+ ## Support
109
+
110
+ - Bugs / feature requests: `https://github.com/jordanstarrk/mcp-preflight/issues`
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-preflight
3
+ Version: 0.1.0
4
+ Summary: See what an MCP server exposes before you trust or connect it.
5
+ Author: jordanstarrk
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 jordanstarrk
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/jordanstarrk/mcp-preflight
29
+ Project-URL: Repository, https://github.com/jordanstarrk/mcp-preflight
30
+ Project-URL: Issues, https://github.com/jordanstarrk/mcp-preflight/issues
31
+ Project-URL: Changelog, https://github.com/jordanstarrk/mcp-preflight/releases
32
+ Keywords: mcp,security,cli,ai,agents
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Environment :: Console
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3 :: Only
40
+ Classifier: Programming Language :: Python :: 3.10
41
+ Classifier: Programming Language :: Python :: 3.11
42
+ Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Programming Language :: Python :: 3.13
44
+ Classifier: Topic :: Security
45
+ Classifier: Topic :: Utilities
46
+ Requires-Python: >=3.10
47
+ Description-Content-Type: text/markdown
48
+ License-File: LICENSE
49
+ Requires-Dist: mcp>=1.26.0
50
+ Dynamic: license-file
51
+
52
+ # mcp-preflight
53
+
54
+ See what an MCP server exposes before you trust or connect it.
55
+
56
+ ## TLDR
57
+
58
+ Run one command and get a quick capability + risk report for an MCP server (tools, resources, prompts).
59
+
60
+ ## Install
61
+
62
+ Recommended (CLI):
63
+
64
+ ```bash
65
+ pipx install mcp-preflight
66
+ ```
67
+
68
+ Alternative:
69
+
70
+ ```bash
71
+ pip install mcp-preflight
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ ```bash
77
+ mcp-preflight "uv run server.py"
78
+ ```
79
+
80
+ ### Quick “real server” smoke test
81
+
82
+ ```bash
83
+ mcp-preflight "npx @modelcontextprotocol/server-filesystem /tmp"
84
+ ```
85
+
86
+ ### Other examples
87
+
88
+ ```bash
89
+ mcp-preflight "npx my-mcp-server"
90
+ mcp-preflight "python3 /path/to/server.py"
91
+ ```
92
+
93
+ ### Save a report (JSON)
94
+
95
+ ```bash
96
+ mcp-preflight --save report.json "uv run server.py"
97
+ ```
98
+
99
+ ### Diff two saved reports
100
+
101
+ ```bash
102
+ mcp-preflight diff before.json after.json
103
+ ```
104
+
105
+ ### JSON output
106
+
107
+ ```bash
108
+ mcp-preflight --json "uv run server.py"
109
+ ```
110
+
111
+ ### Example output
112
+
113
+ ```text
114
+ my-server (MCP 2025-03-26)
115
+
116
+ Note: this runs the server locally; it does not sandbox the process.
117
+
118
+ Tools:
119
+ 🟢 list_items "List all items in the database"
120
+ 🟢 get_item "Get a single item by ID"
121
+ 🟡 create_item "Create a new item"
122
+ 🟡 update_item "Update an existing item"
123
+ 🔴 delete_item "Permanently delete an item"
124
+
125
+ Resources:
126
+ 📄 my-server://items
127
+ 📄 my-server://items/{id}
128
+
129
+ Prompts:
130
+ 💬 analyze_items (project_name)
131
+
132
+ Signals (heuristic):
133
+ ⚠️ system prompt mention: prompt analyze_items
134
+ (may be false positives/negatives)
135
+
136
+ Notes:
137
+ ℹ️ timeout: mcp list_resources
138
+
139
+ Risk: 2 write, 1 destructive, 2 read-only
140
+ ```
141
+
142
+ ## Risk classification
143
+
144
+ Based on tool names and descriptions (conservative by default):
145
+
146
+ - 🟢 **read-only**: `get`, `list`, `search`, `read`, `fetch`, `find`, `show`, `view`
147
+ - 🟡 **write**: `create`, `add`, `update`, `set`, `send`, `write`, `upload`
148
+ - 🔴 **destructive**: `delete`, `remove`, `destroy`, `drop`, `purge`, `clear`, `reset`
149
+ - Unknown → 🟡 (assume write until proven otherwise)
150
+
151
+ ## Non-goals
152
+
153
+ - No sandboxing
154
+ - No policy enforcement
155
+ - No runtime analysis
156
+
157
+ This tool inspects exposed MCP capabilities. It does not call tools (`call_tool`).
158
+
159
+ ## Support
160
+
161
+ - Bugs / feature requests: `https://github.com/jordanstarrk/mcp-preflight/issues`
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ mcp_preflight.py
4
+ pyproject.toml
5
+ mcp_preflight.egg-info/PKG-INFO
6
+ mcp_preflight.egg-info/SOURCES.txt
7
+ mcp_preflight.egg-info/dependency_links.txt
8
+ mcp_preflight.egg-info/entry_points.txt
9
+ mcp_preflight.egg-info/requires.txt
10
+ mcp_preflight.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-preflight = mcp_preflight:main
@@ -0,0 +1 @@
1
+ mcp>=1.26.0
@@ -0,0 +1 @@
1
+ mcp_preflight
@@ -0,0 +1,571 @@
1
+ """
2
+ mcp-preflight — See what an MCP server does before you trust it.
3
+
4
+ Usage:
5
+ mcp-preflight "uv run server.py"
6
+ mcp-preflight "npx my-mcp-server"
7
+ mcp-preflight "python /path/to/server.py"
8
+ mcp-preflight --save report.json "uv run server.py"
9
+ mcp-preflight diff before.json after.json
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import re
19
+ import shlex
20
+ import shutil
21
+ import sys
22
+ import tempfile
23
+ import textwrap
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import TextIO
27
+ from mcp import ClientSession, StdioServerParameters
28
+ from mcp.client.stdio import stdio_client
29
+
30
+ # Exception groups are built-in in Python 3.11+, but on 3.10 they're provided by the
31
+ # `exceptiongroup` backport (often installed as a transitive dependency).
32
+ try: # Python 3.11+
33
+ _BaseExceptionGroup = BaseExceptionGroup # type: ignore[name-defined]
34
+ except NameError: # Python <= 3.10
35
+ try:
36
+ from exceptiongroup import BaseExceptionGroup as _BaseExceptionGroup # type: ignore
37
+ except Exception: # pragma: no cover
38
+ _BaseExceptionGroup = None
39
+
40
+
41
+ # ── Risk classification ──────────────────────────────────────
42
+
43
+ READ_PATTERNS = re.compile(
44
+ r"\b(get|list|search|read|fetch|find|show|view)\b",
45
+ re.IGNORECASE,
46
+ )
47
+ WRITE_PATTERNS = re.compile(
48
+ r"\b(create|add|update|set|send|write|upload)\b",
49
+ re.IGNORECASE,
50
+ )
51
+ DESTRUCTIVE_PATTERNS = re.compile(
52
+ r"\b(delete|remove|destroy|drop|purge|clear|reset)\b",
53
+ re.IGNORECASE,
54
+ )
55
+
56
+
57
+ def classify_tool(name: str, description: str) -> tuple[str, str]:
58
+ """Classify a tool's risk level from its name and description."""
59
+ # Normalize tool names like `get_file_info` so \bget\b matches:
60
+ # underscores/dashes are "word chars" in regex, so treat them as separators.
61
+ text = f"{name} {description}"
62
+ text = re.sub(r"[_-]+", " ", text)
63
+
64
+ if DESTRUCTIVE_PATTERNS.search(text):
65
+ return "🔴", "destructive"
66
+ if WRITE_PATTERNS.search(text):
67
+ return "🟡", "write"
68
+ if READ_PATTERNS.search(text):
69
+ return "🟢", "read"
70
+ # Unknown → 🟡 (assume write until proven otherwise).
71
+ return "🟡", "write"
72
+
73
+ def _normalize_text(s: object) -> str:
74
+ return " ".join(str(s).split())
75
+
76
+
77
+ def _tool_dict(tool) -> dict:
78
+ desc = tool.description or "(no description)"
79
+ icon, risk = classify_tool(tool.name, desc)
80
+ return {"name": tool.name, "description": _normalize_text(desc), "risk": risk, "icon": icon}
81
+
82
+
83
+ def _prompt_dict(prompt) -> dict:
84
+ args = []
85
+ if hasattr(prompt, "arguments") and prompt.arguments:
86
+ args = [a.name for a in prompt.arguments]
87
+ desc = getattr(prompt, "description", None)
88
+ return {
89
+ "name": prompt.name,
90
+ "arguments": args,
91
+ "description": _normalize_text(desc) if desc else None,
92
+ }
93
+
94
+
95
+ SUSPICIOUS_PATTERNS: list[tuple[str, re.Pattern]] = [
96
+ ("prompt injection phrase", re.compile(r"\b(ignore|disregard)\b.*\b(instructions|system|developer)\b", re.I)),
97
+ ("secret exfiltration", re.compile(r"\b(exfiltrat|steal|leak)\w*\b", re.I)),
98
+ ("do not tell user", re.compile(r"\b(don't|do not)\b.*\b(tell|mention|reveal)\b.*\b(user)\b", re.I)),
99
+ ("system prompt mention", re.compile(r"\b(system prompt|developer message)\b", re.I)),
100
+ # base64 shows up in benign contexts (e.g. image tools), so keep this focused on actual key material.
101
+ ("encoded secret material", re.compile(r"\bBEGIN [A-Z ]+ KEY\b", re.I)),
102
+ ("shell download hint", re.compile(r"\b(curl|wget)\b\s+https?://", re.I)),
103
+ ]
104
+
105
+
106
+ def collect_signals(
107
+ tools: list[dict], resource_uris: list[str], template_uris: list[str], prompts: list[dict]
108
+ ) -> list[dict]:
109
+ signals: list[dict] = []
110
+
111
+ def scan(kind: str, name: str, text: str):
112
+ for label, pat in SUSPICIOUS_PATTERNS:
113
+ if pat.search(text):
114
+ signals.append(
115
+ {
116
+ "kind": kind,
117
+ "name": name,
118
+ "rule": label,
119
+ "snippet": text[:200] + ("..." if len(text) > 200 else ""),
120
+ }
121
+ )
122
+
123
+ for t in tools:
124
+ scan("tool", t["name"], f'{t["name"]} {t["description"]}')
125
+ for uri in resource_uris:
126
+ scan("resource", uri, uri)
127
+ for uri in template_uris:
128
+ scan("resource_template", uri, uri)
129
+ for p in prompts:
130
+ text = f'{p["name"]} {" ".join(p.get("arguments") or [])} {p.get("description") or ""}'.strip()
131
+ scan("prompt", p["name"], text)
132
+
133
+ # Stable ordering for screenshots/diffs
134
+ signals.sort(key=lambda s: (s.get("kind", ""), s.get("name", ""), s.get("rule", "")))
135
+ return signals
136
+
137
+
138
+ # ── Output formatting ────────────────────────────────────────
139
+
140
+ def print_header(server_name: str, protocol_version: str):
141
+ print(f"{server_name} (MCP {protocol_version})\n")
142
+
143
+
144
+ def print_tools(tools: list[dict]):
145
+ if not tools:
146
+ print(" Tools: none\n")
147
+ return
148
+
149
+ name_width = min(max(len(t["name"]) for t in tools), 28)
150
+ term_width = shutil.get_terminal_size(fallback=(100, 20)).columns
151
+
152
+ print(" Tools:")
153
+ for tool in tools:
154
+ icon = tool["icon"]
155
+ desc = tool["description"].replace('"', '\\"')
156
+
157
+ prefix = f' {icon} {tool["name"]:<{name_width}} '
158
+ quote_prefix = prefix + '"'
159
+ cont_prefix = " " * len(quote_prefix)
160
+ available = max(20, term_width - len(quote_prefix) - 1) # -1 for closing quote
161
+
162
+ wrapped = textwrap.wrap(desc, width=available) or [""]
163
+ if len(wrapped) == 1:
164
+ print(f'{quote_prefix}{wrapped[0]}"')
165
+ else:
166
+ print(f"{quote_prefix}{wrapped[0]}")
167
+ for line in wrapped[1:-1]:
168
+ print(f"{cont_prefix}{line}")
169
+ print(f'{cont_prefix}{wrapped[-1]}"')
170
+ print()
171
+
172
+ print()
173
+
174
+
175
+ def print_resources(resources, templates):
176
+ has_any = resources or templates
177
+ if not has_any:
178
+ print(" Resources: none\n")
179
+ return
180
+
181
+ print(" Resources:")
182
+ for uri in sorted([r.uri for r in resources]):
183
+ print(f" 📄 {uri}")
184
+ for uri in sorted([t.uriTemplate for t in templates]):
185
+ print(f" 📄 {uri}")
186
+ print()
187
+
188
+
189
+ def print_prompts(prompts):
190
+ if not prompts:
191
+ print(" Prompts: none\n")
192
+ return
193
+
194
+ print(" Prompts:")
195
+ for p in sorted(prompts, key=lambda x: x.get("name", "")):
196
+ args = ""
197
+ if p.get("arguments"):
198
+ arg_names = p["arguments"]
199
+ args = f" ({', '.join(arg_names)})"
200
+ print(f" 💬 {p['name']}{args}")
201
+ print()
202
+
203
+
204
+ def print_signals(signals: list[dict]):
205
+ if not signals:
206
+ return
207
+ print(" Signals (heuristic):")
208
+ for s in signals:
209
+ name = s.get("name") or ""
210
+ rule = s.get("rule") or "signal"
211
+ print(f" ⚠️ {rule}: {s['kind']} {name}")
212
+ print(" (may be false positives/negatives)")
213
+ print()
214
+
215
+ def print_notes(notes: list[dict]):
216
+ if not notes:
217
+ return
218
+ print(" Notes:")
219
+ for n in notes:
220
+ rule = n.get("rule") or "note"
221
+ name = n.get("name") or ""
222
+ print(f" ℹ️ {rule}: {n.get('kind')} {name}")
223
+ print()
224
+
225
+
226
+ def print_risk_summary(counts: dict):
227
+ parts = []
228
+ if counts.get("write"):
229
+ parts.append(f"{counts['write']} write")
230
+ if counts.get("destructive"):
231
+ parts.append(f"{counts['destructive']} destructive")
232
+ if counts.get("read"):
233
+ parts.append(f"{counts['read']} read-only")
234
+
235
+ print(f" Risk: {', '.join(parts)}")
236
+ print()
237
+
238
+
239
+ # ── Main ─────────────────────────────────────────────────────
240
+
241
+ RISK_PRIORITY = {"destructive": 0, "write": 1, "read": 2}
242
+
243
+
244
+ def count_risks(tools: list[dict]) -> dict:
245
+ counts = {"read": 0, "write": 0, "destructive": 0}
246
+ for t in tools:
247
+ counts[t["risk"]] = counts.get(t["risk"], 0) + 1
248
+ return counts
249
+
250
+
251
+ def _contains_timeout(exc: BaseException) -> bool:
252
+ """Return True if exc (possibly an ExceptionGroup) contains a timeout."""
253
+ # In practice, timeouts often surface as cancellation inside anyio TaskGroups.
254
+ if isinstance(exc, (asyncio.TimeoutError, TimeoutError, asyncio.CancelledError)):
255
+ return True
256
+ # anyio cancellation/stream teardown frequently shows up as BrokenResourceError/ClosedResourceError.
257
+ if type(exc).__name__ in {"BrokenResourceError", "ClosedResourceError"}:
258
+ return True
259
+ # TimeoutError may be wrapped in an ExceptionGroup/BaseExceptionGroup.
260
+ if _BaseExceptionGroup is not None and isinstance(exc, _BaseExceptionGroup): # type: ignore[arg-type]
261
+ for sub in getattr(exc, "exceptions", ()):
262
+ if _contains_timeout(sub):
263
+ return True
264
+ return False
265
+
266
+
267
+ def _build_report(
268
+ *,
269
+ scanned_command: list[str],
270
+ server_name: str,
271
+ protocol_version: str,
272
+ tools: list[dict],
273
+ resource_uris: list[str],
274
+ template_uris: list[str],
275
+ prompts: list[dict],
276
+ signals: list[dict],
277
+ notes: list[dict],
278
+ risk: dict,
279
+ ) -> dict:
280
+ return {
281
+ "generatedAt": datetime.now(timezone.utc).isoformat(),
282
+ "scannedCommand": scanned_command,
283
+ "server": {"name": server_name, "protocolVersion": protocol_version},
284
+ "tools": tools,
285
+ "resources": resource_uris,
286
+ "resourceTemplates": template_uris,
287
+ "prompts": prompts,
288
+ "risk": risk,
289
+ "signals": signals,
290
+ "notes": notes,
291
+ }
292
+
293
+
294
+ async def inspect(
295
+ command: str,
296
+ args: list[str],
297
+ *,
298
+ emit_text: bool = True,
299
+ timeout_s: float = 10.0,
300
+ errlog: TextIO | None = None,
301
+ include_signals: bool = True,
302
+ ) -> dict:
303
+ server_params = StdioServerParameters(command=command, args=args)
304
+
305
+ async with stdio_client(server_params, errlog=errlog or sys.stderr) as (read_stream, write_stream):
306
+ async with ClientSession(read_stream, write_stream) as session:
307
+ result = await asyncio.wait_for(session.initialize(), timeout=timeout_s)
308
+
309
+ server_name = "unknown"
310
+ if hasattr(result, "serverInfo") and result.serverInfo:
311
+ server_name = result.serverInfo.name
312
+ protocol_version = getattr(result, "protocolVersion", "unknown")
313
+
314
+ # Tools (required)
315
+ tools_raw = (await asyncio.wait_for(session.list_tools(), timeout=timeout_s)).tools
316
+ tools = [_tool_dict(t) for t in tools_raw]
317
+ tools.sort(key=lambda t: (RISK_PRIORITY.get(t["risk"], 9), t["name"]))
318
+ risk = count_risks(tools)
319
+
320
+ # Resources (optional)
321
+ resources = []
322
+ templates = []
323
+ notes: list[dict] = []
324
+ try:
325
+ resources = (await asyncio.wait_for(session.list_resources(), timeout=timeout_s)).resources
326
+ except asyncio.TimeoutError:
327
+ notes.append(
328
+ {"kind": "mcp", "name": "list_resources", "rule": "timeout", "snippet": f"Timed out after {timeout_s}s"}
329
+ )
330
+ except Exception:
331
+ pass
332
+ try:
333
+ templates = (
334
+ await asyncio.wait_for(session.list_resource_templates(), timeout=timeout_s)
335
+ ).resourceTemplates
336
+ except asyncio.TimeoutError:
337
+ notes.append(
338
+ {
339
+ "kind": "mcp",
340
+ "name": "list_resource_templates",
341
+ "rule": "timeout",
342
+ "snippet": f"Timed out after {timeout_s}s",
343
+ }
344
+ )
345
+ except Exception:
346
+ pass
347
+ resource_uris = sorted([r.uri for r in resources])
348
+ template_uris = sorted([t.uriTemplate for t in templates])
349
+
350
+ # Prompts (optional)
351
+ prompts = []
352
+ try:
353
+ prompts = (await asyncio.wait_for(session.list_prompts(), timeout=timeout_s)).prompts
354
+ except asyncio.TimeoutError:
355
+ notes.append(
356
+ {"kind": "mcp", "name": "list_prompts", "rule": "timeout", "snippet": f"Timed out after {timeout_s}s"}
357
+ )
358
+ except Exception:
359
+ pass
360
+ prompts_info = [_prompt_dict(p) for p in prompts]
361
+ prompts_info.sort(key=lambda p: p.get("name", ""))
362
+
363
+ signals: list[dict] = []
364
+ if include_signals:
365
+ signals = collect_signals(tools, resource_uris, template_uris, prompts_info)
366
+
367
+ notes.sort(key=lambda n: (n.get("kind", ""), n.get("name", ""), n.get("rule", "")))
368
+
369
+ if emit_text:
370
+ print_header(server_name, protocol_version)
371
+ print(" Note: this runs the server locally; it does not sandbox the process.\n")
372
+ print_tools(tools)
373
+ print_resources(resources, templates)
374
+ print_prompts(prompts_info)
375
+ print_signals(signals)
376
+ print_notes(notes)
377
+ print_risk_summary(risk)
378
+
379
+ return _build_report(
380
+ scanned_command=[command, *args],
381
+ server_name=server_name,
382
+ protocol_version=protocol_version,
383
+ tools=tools,
384
+ resource_uris=resource_uris,
385
+ template_uris=template_uris,
386
+ prompts=prompts_info,
387
+ signals=signals,
388
+ notes=notes,
389
+ risk=risk,
390
+ )
391
+
392
+
393
+ def _diff_reports(before: dict, after: dict) -> str:
394
+ def tool_map(r: dict) -> dict[str, dict]:
395
+ return {t["name"]: t for t in r.get("tools", [])}
396
+
397
+ before_tools = tool_map(before)
398
+ after_tools = tool_map(after)
399
+ added = sorted(set(after_tools) - set(before_tools))
400
+ removed = sorted(set(before_tools) - set(after_tools))
401
+ changed_risk = sorted(
402
+ name
403
+ for name in (set(before_tools) & set(after_tools))
404
+ if before_tools[name].get("risk") != after_tools[name].get("risk")
405
+ )
406
+
407
+ def list_diff(before_list: list[str], after_list: list[str]) -> tuple[list[str], list[str]]:
408
+ return sorted(set(after_list) - set(before_list)), sorted(set(before_list) - set(after_list))
409
+
410
+ res_added, res_removed = list_diff(before.get("resources", []), after.get("resources", []))
411
+ tmpl_added, tmpl_removed = list_diff(before.get("resourceTemplates", []), after.get("resourceTemplates", []))
412
+
413
+ before_prompts = sorted(p.get("name") for p in before.get("prompts", []) if p.get("name"))
414
+ after_prompts = sorted(p.get("name") for p in after.get("prompts", []) if p.get("name"))
415
+ pr_added, pr_removed = list_diff(before_prompts, after_prompts)
416
+
417
+ def fmt_risk(r: dict) -> str:
418
+ rr = r.get("risk", {}) if isinstance(r, dict) else {}
419
+ return f'{rr.get("write", 0)} write, {rr.get("destructive", 0)} destructive, {rr.get("read", 0)} read-only'
420
+
421
+ lines: list[str] = []
422
+ lines.append("Diff\n")
423
+ lines.append(f' Before: {before.get("server", {}).get("name", "unknown")} ({fmt_risk(before)})')
424
+ lines.append(f' After: {after.get("server", {}).get("name", "unknown")} ({fmt_risk(after)})\n')
425
+
426
+ if added or removed or changed_risk:
427
+ lines.append(" Tools:")
428
+ for name in added:
429
+ lines.append(f' + {name} ({after_tools[name].get("risk")})')
430
+ for name in removed:
431
+ lines.append(f' - {name} ({before_tools[name].get("risk")})')
432
+ for name in changed_risk:
433
+ lines.append(f' ~ {name}: {before_tools[name].get("risk")} -> {after_tools[name].get("risk")}')
434
+ lines.append("")
435
+
436
+ if res_added or res_removed or tmpl_added or tmpl_removed:
437
+ lines.append(" Resources:")
438
+ for uri in res_added:
439
+ lines.append(f" + {uri}")
440
+ for uri in res_removed:
441
+ lines.append(f" - {uri}")
442
+ for uri in tmpl_added:
443
+ lines.append(f" + {uri}")
444
+ for uri in tmpl_removed:
445
+ lines.append(f" - {uri}")
446
+ lines.append("")
447
+
448
+ if pr_added or pr_removed:
449
+ lines.append(" Prompts:")
450
+ for name in pr_added:
451
+ lines.append(f" + {name}")
452
+ for name in pr_removed:
453
+ lines.append(f" - {name}")
454
+ lines.append("")
455
+
456
+ if not (added or removed or changed_risk or res_added or res_removed or tmpl_added or tmpl_removed or pr_added or pr_removed):
457
+ lines.append(" No changes detected.\n")
458
+
459
+ return "\n".join(lines).rstrip() + "\n"
460
+
461
+
462
+ def main():
463
+ if len(sys.argv) < 2:
464
+ print('Usage: mcp-preflight "uv run server.py"')
465
+ print(' mcp-preflight "npx my-mcp-server"')
466
+ print(' mcp-preflight "python /path/to/server.py"')
467
+ print(" mcp-preflight diff before.json after.json")
468
+ sys.exit(1)
469
+
470
+ if sys.argv[1] == "diff":
471
+ parser = argparse.ArgumentParser(prog="mcp-preflight diff", add_help=True)
472
+ parser.add_argument("before", type=Path)
473
+ parser.add_argument("after", type=Path)
474
+ ns = parser.parse_args(sys.argv[2:])
475
+
476
+ before = json.loads(ns.before.read_text(encoding="utf-8"))
477
+ after = json.loads(ns.after.read_text(encoding="utf-8"))
478
+ sys.stdout.write(_diff_reports(before, after))
479
+ return
480
+
481
+ parser = argparse.ArgumentParser(
482
+ prog="mcp-preflight",
483
+ add_help=True,
484
+ description="Inspect an MCP server's exposed capabilities (tools/resources/prompts).",
485
+ epilog="Note: this runs the server process locally; it does not sandbox the server.",
486
+ )
487
+ parser.add_argument("--json", action="store_true", dest="as_json", help="Print machine-readable JSON")
488
+ parser.add_argument("--save", type=Path, help="Save JSON report to a file")
489
+ parser.add_argument("--timeout", type=float, default=10.0, help="Timeout (seconds) for MCP calls (default: 10)")
490
+ parser.add_argument("--no-signals", action="store_true", help="Disable heuristic signal scanning/output")
491
+ vgroup = parser.add_mutually_exclusive_group()
492
+ vgroup.add_argument("--quiet", action="store_true", help="Suppress server stderr (even on failure)")
493
+ vgroup.add_argument("--verbose", action="store_true", help="Print server stderr (even on success)")
494
+ parser.add_argument("command", nargs=argparse.REMAINDER, help="Server command (quoted or split)")
495
+ ns = parser.parse_args(sys.argv[1:])
496
+
497
+ if not ns.command:
498
+ print('Usage: mcp-preflight "uv run server.py"')
499
+ sys.exit(1)
500
+
501
+ # Accept a single quoted command string (e.g. "uv run server.py") or split args (e.g. uv run server.py).
502
+ if len(ns.command) == 1:
503
+ parts = shlex.split(ns.command[0])
504
+ else:
505
+ parts = ns.command
506
+
507
+ if not parts:
508
+ print('Usage: mcp-preflight "uv run server.py"')
509
+ sys.exit(1)
510
+
511
+ command = parts[0]
512
+ args = parts[1:]
513
+
514
+ emit_text = not ns.as_json
515
+
516
+ errlog: TextIO
517
+ errbuf: TextIO | None = None
518
+ if ns.quiet:
519
+ errlog = open(os.devnull, "w")
520
+ else:
521
+ errbuf = tempfile.TemporaryFile(mode="w+", encoding="utf-8")
522
+ errlog = errbuf
523
+
524
+ try:
525
+ report = asyncio.run(
526
+ inspect(
527
+ command,
528
+ args,
529
+ emit_text=emit_text,
530
+ timeout_s=ns.timeout,
531
+ errlog=errlog,
532
+ include_signals=not ns.no_signals,
533
+ )
534
+ )
535
+ if ns.verbose and errbuf is not None and not ns.quiet:
536
+ errbuf.seek(0)
537
+ server_err = errbuf.read().strip()
538
+ if server_err:
539
+ sys.stderr.write("\n[server stderr]\n" + server_err + "\n")
540
+ except BaseException as e:
541
+ is_timeout = _contains_timeout(e)
542
+ server_err = ""
543
+ if errbuf is not None and not ns.quiet:
544
+ errbuf.seek(0)
545
+ server_err = errbuf.read().strip()
546
+ if server_err:
547
+ sys.stderr.write("\n[server stderr]\n" + server_err + "\n")
548
+ if not server_err:
549
+ sys.stderr.write(
550
+ "Hint: if the server writes logs to stdout, it can break MCP stdio. Ensure server logs go to stderr.\n"
551
+ )
552
+ if is_timeout:
553
+ sys.stderr.write(f"mcp-preflight: timed out after {ns.timeout}s\n")
554
+ else:
555
+ sys.stderr.write(f"mcp-preflight: error: {e}\n")
556
+ raise SystemExit(1)
557
+ finally:
558
+ try:
559
+ errlog.close()
560
+ except Exception:
561
+ pass
562
+
563
+ if ns.save:
564
+ ns.save.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
565
+
566
+ if ns.as_json:
567
+ sys.stdout.write(json.dumps(report, indent=2, sort_keys=True) + "\n")
568
+
569
+
570
+ if __name__ == "__main__":
571
+ main()
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mcp-preflight"
7
+ version = "0.1.0"
8
+ description = "See what an MCP server exposes before you trust or connect it."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {file = "LICENSE"}
12
+ authors = [{name = "jordanstarrk"}]
13
+ keywords = ["mcp", "security", "cli", "ai", "agents"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Security",
27
+ "Topic :: Utilities",
28
+ ]
29
+ dependencies = [
30
+ "mcp>=1.26.0",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/jordanstarrk/mcp-preflight"
35
+ Repository = "https://github.com/jordanstarrk/mcp-preflight"
36
+ Issues = "https://github.com/jordanstarrk/mcp-preflight/issues"
37
+ Changelog = "https://github.com/jordanstarrk/mcp-preflight/releases"
38
+
39
+ [project.scripts]
40
+ mcp-preflight = "mcp_preflight:main"
41
+
42
+ [tool.setuptools]
43
+ py-modules = ["mcp_preflight"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+