mcp-lighthouse 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,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
6
+ GEMINI.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Makito Chiba
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,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-lighthouse
3
+ Version: 0.1.0
4
+ Summary: Audit tool for MCP servers — test protocol compliance, schema quality, and robustness
5
+ Author: Makito Chiba
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: ai-agent,audit,mcp,model-context-protocol,testing
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Topic :: Software Development :: Testing
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: rich>=13.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ # MCP Lighthouse
18
+
19
+ Audit tool for [MCP](https://modelcontextprotocol.io) servers. Run 21 automated checks across 5 dimensions and get a compliance score — like Lighthouse, but for your MCP server.
20
+
21
+ ```
22
+ MCP Lighthouse — my-server v1.0
23
+
24
+ Protocol ████████████████████ 100
25
+ Schema ██████████████░░░░░░ 70
26
+ Robustness ████████████████░░░░ 75
27
+ Practices ██████████░░░░░░░░░░ 50
28
+ Performance ████████████████████ 100
29
+
30
+ Overall Score: 83/100
31
+
32
+ 21 checks: 17 passed, 2 warnings, 2 failed
33
+ ```
34
+
35
+ ## Why
36
+
37
+ You built an MCP server. It works in Claude Code. But does it:
38
+ - Return proper JSON-RPC 2.0 responses?
39
+ - Include `inputSchema` on every tool?
40
+ - Handle invalid tool names without crashing?
41
+ - Respond to `initialize` within a reasonable time?
42
+
43
+ MCP Lighthouse tests all of this automatically.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install mcp-lighthouse
49
+ ```
50
+
51
+ Or from source:
52
+
53
+ ```bash
54
+ git clone https://github.com/MakiDevelop/mcp-lighthouse.git
55
+ cd mcp-lighthouse
56
+ pip install -e .
57
+ ```
58
+
59
+ ## Quick Start
60
+
61
+ ```bash
62
+ # Audit an MCP server via stdio
63
+ mcp-lighthouse scan --stdio "python my_server.py"
64
+ mcp-lighthouse scan --stdio "npx @modelcontextprotocol/server-filesystem /"
65
+
66
+ # Only run specific category
67
+ mcp-lighthouse scan --stdio "python my_server.py" --category protocol
68
+
69
+ # Export markdown report
70
+ mcp-lighthouse scan --stdio "python my_server.py" --report audit.md
71
+
72
+ # List all checks
73
+ mcp-lighthouse list
74
+ ```
75
+
76
+ ## Checks (21 total)
77
+
78
+ ### Protocol Compliance (5 checks, 40% weight) — critical
79
+
80
+ | Check | What it tests |
81
+ |-------|---------------|
82
+ | `proto-init` | Server responds to `initialize` with valid protocolVersion + capabilities + serverInfo |
83
+ | `proto-init-version` | protocolVersion is a known version (2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25) |
84
+ | `proto-jsonrpc-version` | All responses include `"jsonrpc": "2.0"` |
85
+ | `proto-id-match` | Response `id` matches request `id` |
86
+ | `proto-error-format` | Error responses have `code` (int) + `message` (string) |
87
+
88
+ ### Schema Quality (6 checks, 25% weight) — warning
89
+
90
+ | Check | What it tests |
91
+ |-------|---------------|
92
+ | `schema-tools-list` | `tools/list` returns non-empty tools array |
93
+ | `schema-tool-name` | Every tool has a non-empty `name` |
94
+ | `schema-tool-description` | Every tool has a description (>10 chars) |
95
+ | `schema-tool-input-schema` | Every tool has `inputSchema` with `type: "object"` |
96
+ | `schema-required-fields` | `inputSchema` with properties has a `required` array |
97
+ | `schema-no-duplicate-tools` | No duplicate tool names |
98
+
99
+ ### Robustness (4 checks, 20% weight) — warning
100
+
101
+ | Check | What it tests |
102
+ |-------|---------------|
103
+ | `robust-unknown-method` | Server returns `-32601` for unknown method |
104
+ | `robust-invalid-tool` | `tools/call` with non-existent tool returns error (not crash) |
105
+ | `robust-missing-args` | `tools/call` with missing required args returns error |
106
+ | `robust-malformed-json` | Server handles malformed JSON without crashing |
107
+
108
+ ### Best Practices (4 checks, 10% weight) — info
109
+
110
+ | Check | What it tests |
111
+ |-------|---------------|
112
+ | `bp-tool-name-format` | Tool names use snake_case or kebab-case |
113
+ | `bp-description-length` | Tool descriptions are 20-500 chars |
114
+ | `bp-server-info` | `serverInfo` includes both `name` and `version` |
115
+ | `bp-capabilities-declared` | Server declares at least one capability |
116
+
117
+ ### Performance (2 checks, 5% weight) — info
118
+
119
+ | Check | What it tests |
120
+ |-------|---------------|
121
+ | `perf-init-time` | `initialize` completes in < 5 seconds |
122
+ | `perf-tools-list-time` | `tools/list` responds in < 3 seconds |
123
+
124
+ ## Scoring
125
+
126
+ - **Overall**: Weighted average of category scores (protocol 40%, schema 25%, robustness 20%, practices 10%, performance 5%)
127
+ - **Per category**: (passed checks / total checks) * 100
128
+ - A single critical failure in Protocol drops that category to 0
129
+
130
+ ## CLI Reference
131
+
132
+ ```
133
+ mcp-lighthouse scan [OPTIONS]
134
+ --stdio COMMAND Server command to spawn (required for now)
135
+ --category CATEGORY Only run checks in this category
136
+ --timeout SECONDS Per-check timeout (default: 10)
137
+ --verbose Show detailed output
138
+ --report PATH Write markdown report
139
+
140
+ mcp-lighthouse list
141
+ Lists all available checks
142
+ ```
143
+
144
+ ## License
145
+
146
+ MIT
@@ -0,0 +1,130 @@
1
+ # MCP Lighthouse
2
+
3
+ Audit tool for [MCP](https://modelcontextprotocol.io) servers. Run 21 automated checks across 5 dimensions and get a compliance score — like Lighthouse, but for your MCP server.
4
+
5
+ ```
6
+ MCP Lighthouse — my-server v1.0
7
+
8
+ Protocol ████████████████████ 100
9
+ Schema ██████████████░░░░░░ 70
10
+ Robustness ████████████████░░░░ 75
11
+ Practices ██████████░░░░░░░░░░ 50
12
+ Performance ████████████████████ 100
13
+
14
+ Overall Score: 83/100
15
+
16
+ 21 checks: 17 passed, 2 warnings, 2 failed
17
+ ```
18
+
19
+ ## Why
20
+
21
+ You built an MCP server. It works in Claude Code. But does it:
22
+ - Return proper JSON-RPC 2.0 responses?
23
+ - Include `inputSchema` on every tool?
24
+ - Handle invalid tool names without crashing?
25
+ - Respond to `initialize` within a reasonable time?
26
+
27
+ MCP Lighthouse tests all of this automatically.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install mcp-lighthouse
33
+ ```
34
+
35
+ Or from source:
36
+
37
+ ```bash
38
+ git clone https://github.com/MakiDevelop/mcp-lighthouse.git
39
+ cd mcp-lighthouse
40
+ pip install -e .
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```bash
46
+ # Audit an MCP server via stdio
47
+ mcp-lighthouse scan --stdio "python my_server.py"
48
+ mcp-lighthouse scan --stdio "npx @modelcontextprotocol/server-filesystem /"
49
+
50
+ # Only run specific category
51
+ mcp-lighthouse scan --stdio "python my_server.py" --category protocol
52
+
53
+ # Export markdown report
54
+ mcp-lighthouse scan --stdio "python my_server.py" --report audit.md
55
+
56
+ # List all checks
57
+ mcp-lighthouse list
58
+ ```
59
+
60
+ ## Checks (21 total)
61
+
62
+ ### Protocol Compliance (5 checks, 40% weight) — critical
63
+
64
+ | Check | What it tests |
65
+ |-------|---------------|
66
+ | `proto-init` | Server responds to `initialize` with valid protocolVersion + capabilities + serverInfo |
67
+ | `proto-init-version` | protocolVersion is a known version (2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25) |
68
+ | `proto-jsonrpc-version` | All responses include `"jsonrpc": "2.0"` |
69
+ | `proto-id-match` | Response `id` matches request `id` |
70
+ | `proto-error-format` | Error responses have `code` (int) + `message` (string) |
71
+
72
+ ### Schema Quality (6 checks, 25% weight) — warning
73
+
74
+ | Check | What it tests |
75
+ |-------|---------------|
76
+ | `schema-tools-list` | `tools/list` returns non-empty tools array |
77
+ | `schema-tool-name` | Every tool has a non-empty `name` |
78
+ | `schema-tool-description` | Every tool has a description (>10 chars) |
79
+ | `schema-tool-input-schema` | Every tool has `inputSchema` with `type: "object"` |
80
+ | `schema-required-fields` | `inputSchema` with properties has a `required` array |
81
+ | `schema-no-duplicate-tools` | No duplicate tool names |
82
+
83
+ ### Robustness (4 checks, 20% weight) — warning
84
+
85
+ | Check | What it tests |
86
+ |-------|---------------|
87
+ | `robust-unknown-method` | Server returns `-32601` for unknown method |
88
+ | `robust-invalid-tool` | `tools/call` with non-existent tool returns error (not crash) |
89
+ | `robust-missing-args` | `tools/call` with missing required args returns error |
90
+ | `robust-malformed-json` | Server handles malformed JSON without crashing |
91
+
92
+ ### Best Practices (4 checks, 10% weight) — info
93
+
94
+ | Check | What it tests |
95
+ |-------|---------------|
96
+ | `bp-tool-name-format` | Tool names use snake_case or kebab-case |
97
+ | `bp-description-length` | Tool descriptions are 20-500 chars |
98
+ | `bp-server-info` | `serverInfo` includes both `name` and `version` |
99
+ | `bp-capabilities-declared` | Server declares at least one capability |
100
+
101
+ ### Performance (2 checks, 5% weight) — info
102
+
103
+ | Check | What it tests |
104
+ |-------|---------------|
105
+ | `perf-init-time` | `initialize` completes in < 5 seconds |
106
+ | `perf-tools-list-time` | `tools/list` responds in < 3 seconds |
107
+
108
+ ## Scoring
109
+
110
+ - **Overall**: Weighted average of category scores (protocol 40%, schema 25%, robustness 20%, practices 10%, performance 5%)
111
+ - **Per category**: (passed checks / total checks) * 100
112
+ - A single critical failure in Protocol drops that category to 0
113
+
114
+ ## CLI Reference
115
+
116
+ ```
117
+ mcp-lighthouse scan [OPTIONS]
118
+ --stdio COMMAND Server command to spawn (required for now)
119
+ --category CATEGORY Only run checks in this category
120
+ --timeout SECONDS Per-check timeout (default: 10)
121
+ --verbose Show detailed output
122
+ --report PATH Write markdown report
123
+
124
+ mcp-lighthouse list
125
+ Lists all available checks
126
+ ```
127
+
128
+ ## License
129
+
130
+ MIT
@@ -0,0 +1,3 @@
1
+ """MCP Lighthouse — audit tool for MCP servers."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,294 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any, Awaitable, Callable
6
+
7
+ from .transport import JsonRpcError, TransportError
8
+
9
+
10
+ KNOWN_PROTOCOL_VERSIONS = {"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"}
11
+
12
+
13
+ @dataclass
14
+ class CheckResult:
15
+ check_id: str
16
+ name: str
17
+ category: str
18
+ severity: str
19
+ passed: bool
20
+ message: str
21
+ details: str = ""
22
+ elapsed_ms: float = 0
23
+
24
+
25
+ @dataclass
26
+ class CheckInfo:
27
+ check_id: str
28
+ name: str
29
+ category: str
30
+ severity: str
31
+ func: Callable[[Any], Awaitable[CheckResult]]
32
+
33
+
34
+ _checks: list[CheckInfo] = []
35
+
36
+
37
+ def check(id: str, name: str, category: str, severity: str):
38
+ def decorator(func: Callable[[Any], Awaitable[CheckResult]]):
39
+ _checks.append(CheckInfo(id, name, category, severity, func))
40
+ return func
41
+
42
+ return decorator
43
+
44
+
45
+ async def run_all_checks(transport: Any, categories: list[str] | None = None) -> list[CheckResult]:
46
+ selected = set(categories or [])
47
+ results: list[CheckResult] = []
48
+ for info in _checks:
49
+ if selected and info.category not in selected:
50
+ continue
51
+ if not transport.is_running():
52
+ results.append(CheckResult(
53
+ info.check_id, info.name, info.category, info.severity,
54
+ False, "Skipped: server process died", details="subprocess_dead",
55
+ ))
56
+ continue
57
+ start = _now_ms()
58
+ try:
59
+ result = await info.func(transport)
60
+ if not result.elapsed_ms:
61
+ result.elapsed_ms = _now_ms() - start
62
+ except Exception as exc:
63
+ result = CheckResult(
64
+ info.check_id,
65
+ info.name,
66
+ info.category,
67
+ info.severity,
68
+ False,
69
+ f"Check failed: {exc}",
70
+ details=type(exc).__name__,
71
+ elapsed_ms=_now_ms() - start,
72
+ )
73
+ results.append(result)
74
+ return results
75
+
76
+
77
+ def all_checks() -> list[CheckInfo]:
78
+ return list(_checks)
79
+
80
+
81
+ def _now_ms() -> float:
82
+ import time
83
+
84
+ return time.perf_counter() * 1000
85
+
86
+
87
+ def _result(info_id: str, passed: bool, message: str, details: str = "", elapsed_ms: float = 0) -> CheckResult:
88
+ info = next(item for item in _checks if item.check_id == info_id)
89
+ return CheckResult(info.check_id, info.name, info.category, info.severity, passed, message, details, elapsed_ms)
90
+
91
+
92
+ async def _tools(transport: Any) -> list[dict[str, Any]]:
93
+ if not hasattr(transport, "_tools_cache"):
94
+ result = await transport.send_request("tools/list")
95
+ tools = result.get("tools")
96
+ if not isinstance(tools, list):
97
+ raise TransportError("tools/list result.tools is not an array")
98
+ transport._tools_cache = [tool for tool in tools if isinstance(tool, dict)]
99
+ return transport._tools_cache
100
+
101
+
102
+ @check("proto-init", "Initialize response", "protocol", "critical")
103
+ async def proto_init(transport: Any) -> CheckResult:
104
+ result = await transport.initialize()
105
+ ok = (
106
+ isinstance(result.get("protocolVersion"), str)
107
+ and isinstance(result.get("capabilities"), dict)
108
+ and isinstance(result.get("serverInfo"), dict)
109
+ )
110
+ return _result("proto-init", ok, "Server initializes correctly" if ok else "Initialize response is missing required fields")
111
+
112
+
113
+ @check("proto-init-version", "Protocol version", "protocol", "critical")
114
+ async def proto_init_version(transport: Any) -> CheckResult:
115
+ result = await transport.initialize()
116
+ version = result.get("protocolVersion")
117
+ ok = version in KNOWN_PROTOCOL_VERSIONS
118
+ return _result("proto-init-version", ok, f"Protocol version {version} is known" if ok else f"Unknown protocol version: {version}")
119
+
120
+
121
+ @check("proto-jsonrpc-version", "JSON-RPC version", "protocol", "critical")
122
+ async def proto_jsonrpc_version(transport: Any) -> CheckResult:
123
+ await transport.send_request("tools/list")
124
+ bad = [resp for resp in transport.response_history if resp.get("jsonrpc") != "2.0"]
125
+ return _result("proto-jsonrpc-version", not bad, "All responses use JSON-RPC 2.0" if not bad else f"{len(bad)} responses missing jsonrpc 2.0")
126
+
127
+
128
+ @check("proto-id-match", "Response ID matching", "protocol", "critical")
129
+ async def proto_id_match(transport: Any) -> CheckResult:
130
+ await transport.send_request("tools/list")
131
+ response = transport.last_response or {}
132
+ ok = response.get("id") == transport.last_request_id
133
+ return _result("proto-id-match", ok, "Response id matches request id" if ok else "Response id does not match request id")
134
+
135
+
136
+ @check("proto-error-format", "Error format", "protocol", "critical")
137
+ async def proto_error_format(transport: Any) -> CheckResult:
138
+ try:
139
+ await transport.send_request("lighthouse/unknown")
140
+ except JsonRpcError as exc:
141
+ error = exc.error
142
+ ok = isinstance(error.get("code"), int) and isinstance(error.get("message"), str)
143
+ return _result("proto-error-format", ok, "Error response has code and message" if ok else "Error response has invalid shape")
144
+ return _result("proto-error-format", False, "Unknown method did not return a JSON-RPC error")
145
+
146
+
147
+ @check("schema-tools-list", "Tools list", "schema", "warning")
148
+ async def schema_tools_list(transport: Any) -> CheckResult:
149
+ tools = await _tools(transport)
150
+ return _result("schema-tools-list", bool(tools), f"tools/list returned {len(tools)} tools" if tools else "tools/list returned no tools")
151
+
152
+
153
+ @check("schema-tool-name", "Tool names present", "schema", "warning")
154
+ async def schema_tool_name(transport: Any) -> CheckResult:
155
+ tools = await _tools(transport)
156
+ bad = [tool for tool in tools if not isinstance(tool.get("name"), str) or not tool["name"].strip()]
157
+ return _result("schema-tool-name", not bad, "Every tool has a non-empty name" if not bad else f"{len(bad)} tools have missing names")
158
+
159
+
160
+ @check("schema-tool-description", "Tool descriptions present", "schema", "warning")
161
+ async def schema_tool_description(transport: Any) -> CheckResult:
162
+ tools = await _tools(transport)
163
+ bad = [tool for tool in tools if not isinstance(tool.get("description"), str) or len(tool["description"].strip()) <= 10]
164
+ return _result("schema-tool-description", not bad, "Every tool has a useful description" if not bad else f"{len(bad)} tools have short or missing descriptions")
165
+
166
+
167
+ @check("schema-tool-input-schema", "Tool input schemas", "schema", "warning")
168
+ async def schema_tool_input_schema(transport: Any) -> CheckResult:
169
+ tools = await _tools(transport)
170
+ bad = [tool for tool in tools if not isinstance(tool.get("inputSchema"), dict) or tool["inputSchema"].get("type") != "object"]
171
+ return _result("schema-tool-input-schema", not bad, "Every tool inputSchema is an object" if not bad else f"{len(bad)} tools have invalid inputSchema")
172
+
173
+
174
+ @check("schema-required-fields", "Required fields", "schema", "warning")
175
+ async def schema_required_fields(transport: Any) -> CheckResult:
176
+ tools = await _tools(transport)
177
+ bad = []
178
+ for tool in tools:
179
+ schema = tool.get("inputSchema")
180
+ if isinstance(schema, dict) and schema.get("properties") and "required" not in schema:
181
+ bad.append(tool.get("name", "<unnamed>"))
182
+ return _result("schema-required-fields", not bad, "Schemas with properties declare required fields" if not bad else f"{len(bad)} schemas with properties omit required")
183
+
184
+
185
+ @check("schema-no-duplicate-tools", "No duplicate tools", "schema", "warning")
186
+ async def schema_no_duplicate_tools(transport: Any) -> CheckResult:
187
+ tools = await _tools(transport)
188
+ names: list[str] = [tool["name"] for tool in tools if isinstance(tool.get("name"), str)]
189
+ duplicates = sorted(n for n in set(names) if names.count(n) > 1)
190
+ return _result("schema-no-duplicate-tools", not duplicates, "No duplicate tool names" if not duplicates else f"Duplicate tool names: {', '.join(duplicates)}")
191
+
192
+
193
+ @check("robust-unknown-method", "Unknown method handling", "robustness", "warning")
194
+ async def robust_unknown_method(transport: Any) -> CheckResult:
195
+ try:
196
+ await transport.send_request("foo/bar")
197
+ except JsonRpcError as exc:
198
+ ok = exc.error.get("code") == -32601
199
+ return _result("robust-unknown-method", ok, "Unknown method returns -32601" if ok else f"Unknown method returned {exc.error.get('code')}")
200
+ return _result("robust-unknown-method", False, "Unknown method did not return an error")
201
+
202
+
203
+ @check("robust-invalid-tool", "Invalid tool handling", "robustness", "warning")
204
+ async def robust_invalid_tool(transport: Any) -> CheckResult:
205
+ try:
206
+ await transport.send_request("tools/call", {"name": "__mcp_lighthouse_missing_tool__", "arguments": {}})
207
+ except JsonRpcError:
208
+ return _result("robust-invalid-tool", transport.is_running(), "Invalid tool returns an error without crashing")
209
+ except TransportError as exc:
210
+ return _result("robust-invalid-tool", False, f"Invalid tool broke transport: {exc}")
211
+ return _result("robust-invalid-tool", False, "Invalid tool unexpectedly succeeded")
212
+
213
+
214
+ @check("robust-missing-args", "Missing arguments handling", "robustness", "warning")
215
+ async def robust_missing_args(transport: Any) -> CheckResult:
216
+ tools = await _tools(transport)
217
+ candidate = None
218
+ for tool in tools:
219
+ schema = tool.get("inputSchema")
220
+ if isinstance(schema, dict) and schema.get("required"):
221
+ candidate = tool
222
+ break
223
+ if candidate is None:
224
+ return _result("robust-missing-args", True, "No tool with required arguments found")
225
+ try:
226
+ await transport.send_request("tools/call", {"name": candidate.get("name"), "arguments": {}})
227
+ except JsonRpcError:
228
+ return _result("robust-missing-args", transport.is_running(), "Missing required arguments return an error")
229
+ return _result("robust-missing-args", False, f"{candidate.get('name')} accepted missing required arguments")
230
+
231
+
232
+ @check("robust-malformed-json", "Malformed JSON handling", "robustness", "warning")
233
+ async def robust_malformed_json(transport: Any) -> CheckResult:
234
+ await transport.send_raw_line('{"jsonrpc":"2.0","id":999,"method":')
235
+ try:
236
+ response = await transport.read_raw_response(timeout=1)
237
+ except TransportError:
238
+ ok = transport.is_running()
239
+ return _result("robust-malformed-json", ok, "Malformed JSON did not crash server" if ok else "Server stopped after malformed JSON")
240
+ error = response.get("error") if response else None
241
+ ok = isinstance(error, dict) and error.get("code") == -32700
242
+ return _result("robust-malformed-json", ok, "Malformed JSON returns parse error" if ok else "Malformed JSON response is not a parse error")
243
+
244
+
245
+ @check("bp-tool-name-format", "Tool name format", "best_practices", "info")
246
+ async def bp_tool_name_format(transport: Any) -> CheckResult:
247
+ tools = await _tools(transport)
248
+ pattern = re.compile(r"^[a-z0-9]+([_-][a-z0-9]+)*$")
249
+ bad = [tool.get("name", "") for tool in tools if not pattern.match(str(tool.get("name", "")))]
250
+ return _result("bp-tool-name-format", not bad, "Tool names use snake_case or kebab-case" if not bad else f"Non-standard tool names: {', '.join(bad)}")
251
+
252
+
253
+ @check("bp-description-length", "Description length", "best_practices", "info")
254
+ async def bp_description_length(transport: Any) -> CheckResult:
255
+ tools = await _tools(transport)
256
+ bad = []
257
+ for tool in tools:
258
+ description = tool.get("description")
259
+ if not isinstance(description, str) or not 20 <= len(description.strip()) <= 500:
260
+ bad.append(str(tool.get("name", "<unnamed>")))
261
+ return _result("bp-description-length", not bad, "Tool descriptions are 20-500 chars" if not bad else f"{len(bad)} tool descriptions are outside 20-500 chars")
262
+
263
+
264
+ @check("bp-server-info", "Server info", "best_practices", "info")
265
+ async def bp_server_info(transport: Any) -> CheckResult:
266
+ result = await transport.initialize()
267
+ server_info = result.get("serverInfo")
268
+ ok = isinstance(server_info, dict) and bool(server_info.get("name")) and bool(server_info.get("version"))
269
+ return _result("bp-server-info", ok, "serverInfo includes name and version" if ok else "serverInfo should include name and version")
270
+
271
+
272
+ @check("bp-capabilities-declared", "Capabilities declared", "best_practices", "info")
273
+ async def bp_capabilities_declared(transport: Any) -> CheckResult:
274
+ result = await transport.initialize()
275
+ capabilities = result.get("capabilities")
276
+ ok = isinstance(capabilities, dict) and bool(capabilities)
277
+ return _result("bp-capabilities-declared", ok, "Server declares at least one capability" if ok else "Server declares no capabilities")
278
+
279
+
280
+ @check("perf-init-time", "Initialize time", "performance", "info")
281
+ async def perf_init_time(transport: Any) -> CheckResult:
282
+ await transport.initialize()
283
+ elapsed = transport.initialize_elapsed_ms
284
+ ok = elapsed < 5000
285
+ return _result("perf-init-time", ok, f"Initialize completed in {elapsed:.0f} ms", elapsed_ms=elapsed)
286
+
287
+
288
+ @check("perf-tools-list-time", "Tools list time", "performance", "info")
289
+ async def perf_tools_list_time(transport: Any) -> CheckResult:
290
+ start = _now_ms()
291
+ await transport.send_request("tools/list")
292
+ elapsed = _now_ms() - start
293
+ ok = elapsed < 3000
294
+ return _result("perf-tools-list-time", ok, f"tools/list completed in {elapsed:.0f} ms", elapsed_ms=elapsed)
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ from pathlib import Path
6
+
7
+ from .checks import all_checks, run_all_checks
8
+ from .reporter import render_markdown, render_terminal
9
+ from .transport import StdioTransport
10
+
11
+
12
+ VALID_CATEGORIES = ["protocol", "schema", "robustness", "best_practices", "performance"]
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser(prog="mcp-lighthouse")
17
+ subparsers = parser.add_subparsers(dest="command", required=True)
18
+
19
+ scan_parser = subparsers.add_parser("scan", help="scan an MCP server")
20
+ scan_parser.add_argument("--stdio", required=True, help="stdio command to launch the MCP server")
21
+ scan_parser.add_argument("--report", help="write a Markdown report to this path")
22
+ scan_parser.add_argument("--category", choices=VALID_CATEGORIES, action="append", help="run only this check category")
23
+ scan_parser.add_argument("--timeout", type=float, default=10, help="transport timeout in seconds")
24
+
25
+ subparsers.add_parser("list", help="list registered checks")
26
+
27
+ args = parser.parse_args()
28
+ if args.command == "list":
29
+ _list_checks()
30
+ return
31
+ if args.command == "scan":
32
+ asyncio.run(_scan(args))
33
+
34
+
35
+ def _list_checks() -> None:
36
+ for info in all_checks():
37
+ print(f"{info.check_id:<28} {info.category:<15} {info.severity:<8} {info.name}")
38
+
39
+
40
+ async def _scan(args: argparse.Namespace) -> None:
41
+ transport = StdioTransport(args.stdio, timeout=args.timeout)
42
+ try:
43
+ await transport.start()
44
+ await transport.initialize()
45
+ results = await run_all_checks(transport, categories=args.category)
46
+ render_terminal(results, transport.server_info)
47
+ if args.report:
48
+ Path(args.report).write_text(render_markdown(results, transport.server_info), encoding="utf-8")
49
+ finally:
50
+ await transport.close()
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from typing import Any
5
+
6
+ from .checks import CheckResult
7
+
8
+
9
+ CATEGORY_WEIGHTS = {
10
+ "protocol": 40,
11
+ "schema": 25,
12
+ "robustness": 20,
13
+ "best_practices": 10,
14
+ "performance": 5,
15
+ }
16
+
17
+ CATEGORY_LABELS = {
18
+ "protocol": "Protocol",
19
+ "schema": "Schema",
20
+ "robustness": "Robustness",
21
+ "best_practices": "Practices",
22
+ "performance": "Performance",
23
+ }
24
+
25
+
26
+ def category_scores(results: list[CheckResult]) -> dict[str, float]:
27
+ grouped: dict[str, list[CheckResult]] = defaultdict(list)
28
+ for result in results:
29
+ grouped[result.category].append(result)
30
+ scores: dict[str, float] = {}
31
+ for category in CATEGORY_WEIGHTS:
32
+ items = grouped.get(category, [])
33
+ scores[category] = 100.0 if not items else sum(1 for item in items if item.passed) / len(items) * 100
34
+ return scores
35
+
36
+
37
+ def overall_score(results: list[CheckResult]) -> int:
38
+ scores = category_scores(results)
39
+ executed = {r.category for r in results}
40
+ if not executed:
41
+ return 0
42
+ total = sum(CATEGORY_WEIGHTS[cat] for cat in executed if cat in CATEGORY_WEIGHTS)
43
+ if total == 0:
44
+ return 0
45
+ weighted = sum(scores[cat] * CATEGORY_WEIGHTS.get(cat, 0) for cat in executed) / total
46
+ return round(weighted)
47
+
48
+
49
+ def render_terminal(results: list[CheckResult], server_info: dict[str, Any] | None = None) -> None:
50
+ try:
51
+ from rich.console import Console
52
+ from rich.text import Text
53
+ except ImportError:
54
+ print(render_plain(results, server_info))
55
+ return
56
+
57
+ console = Console()
58
+ name = (server_info or {}).get("name", "unknown-server")
59
+ version = (server_info or {}).get("version", "unknown")
60
+ console.print(f"[bold]MCP Lighthouse[/bold] — {name} v{version}\n")
61
+
62
+ scores = category_scores(results)
63
+ for category, score in scores.items():
64
+ filled = round(score / 5)
65
+ bar = "█" * filled + "░" * (20 - filled)
66
+ console.print(f" {CATEGORY_LABELS[category]:<11} [cyan]{bar}[/cyan] {score:>3.0f}")
67
+
68
+ console.print(f"\n [bold]Overall Score:[/bold] {overall_score(results)}/100\n")
69
+
70
+ for result in results:
71
+ icon = "✅" if result.passed else ("❌" if result.severity == "critical" else "⚠️")
72
+ style = "green" if result.passed else ("red" if result.severity == "critical" else "yellow")
73
+ line = Text(f" {icon} {result.check_id:<26} {result.message}", style=style)
74
+ console.print(line)
75
+
76
+ passed = sum(1 for result in results if result.passed)
77
+ warnings = sum(1 for result in results if not result.passed and result.severity != "critical")
78
+ failed = sum(1 for result in results if not result.passed and result.severity == "critical")
79
+ console.print(f"\n {len(results)} checks: {passed} passed, {warnings} warnings, {failed} failed")
80
+
81
+
82
+ def render_plain(results: list[CheckResult], server_info: dict[str, Any] | None = None) -> str:
83
+ name = (server_info or {}).get("name", "unknown-server")
84
+ version = (server_info or {}).get("version", "unknown")
85
+ lines = [f"MCP Lighthouse — {name} v{version}", ""]
86
+ for category, score in category_scores(results).items():
87
+ filled = round(score / 5)
88
+ lines.append(f" {CATEGORY_LABELS[category]:<11} {'#' * filled}{'.' * (20 - filled)} {score:.0f}")
89
+ lines.append("")
90
+ lines.append(f" Overall Score: {overall_score(results)}/100")
91
+ lines.append("")
92
+ for result in results:
93
+ icon = "PASS" if result.passed else ("FAIL" if result.severity == "critical" else "WARN")
94
+ lines.append(f" {icon:<4} {result.check_id:<26} {result.message}")
95
+ return "\n".join(lines)
96
+
97
+
98
+ def render_markdown(results: list[CheckResult], server_info: dict[str, Any] | None = None) -> str:
99
+ name = (server_info or {}).get("name", "unknown-server")
100
+ version = (server_info or {}).get("version", "unknown")
101
+ lines = [
102
+ f"# MCP Lighthouse Report — {name} v{version}",
103
+ "",
104
+ f"Overall Score: **{overall_score(results)}/100**",
105
+ "",
106
+ "## Category Scores",
107
+ "",
108
+ "| Category | Score |",
109
+ "|---|---:|",
110
+ ]
111
+ for category, score in category_scores(results).items():
112
+ lines.append(f"| {CATEGORY_LABELS[category]} | {score:.0f} |")
113
+
114
+ lines.extend(
115
+ [
116
+ "",
117
+ "## Checks",
118
+ "",
119
+ "| Status | Check | Category | Severity | Message | Elapsed |",
120
+ "|---|---|---|---|---|---:|",
121
+ ]
122
+ )
123
+ for result in results:
124
+ status = "Passed" if result.passed else "Failed"
125
+ lines.append(
126
+ f"| {status} | `{result.check_id}` | {result.category} | {result.severity} | "
127
+ f"{_escape(result.message)} | {result.elapsed_ms:.0f} ms |"
128
+ )
129
+
130
+ failures = [result for result in results if not result.passed]
131
+ if failures:
132
+ lines.extend(["", "## Recommendations", ""])
133
+ for result in failures:
134
+ lines.append(f"- `{result.check_id}`: {_recommendation(result)}")
135
+
136
+ return "\n".join(lines) + "\n"
137
+
138
+
139
+ def _escape(value: str) -> str:
140
+ return value.replace("|", "\\|").replace("\n", " ")
141
+
142
+
143
+ def _recommendation(result: CheckResult) -> str:
144
+ recommendations = {
145
+ "protocol": "Fix JSON-RPC protocol handling before relying on this server in automated clients.",
146
+ "schema": "Tighten tool metadata and JSON Schema so clients can validate calls correctly.",
147
+ "robustness": "Return JSON-RPC errors for invalid input while keeping the server process alive.",
148
+ "best_practices": "Improve metadata quality for better discoverability and client compatibility.",
149
+ "performance": "Profile startup or request handling and remove avoidable blocking work.",
150
+ }
151
+ return recommendations.get(result.category, "Review the failing behavior and add a regression test.")
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import shlex
6
+ from typing import Any
7
+
8
+
9
+ class TransportError(RuntimeError):
10
+ pass
11
+
12
+
13
+ class JsonRpcError(TransportError):
14
+ def __init__(self, error: dict[str, Any], response: dict[str, Any] | None = None) -> None:
15
+ self.error = error
16
+ self.response = response or {}
17
+ message = error.get("message", "JSON-RPC error")
18
+ code = error.get("code", "unknown")
19
+ super().__init__(f"{code}: {message}")
20
+
21
+
22
+ class StdioTransport:
23
+ def __init__(self, command: str, timeout: float = 10) -> None:
24
+ self.command = command
25
+ self.timeout = timeout
26
+ self.process: asyncio.subprocess.Process | None = None
27
+ self._next_id = 1
28
+ self.last_request_id: int | None = None
29
+ self.last_response: dict[str, Any] | None = None
30
+ self.response_history: list[dict[str, Any]] = []
31
+ self.server_info: dict[str, Any] = {}
32
+ self.capabilities: dict[str, Any] = {}
33
+ self.initialize_result: dict[str, Any] | None = None
34
+ self.initialize_elapsed_ms: float = 0
35
+
36
+ async def start(self) -> None:
37
+ if self.process is not None:
38
+ return
39
+ args = shlex.split(self.command)
40
+ if not args:
41
+ raise TransportError("Empty stdio command")
42
+ self.process = await asyncio.create_subprocess_exec(
43
+ *args,
44
+ stdin=asyncio.subprocess.PIPE,
45
+ stdout=asyncio.subprocess.PIPE,
46
+ stderr=asyncio.subprocess.PIPE,
47
+ )
48
+
49
+ async def send_request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
50
+ request_id = self._next_id
51
+ self._next_id += 1
52
+ self.last_request_id = request_id
53
+ message: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method}
54
+ if params is not None:
55
+ message["params"] = params
56
+
57
+ await self._write_message(message)
58
+ response = await self._read_message()
59
+ self.last_response = response
60
+ self.response_history.append(response)
61
+
62
+ if response.get("id") != request_id:
63
+ raise TransportError(f"Response id mismatch: expected {request_id}, got {response.get('id')}")
64
+ if "error" in response:
65
+ error = response["error"]
66
+ if isinstance(error, dict):
67
+ raise JsonRpcError(error, response)
68
+ raise TransportError("JSON-RPC error field is not an object")
69
+ if "result" not in response:
70
+ raise TransportError("JSON-RPC response missing result")
71
+ result = response["result"]
72
+ if not isinstance(result, dict):
73
+ raise TransportError("JSON-RPC result is not an object")
74
+ return result
75
+
76
+ async def send_notification(self, method: str, params: dict[str, Any] | None = None) -> None:
77
+ message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
78
+ if params is not None:
79
+ message["params"] = params
80
+ await self._write_message(message)
81
+
82
+ async def initialize(self) -> dict[str, Any]:
83
+ if self.initialize_result is not None:
84
+ return self.initialize_result
85
+
86
+ start = asyncio.get_running_loop().time()
87
+ result = await self.send_request(
88
+ "initialize",
89
+ {
90
+ "protocolVersion": "2025-06-18",
91
+ "capabilities": {},
92
+ "clientInfo": {"name": "mcp-lighthouse", "version": "0.1.0"},
93
+ },
94
+ )
95
+ self.initialize_elapsed_ms = (asyncio.get_running_loop().time() - start) * 1000
96
+ self.initialize_result = result
97
+ self.capabilities = result.get("capabilities") if isinstance(result.get("capabilities"), dict) else {}
98
+ self.server_info = result.get("serverInfo") if isinstance(result.get("serverInfo"), dict) else {}
99
+ await self.send_notification("notifications/initialized")
100
+ return result
101
+
102
+ async def send_raw_line(self, line: str) -> None:
103
+ if not line.endswith("\n"):
104
+ line += "\n"
105
+ await self._ensure_started()
106
+ assert self.process is not None
107
+ if self.process.stdin is None:
108
+ raise TransportError("Subprocess stdin is unavailable")
109
+ self.process.stdin.write(line.encode("utf-8"))
110
+ await self.process.stdin.drain()
111
+
112
+ async def read_raw_response(self, timeout: float | None = None) -> dict[str, Any] | None:
113
+ response = await self._read_message(timeout=timeout)
114
+ self.last_response = response
115
+ self.response_history.append(response)
116
+ return response
117
+
118
+ def is_running(self) -> bool:
119
+ return self.process is not None and self.process.returncode is None
120
+
121
+ async def close(self) -> None:
122
+ if self.process is None:
123
+ return
124
+ if self.process.returncode is None:
125
+ self.process.terminate()
126
+ try:
127
+ await asyncio.wait_for(self.process.wait(), timeout=2)
128
+ except asyncio.TimeoutError:
129
+ self.process.kill()
130
+ await self.process.wait()
131
+ self.process = None
132
+
133
+ async def _ensure_started(self) -> None:
134
+ if self.process is None or self.process.returncode is not None:
135
+ self.process = None
136
+ await self.start()
137
+
138
+ async def _write_message(self, message: dict[str, Any]) -> None:
139
+ await self._ensure_started()
140
+ assert self.process is not None
141
+ if self.process.stdin is None:
142
+ raise TransportError("Subprocess stdin is unavailable")
143
+ data = json.dumps(message, separators=(",", ":")) + "\n"
144
+ self.process.stdin.write(data.encode("utf-8"))
145
+ await self.process.stdin.drain()
146
+
147
+ async def _read_message(self, timeout: float | None = None) -> dict[str, Any]:
148
+ await self._ensure_started()
149
+ assert self.process is not None
150
+ if self.process.stdout is None:
151
+ raise TransportError("Subprocess stdout is unavailable")
152
+ try:
153
+ line = await asyncio.wait_for(self.process.stdout.readline(), timeout=timeout or self.timeout)
154
+ except asyncio.TimeoutError as exc:
155
+ raise TransportError("Timed out waiting for JSON-RPC response") from exc
156
+ if not line:
157
+ raise TransportError("Subprocess closed stdout")
158
+ try:
159
+ response = json.loads(line.decode("utf-8"))
160
+ except json.JSONDecodeError as exc:
161
+ raise TransportError(f"Invalid JSON response: {line.decode('utf-8', errors='replace').strip()}") from exc
162
+ if not isinstance(response, dict):
163
+ raise TransportError("JSON-RPC response is not an object")
164
+ return response
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-lighthouse"
7
+ version = "0.1.0"
8
+ description = "Audit tool for MCP servers — test protocol compliance, schema quality, and robustness"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Makito Chiba" }]
13
+ keywords = ["mcp", "model-context-protocol", "testing", "audit", "ai-agent"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Topic :: Software Development :: Testing",
18
+ ]
19
+ dependencies = ["rich>=13.0", "httpx>=0.27"]
20
+
21
+ [project.scripts]
22
+ mcp-lighthouse = "mcp_lighthouse.cli:main"