Skip to content

Commit f0e9713

Browse files
tommaso-moroCopilot
andcommitted
Evaluate Markdown tool responses
Add opt-in lossless Markdown rendering for the selected high-usage tools, plus a reproducible token and byte benchmark harness with public response-body-free results. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 83bff8f9-aa73-410d-be31-af14dad4cb15
1 parent 3778a41 commit f0e9713

14 files changed

Lines changed: 1561 additions & 3 deletions

evals/markdown_response/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.venv/
2+
__pycache__/
3+
out/

evals/markdown_response/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Markdown response eval
2+
3+
This harness compares the current JSON responses with the lossless Markdown representation enabled by `markdown_output` for `get_file_contents`, `pull_request_read`, `create_pull_request`, `list_pull_requests`, and `search_pull_requests`.
4+
5+
Each read-only scenario is captured once from the MCP server and then converted offline with the same Go renderer used by the feature flag. This ensures both arms contain identical source data. The directory, list, and search tools are measured both with their full output and after existing `fields` filtering. `create_pull_request` uses a representative fixture matching its exact `{id, url}` response shape and is never called against GitHub.
6+
7+
The headline measurement serializes the complete MCP `tools/call` result with compact JSON, approximating the model-facing tool-result message and accounting for the escaping paid when JSON is nested inside a text content block. The output also records inner-text bytes and tokens for structured text responses. Token counts use `tiktoken` with `o200k_base`; `--approx` forces a chars/4 fallback for smoke tests.
8+
9+
## Run
10+
11+
```bash
12+
cd evals/markdown_response
13+
python3 -m venv .venv
14+
source .venv/bin/activate
15+
pip install -r requirements.txt
16+
export GITHUB_PERSONAL_ACCESS_TOKEN=...
17+
python3 markdown_response_eval.py
18+
```
19+
20+
By default the live scenarios use public data from `github/github-mcp-server` and pull request `#2658`. The script writes metrics only to `out/markdown-response-eval.json`; it does not persist response bodies.
21+
22+
```bash
23+
python3 markdown_response_eval.py --owner cli --repo cli --pull-number 12345 --per-page 30
24+
```
25+
26+
To measure the table break-even point, run the same dataset with multiple page sizes:
27+
28+
```bash
29+
for page_size in 1 10 30; do
30+
python3 markdown_response_eval.py --pull-number 2797 --per-page "$page_size" --out "out/page-${page_size}.json"
31+
done
32+
```
33+
34+
The included `results/github-mcp-server-2026-07-31.json` file is a response-body-free summary of this scale run.
35+
36+
`get_file_contents.file` and `pull_request_read.get_diff` are included as controls. They already return resource/plain-text content and should show zero change.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#!/usr/bin/env python3
2+
"""Minimal MCP stdio client for response capture."""
3+
4+
from __future__ import annotations
5+
6+
import json
7+
import os
8+
import select
9+
import shlex
10+
import subprocess
11+
import sys
12+
from pathlib import Path
13+
from types import TracebackType
14+
from typing import Any
15+
16+
REPO_ROOT = Path(__file__).resolve().parents[2]
17+
PROTOCOL_VERSION = "2025-06-18"
18+
19+
20+
class MCPServer:
21+
def __init__(
22+
self,
23+
server_cmd: str = "go run ./cmd/github-mcp-server stdio",
24+
extra_args: list[str] | None = None,
25+
timeout: float = 180.0,
26+
) -> None:
27+
self.cmd = shlex.split(server_cmd) + list(extra_args or [])
28+
self.timeout = timeout
29+
self.proc: subprocess.Popen[str] | None = None
30+
self._id = 0
31+
32+
def __enter__(self) -> "MCPServer":
33+
self.start()
34+
return self
35+
36+
def __exit__(
37+
self,
38+
_exc_type: type[BaseException] | None,
39+
_exc_value: BaseException | None,
40+
_traceback: TracebackType | None,
41+
) -> None:
42+
self.close()
43+
44+
def start(self) -> None:
45+
if not os.environ.get("GITHUB_PERSONAL_ACCESS_TOKEN"):
46+
raise RuntimeError("GITHUB_PERSONAL_ACCESS_TOKEN is required for live captures")
47+
48+
print(f"[mcp] starting: {' '.join(self.cmd)}", file=sys.stderr)
49+
self.proc = subprocess.Popen(
50+
self.cmd,
51+
cwd=REPO_ROOT,
52+
stdin=subprocess.PIPE,
53+
stdout=subprocess.PIPE,
54+
stderr=sys.stderr,
55+
text=True,
56+
env=os.environ.copy(),
57+
)
58+
self._request(
59+
"initialize",
60+
{
61+
"protocolVersion": PROTOCOL_VERSION,
62+
"capabilities": {},
63+
"clientInfo": {"name": "markdown-response-eval", "version": "0"},
64+
},
65+
)
66+
self._notify("notifications/initialized")
67+
68+
def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
69+
return self._request("tools/call", {"name": name, "arguments": arguments})
70+
71+
def close(self) -> None:
72+
if self.proc is None:
73+
return
74+
if self.proc.stdin is not None:
75+
try:
76+
self.proc.stdin.close()
77+
except BrokenPipeError:
78+
pass
79+
self.proc.terminate()
80+
try:
81+
self.proc.wait(timeout=10)
82+
except subprocess.TimeoutExpired:
83+
self.proc.kill()
84+
self.proc.wait(timeout=10)
85+
self.proc = None
86+
87+
def _send(self, payload: dict[str, Any]) -> None:
88+
if self.proc is None or self.proc.stdin is None:
89+
raise RuntimeError("MCP server is not running")
90+
self.proc.stdin.write(json.dumps(payload) + "\n")
91+
self.proc.stdin.flush()
92+
93+
def _notify(self, method: str, params: dict[str, Any] | None = None) -> None:
94+
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
95+
96+
def _read(self) -> dict[str, Any]:
97+
if self.proc is None or self.proc.stdout is None:
98+
raise RuntimeError("MCP server is not running")
99+
while True:
100+
ready, _, _ = select.select([self.proc.stdout], [], [], self.timeout)
101+
if not ready:
102+
raise TimeoutError("timed out waiting for the MCP server")
103+
line = self.proc.stdout.readline()
104+
if line == "":
105+
raise EOFError("MCP server closed stdout unexpectedly")
106+
line = line.strip()
107+
if not line:
108+
continue
109+
try:
110+
return json.loads(line)
111+
except json.JSONDecodeError:
112+
continue
113+
114+
def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
115+
self._id += 1
116+
request_id = self._id
117+
self._send(
118+
{
119+
"jsonrpc": "2.0",
120+
"id": request_id,
121+
"method": method,
122+
"params": params,
123+
}
124+
)
125+
while True:
126+
message = self._read()
127+
if message.get("id") != request_id:
128+
continue
129+
if "error" in message:
130+
raise RuntimeError(f"{method} error: {message['error']}")
131+
result = message.get("result")
132+
if not isinstance(result, dict):
133+
raise RuntimeError(f"{method} returned a non-object result")
134+
return result
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env python3
2+
"""Token counting for the Markdown response eval."""
3+
4+
from __future__ import annotations
5+
6+
from collections.abc import Callable
7+
8+
9+
def get_tokenizer(approx: bool = False) -> tuple[Callable[[str], int], str]:
10+
if approx:
11+
return (lambda text: max(1, len(text) // 4), "approx(chars/4)")
12+
13+
try:
14+
import tiktoken
15+
except ImportError as exc:
16+
raise RuntimeError(
17+
"tiktoken is required; install requirements.txt or pass --approx"
18+
) from exc
19+
20+
encoder = tiktoken.get_encoding("o200k_base")
21+
return (lambda text: len(encoder.encode(text)), "tiktoken(o200k_base)")

0 commit comments

Comments
 (0)