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.
- mcp_lighthouse-0.1.0/.gitignore +6 -0
- mcp_lighthouse-0.1.0/LICENSE +21 -0
- mcp_lighthouse-0.1.0/PKG-INFO +146 -0
- mcp_lighthouse-0.1.0/README.md +130 -0
- mcp_lighthouse-0.1.0/mcp_lighthouse/__init__.py +3 -0
- mcp_lighthouse-0.1.0/mcp_lighthouse/checks.py +294 -0
- mcp_lighthouse-0.1.0/mcp_lighthouse/cli.py +54 -0
- mcp_lighthouse-0.1.0/mcp_lighthouse/reporter.py +151 -0
- mcp_lighthouse-0.1.0/mcp_lighthouse/transport.py +164 -0
- mcp_lighthouse-0.1.0/pyproject.toml +22 -0
|
@@ -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,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"
|