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.
- mcp_preflight-0.1.0/LICENSE +21 -0
- mcp_preflight-0.1.0/PKG-INFO +161 -0
- mcp_preflight-0.1.0/README.md +110 -0
- mcp_preflight-0.1.0/mcp_preflight.egg-info/PKG-INFO +161 -0
- mcp_preflight-0.1.0/mcp_preflight.egg-info/SOURCES.txt +10 -0
- mcp_preflight-0.1.0/mcp_preflight.egg-info/dependency_links.txt +1 -0
- mcp_preflight-0.1.0/mcp_preflight.egg-info/entry_points.txt +2 -0
- mcp_preflight-0.1.0/mcp_preflight.egg-info/requires.txt +1 -0
- mcp_preflight-0.1.0/mcp_preflight.egg-info/top_level.txt +1 -0
- mcp_preflight-0.1.0/mcp_preflight.py +571 -0
- mcp_preflight-0.1.0/pyproject.toml +43 -0
- mcp_preflight-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|