Two small follow-ups from goern's review of #118: - Bump test_smoke.py PROTOCOL_VERSION from 2024-11-05 to 2025-06-18. mcp-go v0.44.0 explicitly supports both, but 2025-06-18 is the current MCP spec version and matches what other clients send today. - Rewrite Option A in extension/README.md so it no longer points to a regenerator script that "lives in commit history" (the script was never committed). Now points to test_smoke.py as the starting reference for the MCP handshake instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.3 KiB
Python
Executable file
214 lines
7.3 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Smoke test for the forgejo-mcp Claude Desktop Extension.
|
|
|
|
Boots the bundled binary in stdio mode, performs the MCP handshake, and verifies
|
|
that the binary's tools/list response matches the manifest's declared `tools`
|
|
array. Catches the most likely drift bug for an extension wrapping someone
|
|
else's binary: the manifest's tool inventory falls out of sync with what the
|
|
binary actually exposes.
|
|
|
|
What this DOES test:
|
|
- Binary starts and speaks MCP over stdio
|
|
- Manifest is well-formed JSON
|
|
- Manifest tools[] matches the binary's tools/list (set equality on names)
|
|
|
|
What this does NOT test:
|
|
- Actual API behavior against a real Forgejo instance (no network)
|
|
- Sideload UX in Claude Desktop (no headless mode for that)
|
|
- Configuration form rendering
|
|
|
|
Usage:
|
|
python3 extension/test_smoke.py
|
|
python3 extension/test_smoke.py --binary path/to/forgejo-mcp
|
|
python3 extension/test_smoke.py --manifest path/to/manifest.json
|
|
|
|
Exits 0 on success, 1 on any mismatch or error.
|
|
|
|
Dependencies: Python 3.9+ stdlib only. No third-party packages.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
PROTOCOL_VERSION = "2025-06-18"
|
|
HANDSHAKE_TIMEOUT_SECONDS = 10
|
|
TOOLS_LIST_TIMEOUT_SECONDS = 10
|
|
|
|
|
|
def send_message(proc, payload):
|
|
"""Write one line-delimited JSON-RPC message to the server's stdin."""
|
|
line = json.dumps(payload) + "\n"
|
|
proc.stdin.write(line.encode())
|
|
proc.stdin.flush()
|
|
|
|
|
|
def read_response(proc, timeout):
|
|
"""Read one line-delimited JSON-RPC response from the server's stdout.
|
|
|
|
Uses a thread + Event to enforce a timeout, since stdout.readline() blocks.
|
|
"""
|
|
result = {"line": None, "error": None}
|
|
done = threading.Event()
|
|
|
|
def reader():
|
|
try:
|
|
result["line"] = proc.stdout.readline()
|
|
except Exception as exc: # pragma: no cover
|
|
result["error"] = exc
|
|
finally:
|
|
done.set()
|
|
|
|
thread = threading.Thread(target=reader, daemon=True)
|
|
thread.start()
|
|
if not done.wait(timeout):
|
|
raise TimeoutError(f"No response within {timeout}s")
|
|
if result["error"]:
|
|
raise result["error"]
|
|
if not result["line"]:
|
|
# Drain stderr for diagnostic context if the server died.
|
|
stderr = proc.stderr.read().decode(errors="replace")
|
|
raise RuntimeError(
|
|
"Server closed stdout without responding."
|
|
+ (f"\n--- stderr ---\n{stderr}" if stderr else "")
|
|
)
|
|
return json.loads(result["line"])
|
|
|
|
|
|
def run_smoke(binary_path: Path, manifest_path: Path) -> int:
|
|
if not binary_path.exists():
|
|
print(f"FAIL: binary not found at {binary_path}", file=sys.stderr)
|
|
return 1
|
|
if not os.access(binary_path, os.X_OK):
|
|
print(f"FAIL: binary at {binary_path} is not executable", file=sys.stderr)
|
|
return 1
|
|
if not manifest_path.exists():
|
|
print(f"FAIL: manifest not found at {manifest_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
with manifest_path.open() as f:
|
|
manifest = json.load(f)
|
|
manifest_tools = {t["name"] for t in manifest.get("tools", [])}
|
|
if not manifest_tools:
|
|
print("FAIL: manifest declares no tools", file=sys.stderr)
|
|
return 1
|
|
|
|
# The binary calls VerifyConnection on startup, which validates that the
|
|
# token is real (not just that the URL resolves). So this smoke test needs
|
|
# a valid token in FORGEJO_ACCESS_TOKEN. A read-only token is plenty —
|
|
# we only call metadata endpoints (initialize, tools/list).
|
|
#
|
|
# If no token is set, skip rather than fail so unattended CI without
|
|
# secrets passes cleanly. Local runs and CI runs with a configured
|
|
# secret get full coverage.
|
|
if not os.environ.get("FORGEJO_ACCESS_TOKEN"):
|
|
print("SKIP: FORGEJO_ACCESS_TOKEN not set — this test needs a real Forgejo")
|
|
print(" token to run. Local: export FORGEJO_ACCESS_TOKEN=<your-token>.")
|
|
print(" CI: configure as a secret. A read-only token is sufficient.")
|
|
return 0
|
|
|
|
url = os.environ.get("FORGEJO_MCP_TEST_URL", "https://codeberg.org")
|
|
env = os.environ.copy()
|
|
proc = subprocess.Popen(
|
|
[
|
|
str(binary_path),
|
|
"--transport", "stdio",
|
|
"--url", url,
|
|
],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
env=env,
|
|
)
|
|
|
|
try:
|
|
# MCP initialize handshake.
|
|
send_message(proc, {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": PROTOCOL_VERSION,
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "forgejo-mcp-smoke-test", "version": "0.1"},
|
|
},
|
|
})
|
|
init_resp = read_response(proc, HANDSHAKE_TIMEOUT_SECONDS)
|
|
if init_resp.get("error"):
|
|
print(f"FAIL: initialize returned error: {init_resp['error']}", file=sys.stderr)
|
|
return 1
|
|
|
|
# initialized notification (no response expected).
|
|
send_message(proc, {
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/initialized",
|
|
})
|
|
|
|
# tools/list — what we actually care about.
|
|
send_message(proc, {
|
|
"jsonrpc": "2.0",
|
|
"id": 2,
|
|
"method": "tools/list",
|
|
})
|
|
tools_resp = read_response(proc, TOOLS_LIST_TIMEOUT_SECONDS)
|
|
if tools_resp.get("error"):
|
|
print(f"FAIL: tools/list returned error: {tools_resp['error']}", file=sys.stderr)
|
|
return 1
|
|
binary_tools = {t["name"] for t in tools_resp.get("result", {}).get("tools", [])}
|
|
finally:
|
|
# Clean shutdown — close stdin, give the process a moment, then kill.
|
|
try:
|
|
proc.stdin.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
proc.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait()
|
|
|
|
# Compare.
|
|
only_in_manifest = manifest_tools - binary_tools
|
|
only_in_binary = binary_tools - manifest_tools
|
|
|
|
if not only_in_manifest and not only_in_binary:
|
|
print(f"PASS: manifest and binary agree on {len(manifest_tools)} tools")
|
|
return 0
|
|
|
|
print("FAIL: manifest tool inventory disagrees with the binary's tools/list", file=sys.stderr)
|
|
if only_in_manifest:
|
|
print(f" In manifest but not in binary ({len(only_in_manifest)}):", file=sys.stderr)
|
|
for name in sorted(only_in_manifest):
|
|
print(f" - {name}", file=sys.stderr)
|
|
if only_in_binary:
|
|
print(f" In binary but not in manifest ({len(only_in_binary)}):", file=sys.stderr)
|
|
for name in sorted(only_in_binary):
|
|
print(f" - {name}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
def main():
|
|
here = Path(__file__).resolve().parent
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
parser.add_argument(
|
|
"--binary",
|
|
type=Path,
|
|
default=here / "bin" / "forgejo-mcp",
|
|
help="Path to the forgejo-mcp binary (default: extension/bin/forgejo-mcp)",
|
|
)
|
|
parser.add_argument(
|
|
"--manifest",
|
|
type=Path,
|
|
default=here / "manifest.json",
|
|
help="Path to the extension manifest (default: extension/manifest.json)",
|
|
)
|
|
args = parser.parse_args()
|
|
sys.exit(run_smoke(args.binary, args.manifest))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|