Repository navigation
Expand file tree
/
Copy pathbite_mcp.py
More file actions
191 lines (166 loc) · 8.52 KB
/
Copy pathbite_mcp.py
File metadata and controls
191 lines (166 loc) · 8.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
"""
Bite for any coding AI: `bite mcp` is a small MCP server with one tool, check_fix.
MCP is the plug most coding AIs share (Claude Code, Cursor, Copilot in VS Code, Windsurf, Codex, Gemini CLI).
Add `bite mcp` as a server in yours and the AI can ask Bite, right after it says "fixed", whether any test
notices its change. A second tool, ask_bite, answers a question in plain words about a fix already committed
("is last week's login fix guarded?"). It's the same free check as `bite live`: the change is taken back out of a copy of the
project and the tests are run. No API key, nothing of yours is touched.
bite mcp run the server (your coding AI starts it; it talks JSON on stdin and stdout)
Setup lines for each coding AI: README.md, "Check the "fixed" live, in any coding AI".
Written by hand on purpose, so Bite still needs no packages: newline-delimited JSON-RPC over stdio.
"""
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import live # noqa: E402
import repo # noqa: E402
PROTOCOL = "2025-06-18" # the newest MCP version this was written against; older clients get their own back
TOOL = {
"name": "check_fix",
"title": "Bite: does a test notice my fix?",
"description": (
"Call this after you change code to fix a bug, before you tell the user it's fixed. Bite takes your "
"change back out of a copy of the project and runs the project's tests. If they still pass, no test "
"shows your fix does anything: add a test that fails without your change and passes with it, then call "
"this again. Free, needs no key, never touches the user's files. Can take as long as the tests do."),
"inputSchema": {
"type": "object",
"properties": {
"folder": {"type": "string",
"description": "The project folder you changed (any folder inside its git repository). "
"Leave it out to use the folder Bite was started in."},
"since": {"type": "string",
"description": "Check the changes since this commit or branch (for example main). "
"Default: the changes since the last commit, including uncommitted ones."},
},
},
"annotations": {"readOnlyHint": True, "openWorldHint": False},
}
ASK = {
"name": "ask_bite",
"title": "Bite: is this past fix guarded by a test?",
"description": (
"Ask in plain words about a fix already committed, like \"is last week's login fix guarded?\". Bite finds "
"the matching commit(s) by their messages, changed file names and the time named (today, yesterday, last "
"week, the last 10 days...), takes each change back out of a copy and runs the tests. The answer opens in a "
"few words (Yes, No or Partly), then a short block per commit, with its id. Free, needs no key, never touches the user's files. If nothing "
"matches, ask again with words from the commit message."),
"inputSchema": {
"type": "object",
"properties": {
"question": {"type": "string", "description": "The question, in plain words."},
"folder": TOOL["inputSchema"]["properties"]["folder"],
"last": {"type": "integer", "description": "How many recent commits to look through (default 100)."},
},
"required": ["question"],
},
"annotations": {"readOnlyHint": True, "openWorldHint": False},
}
def ask_bite(args: dict) -> tuple:
"""(text for the AI, True if it's an error the AI should know it caused)."""
import ask
root, err = project(args)
if err:
return (err, True)
if not str(args.get("question") or "").strip():
return ("Bite: pass question, for example \"is the login fix guarded?\".", True)
try:
last = max(1, int(args.get("last") or 100))
except (TypeError, ValueError):
last = 100
# Free here: the coding AI asking is already an AI, so it can ask again in better words if nothing matches.
r = ask.ask(root, str(args["question"]), last=last, free=True, log=lambda *a: None)
text = r["text"]
if r["status"] == "cant":
text += " This isn't a pass: tell the user Bite couldn't check it, and why."
return (text, False)
TOOLS = {}
def project(args: dict) -> tuple:
"""(the repository's top folder, "") for the folder the AI passed (or the one Bite started in), or (None, what
to tell the AI) when that isn't a folder in a git repository."""
folder = args.get("folder") or os.getcwd()
if not isinstance(folder, str):
return None, "Bite: folder must be a path, like \"/home/me/project\"."
if not os.path.isdir(folder):
return None, f"Bite: {folder} isn't a folder. Pass the project's folder as folder."
root = repo.repo_root(folder)
if root is None:
return None, f"Bite: {folder} isn't inside a git repository. Pass the project's folder as folder."
return root, ""
def check_fix(args: dict) -> tuple:
"""(text for the AI, True if it's an error the AI should know it caused)."""
root, err = project(args)
if err:
return (err, True)
since = args.get("since") or "HEAD"
if repo.git(root, "rev-parse", "--verify", "-q", f"{since}^{{commit}}") is None:
return (f"Bite: {since} isn't a commit or branch in {root}.", True)
r = live.check(root, since)
text = live.say(r)
if r["status"] in ("unguarded", "tests_blind"):
text += "\n\n" + live.send_back(r) + " Then call check_fix again."
elif r["status"] == "cant":
text += " This isn't a pass: tell the user Bite couldn't check it, and why."
return (text, False)
TOOLS.update({TOOL["name"]: check_fix, ASK["name"]: ask_bite})
def answer(msg: dict):
"""The reply to one JSON-RPC message, or None for a notification."""
if not isinstance(msg, dict):
return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid request"}}
method, mid = msg.get("method"), msg.get("id")
if "id" not in msg:
return None # notifications (initialized, cancelled) need no reply
params = msg.get("params") or {}
if method == "initialize":
from recent import _installed_version
result = {"protocolVersion": params.get("protocolVersion") or PROTOCOL,
"capabilities": {"tools": {}},
"serverInfo": {"name": "bite", "version": _installed_version()},
"instructions": "After you fix a bug, call check_fix before you tell the user it's fixed. "
"To ask whether an earlier fix is guarded, call ask_bite."}
elif method == "ping":
result = {}
elif method == "tools/list":
result = {"tools": [TOOL, ASK]}
elif method == "tools/call":
run = TOOLS.get(params.get("name"))
if run is None:
return {"jsonrpc": "2.0", "id": mid,
"error": {"code": -32602, "message": f"Unknown tool: {params.get('name')}"}}
try:
text, err = run(params.get("arguments") or {})
except Exception as e: # a broken check is reported to the AI, never a dead server
text, err = f"Bite's check stopped with an error: {type(e).__name__}: {e}", True
result = {"content": [{"type": "text", "text": text}], "isError": err}
else:
return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"Method not found: {method}"}}
return {"jsonrpc": "2.0", "id": mid, "result": result}
def serve(inp, out) -> int:
"""Read one JSON message per line from inp (bytes), write each reply as one line to out (bytes)."""
for line in inp:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line.decode("utf-8", errors="replace"))
except ValueError:
reply = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}
else:
reply = answer(msg)
if reply is not None:
out.write(json.dumps(reply, ensure_ascii=False).encode("utf-8") + b"\n")
out.flush()
return 0
def main(argv: list) -> int:
if "-h" in argv or "--help" in argv:
print(__doc__.strip())
return 0
# stdout carries the JSON only. Keep a private handle on it, then point fd 1 at stderr, so nothing Bite or
# the tests print (even from a child process) can get mixed into a reply.
sys.stdout.flush()
out = os.fdopen(os.dup(1), "wb")
os.dup2(2, 1)
sys.stdout = sys.stderr
return serve(sys.stdin.buffer, out)