greenlight-mcp 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.
- greenlight_mcp-0.1.0/LICENSE +21 -0
- greenlight_mcp-0.1.0/PKG-INFO +113 -0
- greenlight_mcp-0.1.0/README.md +88 -0
- greenlight_mcp-0.1.0/pyproject.toml +41 -0
- greenlight_mcp-0.1.0/setup.cfg +4 -0
- greenlight_mcp-0.1.0/src/greenlight/__init__.py +1 -0
- greenlight_mcp-0.1.0/src/greenlight/banner.py +22 -0
- greenlight_mcp-0.1.0/src/greenlight/cli.py +91 -0
- greenlight_mcp-0.1.0/src/greenlight/proxy.py +175 -0
- greenlight_mcp-0.1.0/src/greenlight/render.py +82 -0
- greenlight_mcp-0.1.0/src/greenlight_mcp.egg-info/PKG-INFO +113 -0
- greenlight_mcp-0.1.0/src/greenlight_mcp.egg-info/SOURCES.txt +17 -0
- greenlight_mcp-0.1.0/src/greenlight_mcp.egg-info/dependency_links.txt +1 -0
- greenlight_mcp-0.1.0/src/greenlight_mcp.egg-info/entry_points.txt +2 -0
- greenlight_mcp-0.1.0/src/greenlight_mcp.egg-info/requires.txt +7 -0
- greenlight_mcp-0.1.0/src/greenlight_mcp.egg-info/top_level.txt +1 -0
- greenlight_mcp-0.1.0/tests/test_npx_server.py +75 -0
- greenlight_mcp-0.1.0/tests/test_proxy_e2e.py +93 -0
- greenlight_mcp-0.1.0/tests/test_tail_follow.py +74 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ai-ward
|
|
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,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: greenlight-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: See what your MCP server is actually doing -- a transparent stdio proxy and live trace viewer for the Model Context Protocol.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/ai-ward/greenlight
|
|
7
|
+
Project-URL: Issues, https://github.com/ai-ward/greenlight/issues
|
|
8
|
+
Keywords: mcp,model-context-protocol,debugging,proxy,cli
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: rich>=13.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: mcp>=1.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
22
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
23
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
.-----.
|
|
28
|
+
| 🔴 |
|
|
29
|
+
| 🟡 |
|
|
30
|
+
| 🟢 |
|
|
31
|
+
'-----'
|
|
32
|
+
greenlight
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
# greenlight
|
|
36
|
+
|
|
37
|
+
See what your MCP server is actually doing.
|
|
38
|
+
|
|
39
|
+
A transparent stdio proxy for the Model Context Protocol. Point it at
|
|
40
|
+
your real server command instead of running that command directly, and
|
|
41
|
+
it relays every byte exactly as before -- while recording every JSON-RPC
|
|
42
|
+
message to a structured log you can watch live or replay.
|
|
43
|
+
|
|
44
|
+
Right now, if an MCP integration isn't working, you're debugging blind:
|
|
45
|
+
no visibility into what got sent, what came back, or why a call failed.
|
|
46
|
+
Greenlight exists to fix that.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install -e .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
(Not yet published to PyPI as `greenlight-mcp` -- install from source for now.)
|
|
55
|
+
|
|
56
|
+
## Use it
|
|
57
|
+
|
|
58
|
+
Wherever you'd normally configure a server command, wrap it:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
greenlight run -- npx -y @some/mcp-server
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
instead of
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx -y @some/mcp-server
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Every message that passes through gets logged to `./sessions/`. Watch it:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
greenlight tail # replay the most recent session
|
|
74
|
+
greenlight tail -f # follow a session that's still running
|
|
75
|
+
greenlight tail path/to/log.jsonl
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Trace output is colorized by status: green for a clean success, yellow
|
|
79
|
+
for a slow-but-fine call, red for anything that actually failed --
|
|
80
|
+
including MCP tool-level failures (`result.isError`), not just
|
|
81
|
+
transport-level JSON-RPC errors, which are a different thing and easy to
|
|
82
|
+
miss if you only check for the obvious one. See `notes/day1.md` for why
|
|
83
|
+
that distinction mattered enough to write a whole note about it.
|
|
84
|
+
|
|
85
|
+
## How it works
|
|
86
|
+
|
|
87
|
+
`greenlight run` spawns your real server as a subprocess and sits
|
|
88
|
+
between it and the real MCP client, relaying stdin/stdout on two
|
|
89
|
+
threads. Every line is parsed as JSON-RPC, correlated by request id
|
|
90
|
+
(so a response knows its own method name and latency), and written to a
|
|
91
|
+
JSONL file. The one rule the whole thing depends on: nothing but the
|
|
92
|
+
child process's actual bytes ever reaches Greenlight's own stdout --
|
|
93
|
+
logging and UI output only ever go to stderr or to disk. A single stray
|
|
94
|
+
print to stdout would corrupt the protocol stream the real client is
|
|
95
|
+
parsing.
|
|
96
|
+
|
|
97
|
+
## Status
|
|
98
|
+
|
|
99
|
+
- [x] `greenlight run` -- transparent proxy, validated end-to-end against
|
|
100
|
+
a real MCP server (not a mock)
|
|
101
|
+
- [x] `greenlight tail` -- live trace viewer, both static replay and
|
|
102
|
+
genuine live-follow (verified separately, not assumed)
|
|
103
|
+
- [x] Windows PATH resolution for `npx`-style commands
|
|
104
|
+
- [ ] Tested against a real npx-launched server (so far only validated
|
|
105
|
+
against a Python fixture server)
|
|
106
|
+
- [ ] HTTP/SSE transport (stdio only for now -- covers the common local
|
|
107
|
+
MCP server case)
|
|
108
|
+
- [ ] Published to PyPI
|
|
109
|
+
|
|
110
|
+
## Notes
|
|
111
|
+
|
|
112
|
+
[`notes/`](notes/) is a running engineering log, not a cleaned-up
|
|
113
|
+
retrospective -- what broke, how it was found, why the fix is what it is.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
```
|
|
2
|
+
.-----.
|
|
3
|
+
| 🔴 |
|
|
4
|
+
| 🟡 |
|
|
5
|
+
| 🟢 |
|
|
6
|
+
'-----'
|
|
7
|
+
greenlight
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
# greenlight
|
|
11
|
+
|
|
12
|
+
See what your MCP server is actually doing.
|
|
13
|
+
|
|
14
|
+
A transparent stdio proxy for the Model Context Protocol. Point it at
|
|
15
|
+
your real server command instead of running that command directly, and
|
|
16
|
+
it relays every byte exactly as before -- while recording every JSON-RPC
|
|
17
|
+
message to a structured log you can watch live or replay.
|
|
18
|
+
|
|
19
|
+
Right now, if an MCP integration isn't working, you're debugging blind:
|
|
20
|
+
no visibility into what got sent, what came back, or why a call failed.
|
|
21
|
+
Greenlight exists to fix that.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
(Not yet published to PyPI as `greenlight-mcp` -- install from source for now.)
|
|
30
|
+
|
|
31
|
+
## Use it
|
|
32
|
+
|
|
33
|
+
Wherever you'd normally configure a server command, wrap it:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
greenlight run -- npx -y @some/mcp-server
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
instead of
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx -y @some/mcp-server
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Every message that passes through gets logged to `./sessions/`. Watch it:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
greenlight tail # replay the most recent session
|
|
49
|
+
greenlight tail -f # follow a session that's still running
|
|
50
|
+
greenlight tail path/to/log.jsonl
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Trace output is colorized by status: green for a clean success, yellow
|
|
54
|
+
for a slow-but-fine call, red for anything that actually failed --
|
|
55
|
+
including MCP tool-level failures (`result.isError`), not just
|
|
56
|
+
transport-level JSON-RPC errors, which are a different thing and easy to
|
|
57
|
+
miss if you only check for the obvious one. See `notes/day1.md` for why
|
|
58
|
+
that distinction mattered enough to write a whole note about it.
|
|
59
|
+
|
|
60
|
+
## How it works
|
|
61
|
+
|
|
62
|
+
`greenlight run` spawns your real server as a subprocess and sits
|
|
63
|
+
between it and the real MCP client, relaying stdin/stdout on two
|
|
64
|
+
threads. Every line is parsed as JSON-RPC, correlated by request id
|
|
65
|
+
(so a response knows its own method name and latency), and written to a
|
|
66
|
+
JSONL file. The one rule the whole thing depends on: nothing but the
|
|
67
|
+
child process's actual bytes ever reaches Greenlight's own stdout --
|
|
68
|
+
logging and UI output only ever go to stderr or to disk. A single stray
|
|
69
|
+
print to stdout would corrupt the protocol stream the real client is
|
|
70
|
+
parsing.
|
|
71
|
+
|
|
72
|
+
## Status
|
|
73
|
+
|
|
74
|
+
- [x] `greenlight run` -- transparent proxy, validated end-to-end against
|
|
75
|
+
a real MCP server (not a mock)
|
|
76
|
+
- [x] `greenlight tail` -- live trace viewer, both static replay and
|
|
77
|
+
genuine live-follow (verified separately, not assumed)
|
|
78
|
+
- [x] Windows PATH resolution for `npx`-style commands
|
|
79
|
+
- [ ] Tested against a real npx-launched server (so far only validated
|
|
80
|
+
against a Python fixture server)
|
|
81
|
+
- [ ] HTTP/SSE transport (stdio only for now -- covers the common local
|
|
82
|
+
MCP server case)
|
|
83
|
+
- [ ] Published to PyPI
|
|
84
|
+
|
|
85
|
+
## Notes
|
|
86
|
+
|
|
87
|
+
[`notes/`](notes/) is a running engineering log, not a cleaned-up
|
|
88
|
+
retrospective -- what broke, how it was found, why the fix is what it is.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "greenlight-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "See what your MCP server is actually doing -- a transparent stdio proxy and live trace viewer for the Model Context Protocol."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["mcp", "model-context-protocol", "debugging", "proxy", "cli"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Debuggers",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"rich>=13.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/ai-ward/greenlight"
|
|
27
|
+
Issues = "https://github.com/ai-ward/greenlight/issues"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = [
|
|
31
|
+
"mcp>=1.0",
|
|
32
|
+
"pytest>=8.0",
|
|
33
|
+
"build>=1.0",
|
|
34
|
+
"twine>=5.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
greenlight = "greenlight.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The banner. Purely cosmetic -- never called from run_proxy(), which has
|
|
3
|
+
to keep stdout pure JSON-RPC and shouldn't spam stderr with art on every
|
|
4
|
+
host-triggered startup. This only shows up where a human actually typed
|
|
5
|
+
the command: bare `greenlight` and `greenlight tail`.
|
|
6
|
+
"""
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
_LINES = [
|
|
10
|
+
(" .-----.", "white"),
|
|
11
|
+
(" | o |", "bold red"),
|
|
12
|
+
(" | o |", "bold yellow"),
|
|
13
|
+
(" | o |", "bold green"),
|
|
14
|
+
(" '-----'", "white"),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def print_banner(console: Console) -> None:
|
|
19
|
+
for text, style in _LINES:
|
|
20
|
+
console.print(text, style=style, highlight=False)
|
|
21
|
+
console.print(" greenlight", style="bold", highlight=False)
|
|
22
|
+
console.print()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Greenlight CLI entry point.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Sequence
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from greenlight.banner import print_banner
|
|
15
|
+
from greenlight.proxy import SESSIONS_DIR, run_proxy
|
|
16
|
+
from greenlight.render import latest_session, tail_file
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve(command: list[str]) -> list[str]:
|
|
20
|
+
"""Resolve command[0] through PATH (and PATHEXT on Windows) before
|
|
21
|
+
handing it to subprocess. Without this, `greenlight run -- npx ...`
|
|
22
|
+
fails on Windows with a confusing FileNotFoundError, because
|
|
23
|
+
subprocess with shell=False doesn't apply PATHEXT resolution itself
|
|
24
|
+
the way a real shell would -- "npx" on Windows is actually npx.cmd."""
|
|
25
|
+
if not command:
|
|
26
|
+
return command
|
|
27
|
+
resolved = shutil.which(command[0])
|
|
28
|
+
return [resolved, *command[1:]] if resolved else command
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="greenlight",
|
|
34
|
+
description="See what your MCP server is actually doing.",
|
|
35
|
+
)
|
|
36
|
+
subparsers = parser.add_subparsers(dest="cmd", required=False)
|
|
37
|
+
|
|
38
|
+
run_p = subparsers.add_parser(
|
|
39
|
+
"run",
|
|
40
|
+
help="Run an MCP server through the proxy, recording every message.",
|
|
41
|
+
)
|
|
42
|
+
run_p.add_argument("--name", default=None, help="session name (defaults to the command's name)")
|
|
43
|
+
run_p.add_argument(
|
|
44
|
+
"command", nargs=argparse.REMAINDER,
|
|
45
|
+
help="the real MCP server command, e.g. -- npx -y @some/mcp-server",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
tail_p = subparsers.add_parser(
|
|
49
|
+
"tail",
|
|
50
|
+
help="View a recorded session's trace -- green for ok, yellow for slow, red for failed.",
|
|
51
|
+
)
|
|
52
|
+
tail_p.add_argument(
|
|
53
|
+
"path", nargs="?", default=None,
|
|
54
|
+
help="session log to view (defaults to the most recent one in ./sessions)",
|
|
55
|
+
)
|
|
56
|
+
tail_p.add_argument(
|
|
57
|
+
"-f", "--follow", action="store_true",
|
|
58
|
+
help="keep watching for new messages, like `tail -f` (use this while a session is still running)",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
args = parser.parse_args(argv)
|
|
62
|
+
|
|
63
|
+
if args.cmd is None:
|
|
64
|
+
print_banner(Console())
|
|
65
|
+
parser.print_help()
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
if args.cmd == "run":
|
|
69
|
+
command = list(args.command)
|
|
70
|
+
if command and command[0] == "--":
|
|
71
|
+
command = command[1:]
|
|
72
|
+
if not command:
|
|
73
|
+
parser.error("no server command given -- e.g. `greenlight run -- npx -y @some/mcp-server`")
|
|
74
|
+
return run_proxy(_resolve(command), session_name=args.name)
|
|
75
|
+
|
|
76
|
+
if args.cmd == "tail":
|
|
77
|
+
path = Path(args.path) if args.path else latest_session(SESSIONS_DIR)
|
|
78
|
+
if path is None:
|
|
79
|
+
parser.error(f"no session logs found in {SESSIONS_DIR} -- run `greenlight run -- ...` first")
|
|
80
|
+
if not path.exists():
|
|
81
|
+
parser.error(f"no such file: {path}")
|
|
82
|
+
print_banner(Console())
|
|
83
|
+
tail_file(path, follow=args.follow)
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
parser.error(f"unknown command {args.cmd!r}")
|
|
87
|
+
return 2
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
sys.exit(main())
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The core of Greenlight: a transparent stdio proxy for MCP servers.
|
|
3
|
+
|
|
4
|
+
Sits between a real MCP client (Claude Desktop, Claude Code, etc.) and a
|
|
5
|
+
real MCP server. Every byte that would have flowed directly between them
|
|
6
|
+
still does -- this only *also* copies each line into a structured log.
|
|
7
|
+
|
|
8
|
+
The one rule that matters more than anything else in this file: nothing
|
|
9
|
+
except the child process's own stdout bytes may ever reach our stdout.
|
|
10
|
+
Any UI or logging written there would corrupt the JSON-RPC stream the
|
|
11
|
+
real client is expecting to parse cleanly. Logging goes to a JSONL file
|
|
12
|
+
on disk (stdout is sacred; stderr is not -- the stdio MCP transport never
|
|
13
|
+
uses stderr for protocol traffic, so a status line there is safe).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Callable, Optional
|
|
26
|
+
|
|
27
|
+
SESSIONS_DIR = Path.cwd() / "sessions"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class _PendingRequest:
|
|
32
|
+
method: str
|
|
33
|
+
sent_at: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProxySession:
|
|
37
|
+
"""Parses each relayed line as JSON-RPC and writes one structured
|
|
38
|
+
record per message to a JSONL log. Correlates responses back to the
|
|
39
|
+
request that caused them (by id) so latency and method name are
|
|
40
|
+
known even on the response side, where the raw JSON-RPC message
|
|
41
|
+
alone doesn't carry the method."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, log_path: Path):
|
|
44
|
+
self.log_path = log_path
|
|
45
|
+
self._pending: dict[object, _PendingRequest] = {}
|
|
46
|
+
self._lock = threading.Lock()
|
|
47
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
self._log_file = open(log_path, "a", encoding="utf-8", buffering=1)
|
|
49
|
+
|
|
50
|
+
def record(self, direction: str, raw_line: str) -> None:
|
|
51
|
+
line = raw_line.strip()
|
|
52
|
+
if not line:
|
|
53
|
+
return
|
|
54
|
+
ts = time.time()
|
|
55
|
+
entry: dict = {"ts": ts, "direction": direction}
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
msg = json.loads(line)
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
# Not every line a server writes to stdout is guaranteed to be
|
|
61
|
+
# a clean JSON-RPC message in practice (stray prints, partial
|
|
62
|
+
# writes). Relay already happened upstream of this function --
|
|
63
|
+
# log it as unparsed rather than dropping it or crashing.
|
|
64
|
+
entry["parsed"] = False
|
|
65
|
+
entry["raw"] = line[:500]
|
|
66
|
+
self._write(entry)
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
entry["parsed"] = True
|
|
70
|
+
msg_id = msg.get("id")
|
|
71
|
+
method = msg.get("method")
|
|
72
|
+
|
|
73
|
+
if method is not None:
|
|
74
|
+
entry["type"] = "request" if msg_id is not None else "notification"
|
|
75
|
+
entry["method"] = method
|
|
76
|
+
if msg_id is not None:
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._pending[msg_id] = _PendingRequest(method=method, sent_at=ts)
|
|
79
|
+
else:
|
|
80
|
+
entry["type"] = "error" if "error" in msg else "result"
|
|
81
|
+
pending = None
|
|
82
|
+
if msg_id is not None:
|
|
83
|
+
with self._lock:
|
|
84
|
+
pending = self._pending.pop(msg_id, None)
|
|
85
|
+
if pending is not None:
|
|
86
|
+
entry["method"] = pending.method
|
|
87
|
+
entry["latency_ms"] = round((ts - pending.sent_at) * 1000, 2)
|
|
88
|
+
if "error" in msg:
|
|
89
|
+
entry["error"] = msg["error"]
|
|
90
|
+
elif isinstance(msg.get("result"), dict) and msg["result"].get("isError"):
|
|
91
|
+
# MCP nests tool-execution failures inside a normal JSON-RPC
|
|
92
|
+
# "result" (isError: true), separate from transport-level
|
|
93
|
+
# JSON-RPC errors -- a failed tool call is NOT `"error" in msg`.
|
|
94
|
+
# Found by testing against a real server whose tool
|
|
95
|
+
# deliberately raises: it logged as an ordinary success
|
|
96
|
+
# until this check was added. This is exactly the kind of
|
|
97
|
+
# failure a trace viewer exists to surface, so it gets its
|
|
98
|
+
# own flag rather than being indistinguishable from a
|
|
99
|
+
# normal result.
|
|
100
|
+
entry["tool_error"] = True
|
|
101
|
+
|
|
102
|
+
self._write(entry)
|
|
103
|
+
|
|
104
|
+
def _write(self, entry: dict) -> None:
|
|
105
|
+
self._log_file.write(json.dumps(entry) + "\n")
|
|
106
|
+
self._log_file.flush()
|
|
107
|
+
|
|
108
|
+
def close(self) -> None:
|
|
109
|
+
self._log_file.close()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _pump(src, dst, on_line: Callable[[str], None]) -> None:
|
|
113
|
+
"""Read lines from src, relay them byte-for-byte to dst immediately,
|
|
114
|
+
then hand off to on_line for logging. Relay happens first and always
|
|
115
|
+
-- a logging exception must never be able to break the proxied
|
|
116
|
+
stream. Runs in its own thread; one of these per direction."""
|
|
117
|
+
try:
|
|
118
|
+
for raw in iter(src.readline, b""):
|
|
119
|
+
try:
|
|
120
|
+
dst.write(raw)
|
|
121
|
+
dst.flush()
|
|
122
|
+
except (BrokenPipeError, OSError):
|
|
123
|
+
break
|
|
124
|
+
try:
|
|
125
|
+
on_line(raw.decode("utf-8", errors="replace"))
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
finally:
|
|
129
|
+
try:
|
|
130
|
+
dst.flush()
|
|
131
|
+
except (BrokenPipeError, OSError):
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run_proxy(command: list[str], session_name: Optional[str] = None) -> int:
|
|
136
|
+
name = session_name or (Path(command[0]).stem if command else "session")
|
|
137
|
+
log_path = SESSIONS_DIR / f"{name}-{int(time.time())}-{uuid.uuid4().hex[:6]}.jsonl"
|
|
138
|
+
session = ProxySession(log_path)
|
|
139
|
+
|
|
140
|
+
print(f"greenlight: recording to {log_path}", file=sys.stderr)
|
|
141
|
+
print(f"greenlight: run `greenlight tail {log_path}` in another terminal to watch live",
|
|
142
|
+
file=sys.stderr)
|
|
143
|
+
|
|
144
|
+
proc = subprocess.Popen(
|
|
145
|
+
command,
|
|
146
|
+
stdin=subprocess.PIPE,
|
|
147
|
+
stdout=subprocess.PIPE,
|
|
148
|
+
stderr=None, # inherited -- the server's own error output isn't ours to hide
|
|
149
|
+
bufsize=0, # no extra buffering beyond the pipe itself; latency matters here
|
|
150
|
+
)
|
|
151
|
+
assert proc.stdin is not None and proc.stdout is not None
|
|
152
|
+
|
|
153
|
+
t_in = threading.Thread(
|
|
154
|
+
target=_pump,
|
|
155
|
+
args=(sys.stdin.buffer, proc.stdin, lambda line: session.record("client->server", line)),
|
|
156
|
+
daemon=True,
|
|
157
|
+
)
|
|
158
|
+
t_out = threading.Thread(
|
|
159
|
+
target=_pump,
|
|
160
|
+
args=(proc.stdout, sys.stdout.buffer, lambda line: session.record("server->client", line)),
|
|
161
|
+
daemon=True,
|
|
162
|
+
)
|
|
163
|
+
t_in.start()
|
|
164
|
+
t_out.start()
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
returncode = proc.wait()
|
|
168
|
+
except KeyboardInterrupt:
|
|
169
|
+
proc.terminate()
|
|
170
|
+
returncode = proc.wait()
|
|
171
|
+
finally:
|
|
172
|
+
t_out.join(timeout=2)
|
|
173
|
+
session.close()
|
|
174
|
+
|
|
175
|
+
return returncode
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Live trace viewer: renders a JSONL session log as a colorized, scrolling
|
|
3
|
+
trace -- green for a clean success, yellow for a slow-but-fine call, red
|
|
4
|
+
for anything that actually failed (transport-level or tool-level -- see
|
|
5
|
+
proxy.py's ProxySession.record for why those are tracked separately).
|
|
6
|
+
|
|
7
|
+
Prints incrementally, not as a redrawn table -- an unbounded trace is
|
|
8
|
+
closer to `tail -f` / `kubectl logs -f` than a fixed dashboard, and
|
|
9
|
+
that's the right model here: you want the scrollback, not just the
|
|
10
|
+
current state.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
SLOW_MS = 500 # a successful call slower than this still gets flagged
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _format_entry(entry: dict) -> tuple[str, str]:
|
|
27
|
+
ts_struct = time.localtime(entry["ts"])
|
|
28
|
+
ms = int((entry["ts"] % 1) * 1000)
|
|
29
|
+
timestamp = f"{time.strftime('%H:%M:%S', ts_struct)}.{ms:03d}"
|
|
30
|
+
arrow = "->" if entry.get("direction") == "client->server" else "<-"
|
|
31
|
+
|
|
32
|
+
if not entry.get("parsed", True):
|
|
33
|
+
raw = entry.get("raw", "")
|
|
34
|
+
return f"{timestamp} {arrow} [unparsed] {raw[:60]}", "dim"
|
|
35
|
+
|
|
36
|
+
kind = entry.get("type", "?")
|
|
37
|
+
method = entry.get("method") or "?"
|
|
38
|
+
|
|
39
|
+
if kind in ("request", "notification"):
|
|
40
|
+
return f"{timestamp} {arrow} {method}", "white"
|
|
41
|
+
|
|
42
|
+
latency = entry.get("latency_ms")
|
|
43
|
+
latency_str = f"{latency:>8.2f}ms" if latency is not None else " " * 10
|
|
44
|
+
|
|
45
|
+
if entry.get("tool_error"):
|
|
46
|
+
return f"{timestamp} {arrow} {method:<24s} {latency_str} FAILED (tool error)", "bold red"
|
|
47
|
+
if kind == "error":
|
|
48
|
+
message = entry.get("error", {}).get("message", "?")
|
|
49
|
+
return f"{timestamp} {arrow} {method:<24s} {latency_str} FAILED: {message}", "bold red"
|
|
50
|
+
if latency is not None and latency > SLOW_MS:
|
|
51
|
+
return f"{timestamp} {arrow} {method:<24s} {latency_str} ok (slow)", "yellow"
|
|
52
|
+
return f"{timestamp} {arrow} {method:<24s} {latency_str} ok", "green"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def tail_file(path: Path, follow: bool = False) -> None:
|
|
56
|
+
console.print(f"[bold]watching[/bold] {path}")
|
|
57
|
+
console.print(f"[dim]-> client to server <- server to client[/dim]\n")
|
|
58
|
+
|
|
59
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
60
|
+
while True:
|
|
61
|
+
line = f.readline()
|
|
62
|
+
if not line:
|
|
63
|
+
if not follow:
|
|
64
|
+
break
|
|
65
|
+
time.sleep(0.1)
|
|
66
|
+
continue
|
|
67
|
+
line = line.strip()
|
|
68
|
+
if not line:
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
entry = json.loads(line)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
continue
|
|
74
|
+
text, style = _format_entry(entry)
|
|
75
|
+
console.print(text, style=style)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def latest_session(sessions_dir: Path) -> Optional[Path]:
|
|
79
|
+
if not sessions_dir.exists():
|
|
80
|
+
return None
|
|
81
|
+
files = sorted(sessions_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime)
|
|
82
|
+
return files[-1] if files else None
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: greenlight-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: See what your MCP server is actually doing -- a transparent stdio proxy and live trace viewer for the Model Context Protocol.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/ai-ward/greenlight
|
|
7
|
+
Project-URL: Issues, https://github.com/ai-ward/greenlight/issues
|
|
8
|
+
Keywords: mcp,model-context-protocol,debugging,proxy,cli
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: rich>=13.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: mcp>=1.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
22
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
23
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
.-----.
|
|
28
|
+
| 🔴 |
|
|
29
|
+
| 🟡 |
|
|
30
|
+
| 🟢 |
|
|
31
|
+
'-----'
|
|
32
|
+
greenlight
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
# greenlight
|
|
36
|
+
|
|
37
|
+
See what your MCP server is actually doing.
|
|
38
|
+
|
|
39
|
+
A transparent stdio proxy for the Model Context Protocol. Point it at
|
|
40
|
+
your real server command instead of running that command directly, and
|
|
41
|
+
it relays every byte exactly as before -- while recording every JSON-RPC
|
|
42
|
+
message to a structured log you can watch live or replay.
|
|
43
|
+
|
|
44
|
+
Right now, if an MCP integration isn't working, you're debugging blind:
|
|
45
|
+
no visibility into what got sent, what came back, or why a call failed.
|
|
46
|
+
Greenlight exists to fix that.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install -e .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
(Not yet published to PyPI as `greenlight-mcp` -- install from source for now.)
|
|
55
|
+
|
|
56
|
+
## Use it
|
|
57
|
+
|
|
58
|
+
Wherever you'd normally configure a server command, wrap it:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
greenlight run -- npx -y @some/mcp-server
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
instead of
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx -y @some/mcp-server
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Every message that passes through gets logged to `./sessions/`. Watch it:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
greenlight tail # replay the most recent session
|
|
74
|
+
greenlight tail -f # follow a session that's still running
|
|
75
|
+
greenlight tail path/to/log.jsonl
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Trace output is colorized by status: green for a clean success, yellow
|
|
79
|
+
for a slow-but-fine call, red for anything that actually failed --
|
|
80
|
+
including MCP tool-level failures (`result.isError`), not just
|
|
81
|
+
transport-level JSON-RPC errors, which are a different thing and easy to
|
|
82
|
+
miss if you only check for the obvious one. See `notes/day1.md` for why
|
|
83
|
+
that distinction mattered enough to write a whole note about it.
|
|
84
|
+
|
|
85
|
+
## How it works
|
|
86
|
+
|
|
87
|
+
`greenlight run` spawns your real server as a subprocess and sits
|
|
88
|
+
between it and the real MCP client, relaying stdin/stdout on two
|
|
89
|
+
threads. Every line is parsed as JSON-RPC, correlated by request id
|
|
90
|
+
(so a response knows its own method name and latency), and written to a
|
|
91
|
+
JSONL file. The one rule the whole thing depends on: nothing but the
|
|
92
|
+
child process's actual bytes ever reaches Greenlight's own stdout --
|
|
93
|
+
logging and UI output only ever go to stderr or to disk. A single stray
|
|
94
|
+
print to stdout would corrupt the protocol stream the real client is
|
|
95
|
+
parsing.
|
|
96
|
+
|
|
97
|
+
## Status
|
|
98
|
+
|
|
99
|
+
- [x] `greenlight run` -- transparent proxy, validated end-to-end against
|
|
100
|
+
a real MCP server (not a mock)
|
|
101
|
+
- [x] `greenlight tail` -- live trace viewer, both static replay and
|
|
102
|
+
genuine live-follow (verified separately, not assumed)
|
|
103
|
+
- [x] Windows PATH resolution for `npx`-style commands
|
|
104
|
+
- [ ] Tested against a real npx-launched server (so far only validated
|
|
105
|
+
against a Python fixture server)
|
|
106
|
+
- [ ] HTTP/SSE transport (stdio only for now -- covers the common local
|
|
107
|
+
MCP server case)
|
|
108
|
+
- [ ] Published to PyPI
|
|
109
|
+
|
|
110
|
+
## Notes
|
|
111
|
+
|
|
112
|
+
[`notes/`](notes/) is a running engineering log, not a cleaned-up
|
|
113
|
+
retrospective -- what broke, how it was found, why the fix is what it is.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/greenlight/__init__.py
|
|
5
|
+
src/greenlight/banner.py
|
|
6
|
+
src/greenlight/cli.py
|
|
7
|
+
src/greenlight/proxy.py
|
|
8
|
+
src/greenlight/render.py
|
|
9
|
+
src/greenlight_mcp.egg-info/PKG-INFO
|
|
10
|
+
src/greenlight_mcp.egg-info/SOURCES.txt
|
|
11
|
+
src/greenlight_mcp.egg-info/dependency_links.txt
|
|
12
|
+
src/greenlight_mcp.egg-info/entry_points.txt
|
|
13
|
+
src/greenlight_mcp.egg-info/requires.txt
|
|
14
|
+
src/greenlight_mcp.egg-info/top_level.txt
|
|
15
|
+
tests/test_npx_server.py
|
|
16
|
+
tests/test_proxy_e2e.py
|
|
17
|
+
tests/test_tail_follow.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
greenlight
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validates greenlight against a real, third-party, npx-launched MCP
|
|
3
|
+
server -- the official reference server, not something hand-picked to be
|
|
4
|
+
easy. This is the realistic case: most local MCP servers are started via
|
|
5
|
+
`npx`, and on Windows that specifically exercises the PATHEXT resolution
|
|
6
|
+
fix in cli.py (`npx` is actually `npx.cmd`), which the fixture-server
|
|
7
|
+
tests never touched because they invoke `python` directly.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
.venv\\Scripts\\python.exe tests\\test_npx_server.py
|
|
11
|
+
"""
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from mcp import ClientSession
|
|
18
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
19
|
+
|
|
20
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
21
|
+
SESSIONS_DIR = ROOT / "sessions"
|
|
22
|
+
PYTHON = sys.executable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def main() -> None:
|
|
26
|
+
before = set(SESSIONS_DIR.glob("*.jsonl")) if SESSIONS_DIR.exists() else set()
|
|
27
|
+
|
|
28
|
+
# The real-world shape: greenlight wraps `npx`, not a python script.
|
|
29
|
+
# cli.py's shutil.which() resolution is what makes this work at all
|
|
30
|
+
# on Windows -- subprocess.Popen(["npx", ...]) with shell=False fails
|
|
31
|
+
# outright otherwise, because Windows doesn't apply PATHEXT the way a
|
|
32
|
+
# real shell does.
|
|
33
|
+
params = StdioServerParameters(
|
|
34
|
+
command=PYTHON,
|
|
35
|
+
args=["-m", "greenlight.cli", "run", "--name", "npx-everything", "--",
|
|
36
|
+
"npx", "-y", "@modelcontextprotocol/server-everything", "stdio"],
|
|
37
|
+
cwd=str(ROOT),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async with stdio_client(params) as (read, write):
|
|
41
|
+
async with ClientSession(read, write) as session:
|
|
42
|
+
await session.initialize()
|
|
43
|
+
print("initialize: ok (through greenlight, wrapping a real npx server)")
|
|
44
|
+
|
|
45
|
+
tools = await session.list_tools()
|
|
46
|
+
names = {t.name for t in tools.tools}
|
|
47
|
+
assert "echo" in names and "get-sum" in names, names
|
|
48
|
+
print(f"list_tools: ok ({len(names)} tools)")
|
|
49
|
+
|
|
50
|
+
echo_result = await session.call_tool("echo", {"message": "hello from greenlight"})
|
|
51
|
+
assert not echo_result.is_error, echo_result
|
|
52
|
+
print(f"call_tool(echo): ok -> {echo_result.content}")
|
|
53
|
+
|
|
54
|
+
sum_result = await session.call_tool("get-sum", {"a": 4, "b": 5})
|
|
55
|
+
assert not sum_result.is_error, sum_result
|
|
56
|
+
print(f"call_tool(get-sum): ok -> {sum_result.content}")
|
|
57
|
+
|
|
58
|
+
after = set(SESSIONS_DIR.glob("*.jsonl"))
|
|
59
|
+
new_logs = after - before
|
|
60
|
+
assert len(new_logs) == 1, f"expected exactly one new session log, got {new_logs}"
|
|
61
|
+
log_path = new_logs.pop()
|
|
62
|
+
|
|
63
|
+
entries = [json.loads(line) for line in log_path.read_text().splitlines() if line.strip()]
|
|
64
|
+
calls = [e for e in entries if e.get("method") == "tools/call" and e.get("direction") == "server->client"]
|
|
65
|
+
assert len(calls) == 2, f"expected 2 tools/call results logged, got {len(calls)}"
|
|
66
|
+
print(f"\nsession log: {log_path}")
|
|
67
|
+
print(f"logged {len(entries)} entries, {len(calls)} tool call results, "
|
|
68
|
+
f"none malformed/unparsed: {all(e.get('parsed', True) for e in entries)}")
|
|
69
|
+
|
|
70
|
+
print("\nALL CHECKS PASSED -- greenlight works against a real npx-launched "
|
|
71
|
+
"MCP server on Windows, not just the Python fixture.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
End-to-end validation: drive a real MCP client session THROUGH greenlight
|
|
3
|
+
against a real MCP server, then check both that the protocol worked
|
|
4
|
+
normally (the proxy is transparent) and that the session log correctly
|
|
5
|
+
recorded what happened.
|
|
6
|
+
|
|
7
|
+
This is deliberately not a unit test with mocks -- the whole point of
|
|
8
|
+
Greenlight is to sit in a real MCP stdio stream without breaking it, so
|
|
9
|
+
the only test that actually proves that is a real session.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
.venv\\Scripts\\python.exe tests\\test_proxy_e2e.py
|
|
13
|
+
"""
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from mcp import ClientSession
|
|
20
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
21
|
+
|
|
22
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
23
|
+
SESSIONS_DIR = ROOT / "sessions"
|
|
24
|
+
PYTHON = sys.executable
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def main() -> None:
|
|
28
|
+
before = set(SESSIONS_DIR.glob("*.jsonl")) if SESSIONS_DIR.exists() else set()
|
|
29
|
+
|
|
30
|
+
# This is the exact shape of command a real MCP host config would use:
|
|
31
|
+
# instead of running the server directly, run it through `greenlight run`.
|
|
32
|
+
params = StdioServerParameters(
|
|
33
|
+
command=PYTHON,
|
|
34
|
+
args=["-m", "greenlight.cli", "run", "--name", "fixture-e2e", "--",
|
|
35
|
+
PYTHON, str(ROOT / "tests" / "fixture_server.py")],
|
|
36
|
+
cwd=str(ROOT),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
async with stdio_client(params) as (read, write):
|
|
40
|
+
async with ClientSession(read, write) as session:
|
|
41
|
+
await session.initialize()
|
|
42
|
+
print("initialize: ok")
|
|
43
|
+
|
|
44
|
+
tools = await session.list_tools()
|
|
45
|
+
names = sorted(t.name for t in tools.tools)
|
|
46
|
+
assert names == ["add", "boom", "slow_echo"], names
|
|
47
|
+
print(f"list_tools: ok ({names})")
|
|
48
|
+
|
|
49
|
+
add_result = await session.call_tool("add", {"a": 2, "b": 3})
|
|
50
|
+
assert not add_result.is_error, add_result
|
|
51
|
+
print(f"call_tool(add): ok -> {add_result.content}")
|
|
52
|
+
|
|
53
|
+
echo_result = await session.call_tool("slow_echo", {"text": "hi", "delay_ms": 150})
|
|
54
|
+
assert not echo_result.is_error, echo_result
|
|
55
|
+
print(f"call_tool(slow_echo): ok -> {echo_result.content}")
|
|
56
|
+
|
|
57
|
+
boom_result = await session.call_tool("boom", {})
|
|
58
|
+
assert boom_result.is_error, "expected boom() to report as an error"
|
|
59
|
+
print(f"call_tool(boom): ok, correctly reported as error")
|
|
60
|
+
|
|
61
|
+
after = set(SESSIONS_DIR.glob("*.jsonl"))
|
|
62
|
+
new_logs = after - before
|
|
63
|
+
assert len(new_logs) == 1, f"expected exactly one new session log, got {new_logs}"
|
|
64
|
+
log_path = new_logs.pop()
|
|
65
|
+
print(f"\nsession log: {log_path}")
|
|
66
|
+
|
|
67
|
+
entries = [json.loads(line) for line in log_path.read_text().splitlines() if line.strip()]
|
|
68
|
+
print(f"logged {len(entries)} entries")
|
|
69
|
+
|
|
70
|
+
methods_seen = {e.get("method") for e in entries if e.get("method")}
|
|
71
|
+
assert "tools/call" in methods_seen, methods_seen
|
|
72
|
+
print(f"methods logged: {sorted(m for m in methods_seen if m)}")
|
|
73
|
+
|
|
74
|
+
slow_calls = [e for e in entries if e.get("type") == "result" and e.get("latency_ms", 0) > 100]
|
|
75
|
+
assert slow_calls, "expected at least one result with latency_ms > 100 (the slow_echo call)"
|
|
76
|
+
print(f"latency tracking: ok (slow_echo measured at {slow_calls[0]['latency_ms']}ms)")
|
|
77
|
+
|
|
78
|
+
# boom() fails at the tool level, not the transport level -- MCP nests
|
|
79
|
+
# that inside a normal JSON-RPC "result" (isError: true), so it must
|
|
80
|
+
# NOT show up as type == "error" (that's for transport-level failures).
|
|
81
|
+
# It must show up as tool_error instead.
|
|
82
|
+
transport_errors = [e for e in entries if e.get("type") == "error"]
|
|
83
|
+
tool_errors = [e for e in entries if e.get("tool_error")]
|
|
84
|
+
assert not transport_errors, f"expected no transport-level errors, got {transport_errors}"
|
|
85
|
+
assert len(tool_errors) == 1, f"expected exactly one tool_error (from boom()), got {tool_errors}"
|
|
86
|
+
print(f"tool-error detection: ok (boom() correctly flagged as tool_error, "
|
|
87
|
+
f"not confused with a transport error)")
|
|
88
|
+
|
|
89
|
+
print("\nALL CHECKS PASSED -- proxy is transparent and the log is accurate.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Verifies tail_file(follow=True) actually picks up new lines as they're
|
|
3
|
+
written, not just replays what already existed when it started. Runs
|
|
4
|
+
the real proxy against the real fixture server, starts following the log
|
|
5
|
+
partway through the session, and checks that later calls show up.
|
|
6
|
+
"""
|
|
7
|
+
import asyncio
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from io import StringIO
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from mcp import ClientSession
|
|
15
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
19
|
+
sys.path.insert(0, str(ROOT / "src"))
|
|
20
|
+
|
|
21
|
+
from greenlight.render import tail_file # noqa: E402
|
|
22
|
+
import greenlight.render as render_module # noqa: E402
|
|
23
|
+
|
|
24
|
+
PYTHON = sys.executable
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def main() -> None:
|
|
28
|
+
params = StdioServerParameters(
|
|
29
|
+
command=PYTHON,
|
|
30
|
+
args=["-m", "greenlight.cli", "run", "--name", "follow-test", "--",
|
|
31
|
+
PYTHON, str(ROOT / "tests" / "fixture_server.py")],
|
|
32
|
+
cwd=str(ROOT),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
captured = StringIO()
|
|
36
|
+
render_module.console = Console(file=captured, force_terminal=False)
|
|
37
|
+
|
|
38
|
+
follow_thread = None
|
|
39
|
+
log_path_holder: dict = {}
|
|
40
|
+
|
|
41
|
+
async with stdio_client(params) as (read, write):
|
|
42
|
+
async with ClientSession(read, write) as session:
|
|
43
|
+
await session.initialize()
|
|
44
|
+
await session.call_tool("add", {"a": 1, "b": 1})
|
|
45
|
+
|
|
46
|
+
sessions_dir = ROOT / "sessions"
|
|
47
|
+
candidates = sorted(sessions_dir.glob("follow-test-*.jsonl"), key=lambda p: p.stat().st_mtime)
|
|
48
|
+
log_path = candidates[-1]
|
|
49
|
+
log_path_holder["path"] = log_path
|
|
50
|
+
print(f"following {log_path} while the session is still open")
|
|
51
|
+
|
|
52
|
+
follow_thread = threading.Thread(target=tail_file, args=(log_path,), kwargs={"follow": True}, daemon=True)
|
|
53
|
+
follow_thread.start()
|
|
54
|
+
time.sleep(0.3)
|
|
55
|
+
|
|
56
|
+
before = captured.getvalue()
|
|
57
|
+
assert "add" not in before or before.count("tools/call") >= 1, "sanity check on initial capture"
|
|
58
|
+
|
|
59
|
+
# this call happens AFTER tail_file(follow=True) is already running
|
|
60
|
+
await session.call_tool("slow_echo", {"text": "live", "delay_ms": 50})
|
|
61
|
+
time.sleep(0.5)
|
|
62
|
+
|
|
63
|
+
output = captured.getvalue()
|
|
64
|
+
call_count = output.count("tools/call")
|
|
65
|
+
print(f"\ncaptured {call_count} tools/call lines while following live")
|
|
66
|
+
assert call_count >= 4, (
|
|
67
|
+
f"expected at least 4 tools/call lines (2 calls x request+response) "
|
|
68
|
+
f"captured DURING live follow, got {call_count}\n---\n{output}"
|
|
69
|
+
)
|
|
70
|
+
print("PASS: tail -f picks up new messages as they're written, not just at startup")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
asyncio.run(main())
|