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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ greenlight = greenlight.cli:main
@@ -0,0 +1,7 @@
1
+ rich>=13.0
2
+
3
+ [dev]
4
+ mcp>=1.0
5
+ pytest>=8.0
6
+ build>=1.0
7
+ twine>=5.0
@@ -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())