Files
teahub/mcp/tests/test_server.py
T
teahubxyzandClaude Opus 5.5 fb8acb0af9 mcp: Teahub MCP 2.0, typed and with the tools agents kept asking for
One file, 29 tools, every one acting with the caller's own Gitea token.
Parameters are typed (patterns, ranges, enums), so FastMCP refuses a bad
argument before Gitea is asked anything and publishes the limits in each
tool's schema.

New: tea_pull_get/create/review/merge, tea_labels and tea_label,
tea_commit (up to 50 files, text or base64), tea_branch_create,
tea_repo_edit (including archived), tea_hook_create (one per URL),
tea_secret_set and tea_variable_set.

Fixed:
- unknown labels are refused instead of silently dropped
- a file update is chosen from a 404 on the path, not from matching "404"
  in an error message; paths with spaces are percent-encoded
- tea_org_add_member names its team; there is no default
- org repositories are private by default, like user repositories
- "more" and "truncated" come from X-Total-Count, so they are true
- tea_issue_comments pages client-side: Gitea's endpoint is unpaged
- tea_actions reads /actions/tasks: 1.24 has no /actions/runs, so the
  old tool always got a 404
- a Gitea that does not answer is named as such instead of a masked error

Tests run against a fake Gitea (27). The same code was run against
teahub.xyz with a temporary token in scratch repositories that were then
deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYHXFvWMCs1GUmRmpySqQ6
2026-10-14 18:04:07 -07:00

424 lines
27 KiB
Python

"""The Teahub MCP against a fake Gitea. From mcp/: python -m unittest discover -s tests"""
from __future__ import annotations
import base64
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.update({"GITEA_URL": "https://gitea.test", "PUBLIC_URL": "https://mcp.test", "GITEA_OAUTH_CLIENT_ID": "id",
"GITEA_OAUTH_CLIENT_SECRET": "not-a-secret", "JWT_SIGNING_KEY": "k" * 32,
"FASTMCP_HOME": tempfile.mkdtemp()})
import httpx # noqa: E402
from fastmcp import Client # noqa: E402
from fastmcp.exceptions import ToolError # noqa: E402
from tea_mcp import server # noqa: E402
R = "repos/o/r"
READS = {"tea_whoami", "tea_issues", "tea_issue_get", "tea_issue_comments", "tea_labels", "tea_pull_get", "tea_repo",
"tea_tree", "tea_file_get", "tea_actions"}
WRITES = {"tea_issue_create", "tea_issue_edit", "tea_issue_comment", "tea_label", "tea_pull_create", "tea_pull_review",
"tea_pull_merge", "tea_repo_edit", "tea_repo_create", "tea_org_repo_create", "tea_org_create",
"tea_org_add_member", "tea_hook_create", "tea_file_change", "tea_commit", "tea_branch_create",
"tea_workflow_dispatch", "tea_secret_set", "tea_variable_set"}
AGAIN = {"tea_issue_edit", "tea_label", "tea_repo_edit", "tea_org_add_member", "tea_hook_create", "tea_secret_set",
"tea_variable_set"}
def b64(data: bytes) -> str:
return base64.b64encode(data).decode()
def answer(data=None, status=200, total=None) -> httpx.Response:
return httpx.Response(status, json=data, headers={"x-total-count": str(total)} if total is not None else None)
class FakeGitea:
"""Just enough of Gitea's API. Every call is kept as (method, path, query, json body)."""
def __init__(self):
self.calls: list[tuple[str, str, dict, object]] = []
self.raw_paths: list[bytes] = []
self.repo_count = 230
self.files = {"README.md": b"hello\n", "img/logo.png": b"\x89PNG\r\n\x1a\n\xff", "docs/a b.md": b"spaced\n"}
self.labels = [{"id": 1, "name": "bug", "color": "ee0701", "description": "broken"},
{"id": 2, "name": "docs", "color": "0075ca", "description": ""}]
self.on_issue = [self.labels[0]]
self.comments = [{"id": i, "user": {"login": "tea"}, "body": f"c{i}"} for i in range(1, 46)]
self.variables = {"EXISTING": "1"}
self.hooks = [{"id": 7, "config": {"url": "https://pages.test/hook"}, "events": ["push"]}]
self.merged = False
self.diff = "diff --git a/x b/x\n"
def __call__(self, request: httpx.Request) -> httpx.Response:
m, p, q = request.method, request.url.path.removeprefix("/api/v1/"), dict(request.url.params)
body = json.loads(request.content) if request.content else None
self.calls.append((m, p, q, body))
self.raw_paths.append(request.url.raw_path)
if request.headers.get("authorization") != "Bearer tok":
return answer({"message": "token is required"}, 401)
page, limit = int(q.get("page", 1)), int(q.get("limit", 50))
window = slice((page - 1) * limit, page * limit)
if (m, p) == ("GET", "user"):
return answer({"login": "tea", "is_admin": False})
if (m, p) == ("GET", "user/repos"):
return answer([{"full_name": f"tea/r{i}"} for i in range(self.repo_count)][window], total=self.repo_count)
if (m, p) in (("POST", "user/repos"), ("POST", "orgs/acme/repos")):
owner = "tea" if p == "user/repos" else "acme"
return answer({"full_name": f"{owner}/{body['name']}", "private": body["private"]}, 201)
if (m, p) == ("POST", "orgs"):
return answer({"username": body["username"]}, 201)
if (m, p) == ("GET", "orgs/acme/teams"):
return answer([{"id": 3, "name": "Owners"}, {"id": 4, "name": "Writers"}], total=2)
if m == "PUT" and p.startswith("teams/"):
return answer(status=204)
if (m, p) == ("GET", R):
return answer({"full_name": "o/r", "default_branch": "trunk", "private": True, "archived": False})
if (m, p) == ("PATCH", R):
return answer({"full_name": "o/r", **body})
if (m, p) == ("GET", "repos/o/forbidden"):
return answer({"message": "user should have a permission"}, 403)
if (m, p) == ("GET", "repos/o/broken"):
return answer({"message": "boom: secret internals"}, 500)
if (m, p) == ("GET", f"{R}/labels"):
return answer(self.labels, total=len(self.labels))
if (m, p) == ("GET", f"{R}/issues"):
return answer([{"number": n, "title": f"t{n}", "state": "open"} for n in range(25, 0, -1)][window], total=25)
if (m, p) == ("POST", f"{R}/issues"):
return answer({"number": 26, "title": body["title"],
"labels": [l for l in self.labels if l["id"] in body.get("labels", [])]}, 201)
if (m, p) == ("GET", f"{R}/issues/1"):
return answer({"number": 1, "title": "t1", "body": "x" * (server.MAX_BYTES + 5), "labels": self.on_issue})
if (m, p) == ("PATCH", f"{R}/issues/1"):
return answer({"number": 1, "title": body.get("title", "t1"), "state": body.get("state", "open")}, 201)
if (m, p) == ("GET", f"{R}/issues/1/comments"):
return answer(self.comments, total=len(self.comments))
if (m, p) == ("POST", f"{R}/issues/1/comments"):
return answer({"id": 99, "html_url": "https://gitea.test/o/r/issues/1#issuecomment-99"}, 201)
if (m, p) == ("POST", f"{R}/issues/1/labels"):
self.on_issue = self.on_issue + [l for l in self.labels if l["id"] in body["labels"]]
return answer(self.on_issue)
if m == "DELETE" and p.startswith(f"{R}/issues/1/labels/"):
self.on_issue = [l for l in self.on_issue if str(l["id"]) != p.rsplit("/", 1)[1]]
return answer(status=204)
if (m, p) == ("GET", f"{R}/pulls/2"):
return answer({"number": 2, "title": "p", "body": "why", "head": {"ref": "feature", "sha": "abcdef1234567890"},
"base": {"ref": "trunk"}, "mergeable": True, "merged": self.merged,
"merge_commit_sha": "fedcba9876543210" if self.merged else None})
if (m, p) == ("GET", f"{R}/pulls/2.diff"):
return httpx.Response(200, text=self.diff)
if (m, p) == ("GET", f"{R}/commits/abcdef1234567890/status"):
return answer({"state": "success", "statuses": [{"context": "ci / test", "status": "success",
"description": "ok", "target_url": "https://ci.test/1"}]})
if (m, p) == ("POST", f"{R}/pulls"):
return answer({"number": 3, "title": body["title"], "head": {"ref": body["head"]}}, 201)
if (m, p) == ("POST", f"{R}/pulls/2/reviews"):
return answer({"id": 11, "state": body["event"]})
if (m, p) == ("POST", f"{R}/pulls/2/merge"):
self.merged = True
return answer()
if (m, p) == ("GET", f"{R}/contents"):
return answer([{"name": n, "type": "file" if "." in n else "dir", "size": 1} for n in ("README.md", "img", "docs")])
if m == "GET" and p.startswith(f"{R}/contents/"):
path = p.removeprefix(f"{R}/contents/")
if path in ("img", "docs"):
return answer([{"name": k.split("/")[1], "type": "file", "size": len(v)}
for k, v in self.files.items() if k.startswith(path + "/")])
if path not in self.files:
return answer({"message": "object does not exist"}, 404)
data = self.files[path]
return answer({"type": "file", "path": path, "sha": f"sha-{path}", "size": len(data), "content": b64(data)})
if (m, p) == ("POST", f"{R}/contents"):
return answer({"commit": {"sha": "c0ffee" * 7, "html_url": "https://gitea.test/o/r/commit/c0ffee"}}, 201)
if (m, p) == ("POST", f"{R}/branches"):
return answer({"name": body["new_branch_name"], "commit": {"id": "b" * 40}}, 201)
if (m, p) == ("GET", f"{R}/hooks"):
return answer(self.hooks, total=len(self.hooks))
if (m, p) == ("POST", f"{R}/hooks"):
return answer({"id": 8, "events": body["events"]}, 201)
if (m, p) == ("GET", f"{R}/actions/tasks"):
tasks = [{"id": 40 - i, "run_number": 9 - i // 2, "name": ("test", "lint")[i % 2], "display_title": "deploy",
"workflow_id": "deploy.yml", "event": "push", "status": "success", "head_branch": "trunk",
"head_sha": "1234567890abcdef", "url": f"https://gitea.test/o/r/actions/runs/{9 - i // 2}"}
for i in range(15)]
return answer({"total_count": len(tasks), "workflow_runs": tasks[window]})
if (m, p) == ("POST", f"{R}/actions/workflows/ci.yml/dispatches"):
return answer(status=204)
if m == "PUT" and p.startswith(f"{R}/actions/secrets/"):
return answer(status=201)
if p.startswith(f"{R}/actions/variables/"):
name = p.rsplit("/", 1)[1]
if m == "GET":
return answer({"name": name, "data": self.variables[name]}) if name in self.variables else \
answer({"message": "not found"}, 404)
self.variables[name] = body["value"]
return answer(status=201 if m == "POST" else 204)
return answer({"message": f"no route {m} {p}"}, 404)
class TeaMCP(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.gitea = FakeGitea()
server._transport = httpx.MockTransport(self.gitea)
self.real_token, server.actor_token = server.actor_token, lambda: "tok"
self.client = Client(server.mcp)
await self.client.__aenter__()
async def asyncTearDown(self):
await self.client.__aexit__(None, None, None)
server.actor_token, server._transport = self.real_token, None
async def call(self, tool: str, **args) -> dict:
return (await self.client.call_tool(tool, args)).structured_content
async def refused(self, tool: str, **args) -> str:
with self.assertRaises(ToolError) as caught:
await self.client.call_tool(tool, args)
return str(caught.exception)
def sent(self, method: str, path: str | None = None) -> list[tuple]:
return [c for c in self.gitea.calls if c[0] == method and (path is None or c[1] == path)]
async def test_tools_and_their_hints(self):
tools = {t.name: t for t in await self.client.list_tools()}
self.assertEqual(set(tools), READS | WRITES)
for name, tool in tools.items():
with self.subTest(tool=name):
hints = tool.annotations
self.assertEqual(bool(hints.read_only_hint), name in READS)
self.assertIs(hints.destructive_hint, None if name in READS else False)
self.assertEqual(bool(hints.idempotent_hint), name in AGAIN)
self.assertEqual(tools["tea_issues"].input_schema["properties"]["limit"]["maximum"], 50)
async def test_bad_arguments_never_reach_gitea(self):
for tool, args in [("tea_issues", {"repo": "no-slash"}), ("tea_issues", {"repo": "o/r", "limit": 51}),
("tea_issues", {"repo": "o/r", "state": "merged"}), ("tea_repo", {"repo": "../x"}),
("tea_repo", {"repo": "o/.."}), ("tea_file_get", {"repo": "o/r", "path": "a\\b"}),
("tea_secret_set", {"repo": "o/r", "name": "1BAD", "value": "v"}),
("tea_commit", {"repo": "o/r", "message": "m", "files": []}),
("tea_pull_review", {"repo": "o/r", "number": 2, "event": "LGTM"}),
("tea_workflow_dispatch", {"repo": "o/r", "workflow": "../x.yml"}),
("tea_org_add_member", {"org": "acme", "user": "kim"})]:
with self.subTest(tool=tool, args=args):
await self.refused(tool, **args)
self.assertEqual(self.gitea.calls, [])
async def test_whoami_says_truthfully_whether_it_stopped_short(self):
me = await self.call("tea_whoami")
self.assertEqual((me["login"], len(me["repos"]), me["truncated"]), ("tea", 200, True))
self.gitea.repo_count = 200
me = await self.call("tea_whoami")
self.assertEqual((len(me["repos"]), me["truncated"]), (200, False))
async def test_issues_pages(self):
first = await self.call("tea_issues", repo="o/r", limit=20)
last = await self.call("tea_issues", repo="o/r", page=2, limit=20)
self.assertEqual((len(first["issues"]), first["more"], len(last["issues"]), last["more"]), (20, True, 5, False))
self.assertEqual(self.sent("GET", f"{R}/issues")[0][2], {"state": "open", "type": "issues", "page": "1", "limit": "20"})
await self.call("tea_issues", repo="o/r", kind="pulls", labels=["bug", "docs"])
self.assertEqual(self.sent("GET", f"{R}/issues")[-1][2]["labels"], "bug,docs")
async def test_unknown_labels_are_refused(self):
message = await self.refused("tea_issue_create", repo="o/r", title="x", labels=["bug", "nope"])
self.assertIn("unknown labels: nope", message)
self.assertIn("bug, docs", message)
self.assertEqual(self.sent("POST"), [])
made = await self.call("tea_issue_create", repo="o/r", title=" x ", labels=["docs"])
self.assertEqual(self.sent("POST", f"{R}/issues")[0][3], {"title": "x", "body": "", "labels": [2]})
self.assertEqual((made["number"], made["labels"]), (26, ["docs"]))
async def test_label_adds_and_removes(self):
out = await self.call("tea_label", repo="o/r", number=1, add=["docs"], remove=["bug"])
self.assertEqual(self.sent("POST", f"{R}/issues/1/labels")[0][3], {"labels": [2]})
self.assertEqual([c[1] for c in self.sent("DELETE")], [f"{R}/issues/1/labels/1"])
self.assertEqual(out["labels"], ["docs"])
self.assertIn("nothing to change", await self.refused("tea_label", repo="o/r", number=1))
async def test_long_bodies_are_clipped_and_comments_paged(self):
issue = await self.call("tea_issue_get", repo="o/r", number=1)
self.assertEqual((len(issue["body"]), issue["body_truncated"]), (server.MAX_BYTES, True))
page2 = await self.call("tea_issue_comments", repo="o/r", number=1, page=2, limit=20)
page3 = await self.call("tea_issue_comments", repo="o/r", number=1, page=3, limit=20)
self.assertEqual([c["id"] for c in page2["comments"]], list(range(21, 41)))
self.assertEqual((page2["more"], len(page3["comments"]), page3["more"]), (True, 5, False))
self.assertNotIn("body_truncated", page2["comments"][0])
async def test_issue_edit_and_comment(self):
out = await self.call("tea_issue_edit", repo="o/r", number=1, state="closed", assignees=["tea"])
self.assertEqual((self.sent("PATCH")[0][3], out["state"]), ({"state": "closed", "assignees": ["tea"]}, "closed"))
self.assertIn("nothing to change", await self.refused("tea_issue_edit", repo="o/r", number=1))
comment = await self.call("tea_issue_comment", repo="o/r", number=1, body=" hi ")
self.assertEqual((self.sent("POST")[0][3], comment["comment_id"]), ({"body": "hi"}, 99))
self.assertIn("body is empty", await self.refused("tea_issue_comment", repo="o/r", number=1, body=" "))
async def test_pull_read_review_merge(self):
self.gitea.diff = "+" * (server.MAX_BYTES + 10)
pr = await self.call("tea_pull_get", repo="o/r", number=2)
self.assertEqual((pr["head"], pr["base"], pr["head_sha"], pr["is_pull"]), ("feature", "trunk", "abcdef123456", True))
self.assertEqual((pr["checks"]["state"], pr["checks"]["statuses"][0]["context"]), ("success", "ci / test"))
self.assertEqual((len(pr["diff"]), pr["diff_truncated"]), (server.MAX_BYTES, True))
self.assertNotIn("diff", await self.call("tea_pull_get", repo="o/r", number=2, diff=False))
review = await self.call("tea_pull_review", repo="o/r", number=2, event="APPROVED")
self.assertEqual(review["state"], "APPROVED")
merged = await self.call("tea_pull_merge", repo="o/r", number=2)
self.assertEqual(self.sent("POST", f"{R}/pulls/2/merge")[0][3], {"Do": "squash", "delete_branch_after_merge": False})
self.assertEqual((merged["merged"], merged["merge_commit"]), (True, "fedcba987654"))
async def test_pull_create_targets_the_default_branch(self):
made = await self.call("tea_pull_create", repo="o/r", head="feature", title="p")
self.assertEqual(self.sent("POST", f"{R}/pulls")[0][3], {"head": "feature", "base": "trunk", "title": "p", "body": ""})
self.assertEqual((made["number"], made["is_pull"]), (3, True))
async def test_file_reads(self):
text = await self.call("tea_file_get", repo="o/r", path="/README.md")
binary = await self.call("tea_file_get", repo="o/r", path="img/logo.png")
spaced = await self.call("tea_file_get", repo="o/r", path="docs/a b.md")
self.assertEqual((text["content"], text["sha"]), ("hello\n", "sha-README.md"))
self.assertEqual((binary.get("binary"), "content" in binary), (True, False))
self.assertEqual(spaced["content"], "spaced\n")
self.assertIn(b"/api/v1/repos/o/r/contents/docs/a%20b.md", self.gitea.raw_paths)
self.gitea.files["big.txt"] = b"x" * (server.MAX_BYTES + 1)
self.assertTrue((await self.call("tea_file_get", repo="o/r", path="big.txt"))["too_large"])
self.assertIn("not a file", await self.refused("tea_file_get", repo="o/r", path="docs"))
self.assertIn("plain repository path", await self.refused("tea_file_get", repo="o/r", path="docs/../x"))
async def test_tree(self):
root = await self.call("tea_tree", repo="o/r")
self.assertEqual(([e["name"] for e in root["entries"]], root["truncated"]), (["README.md", "img", "docs"], False))
self.assertEqual(self.gitea.calls[-1][1], f"{R}/contents")
docs = await self.call("tea_tree", repo="o/r", path="docs/", ref="trunk")
self.assertEqual(([e["name"] for e in docs["entries"]], docs["path"]), (["a b.md"], "docs"))
self.assertEqual(self.gitea.calls[-1][2], {"ref": "trunk"})
async def test_commit_creates_updates_and_carries_binaries(self):
png = b"\x89PNG\r\n\x1a\n\x00\x01"
out = await self.call("tea_commit", repo="o/r", message=" add logo ", new_branch="art", files=[
{"path": "README.md", "content": "hello again\n"}, {"path": "img/new.png", "content_base64": b64(png)}])
payload = self.sent("POST", f"{R}/contents")[0][3]
self.assertEqual((payload["message"], payload["branch"], payload["new_branch"]), ("add logo", "trunk", "art"))
readme, image = payload["files"]
self.assertEqual((readme["operation"], readme["sha"], base64.b64decode(readme["content"])),
("update", "sha-README.md", b"hello again\n"))
self.assertEqual((image["operation"], base64.b64decode(image["content"]), "sha" in image), ("create", png, False))
self.assertEqual(out["files"], {"README.md": "update", "img/new.png": "create"})
self.assertEqual((out["branch"], out["commit"]), ("art", "c0ffeec0ffee"))
self.assertTrue(all(c[2] == {"ref": "trunk"} for c in self.gitea.calls if c[1].startswith(f"{R}/contents/")))
async def test_commit_refusals(self):
for files, expected in [([{"path": "a.txt"}], "exactly one"),
([{"path": "a.txt", "content": "x", "content_base64": b64(b"x")}], "exactly one"),
([{"path": "a.bin", "content_base64": "not base64!"}], "not valid base64"),
([{"path": "../a.txt", "content": "x"}], "plain repository path"),
([{"path": "a", "content": "x"}, {"path": "/a", "content": "y"}], "appears twice")]:
with self.subTest(expected=expected):
self.assertIn(expected, await self.refused("tea_commit", repo="o/r", message="m", files=files))
cap, server.MAX_COMMIT_BYTES = server.MAX_COMMIT_BYTES, 10
try:
self.assertIn("more than 10 bytes", await self.refused("tea_commit", repo="o/r", message="m",
files=[{"path": "a", "content": "x" * 11}]))
finally:
server.MAX_COMMIT_BYTES = cap
self.assertEqual(self.gitea.calls, []) # all of that was refused before asking Gitea anything
self.assertIn("is not a file", await self.refused("tea_commit", repo="o/r", message="m",
files=[{"path": "docs", "content": "x"}]))
self.assertEqual(self.sent("POST"), [])
async def test_file_change_creates_or_updates_without_guessing(self):
await self.call("tea_file_change", repo="o/r", path="new.md", content="n", message="m")
await self.call("tea_file_change", repo="o/r", path="README.md", content="u", message="m", branch="dev")
created, updated = [c[3] for c in self.sent("POST", f"{R}/contents")]
self.assertEqual((created["files"][0]["operation"], created["branch"]), ("create", "trunk"))
self.assertEqual((updated["files"][0]["operation"], updated["branch"]), ("update", "dev"))
async def test_branch_create(self):
branch = await self.call("tea_branch_create", repo="o/r", name="feature/x")
self.assertEqual(self.sent("POST", f"{R}/branches")[0][3], {"new_branch_name": "feature/x", "old_ref_name": "trunk"})
self.assertEqual(branch["commit"], "b" * 12)
async def test_actions_lists_jobs_newest_first(self):
first = await self.call("tea_actions", repo="o/r")
last = await self.call("tea_actions", repo="o/r", page=2)
job = first["jobs"][0]
self.assertEqual((job["id"], job["run"], job["job"], job["workflow"], job["sha"]),
(40, 9, "test", "deploy.yml", "1234567890ab"))
self.assertEqual((len(first["jobs"]), first["more"], len(last["jobs"]), last["more"]), (10, True, 5, False))
async def test_workflow_dispatch_defaults_to_the_default_branch(self):
out = await self.call("tea_workflow_dispatch", repo="o/r", workflow="ci.yml", inputs={"why": "test"})
self.assertEqual((self.sent("POST")[0][3], out["dispatched"]), ({"ref": "trunk", "inputs": {"why": "test"}}, True))
async def test_a_secret_is_never_echoed(self):
result = await self.client.call_tool("tea_secret_set", {"repo": "o/r", "name": "deploy_key", "value": "s3cr3t-v"})
shown = json.dumps(result.structured_content) + "".join(getattr(c, "text", "") for c in result.content)
self.assertNotIn("s3cr3t-v", shown)
self.assertEqual(self.sent("PUT")[0][1:], (f"{R}/actions/secrets/DEPLOY_KEY", {}, {"data": "s3cr3t-v"}))
async def test_variable_set_creates_or_updates(self):
fresh = await self.call("tea_variable_set", repo="o/r", name="fresh", value="1")
existing = await self.call("tea_variable_set", repo="o/r", name="existing", value="2")
self.assertEqual((fresh["created"], existing["created"]), (True, False))
self.assertEqual([(c[0], c[1].rsplit("/", 1)[1]) for c in self.gitea.calls if c[0] in ("POST", "PUT")],
[("POST", "FRESH"), ("PUT", "EXISTING")])
async def test_hook_create_is_idempotent(self):
same = await self.call("tea_hook_create", repo="o/r", url="https://pages.test/hook")
self.assertEqual((same["created"], same["hook_id"], self.sent("POST")), (False, 7, []))
new = await self.call("tea_hook_create", repo="o/r", url="https://ci.test/hook", events=["push", "release"],
secret="shh")
posted = self.sent("POST", f"{R}/hooks")[0][3]
self.assertEqual((posted["events"], posted["config"]),
(["push", "release"], {"url": "https://ci.test/hook", "content_type": "json", "secret": "shh"}))
self.assertNotIn("shh", json.dumps(new))
async def test_repositories_are_private_unless_asked(self):
mine = await self.call("tea_repo_create", name="lab")
org = await self.call("tea_org_repo_create", org="acme", name="site", private=False)
self.assertEqual([c[3]["private"] for c in self.sent("POST")], [True, False])
self.assertEqual((mine["full_name"], org["full_name"]), ("tea/lab", "acme/site"))
async def test_org_add_member_needs_a_real_team(self):
self.assertIn("Owners, Writers", await self.refused("tea_org_add_member", org="acme", user="kim", team="Admins"))
added = await self.call("tea_org_add_member", org="acme", user="kim", team="writers")
self.assertEqual((self.sent("PUT")[0][1], added["team"]), ("teams/4/members/kim", "Writers"))
async def test_repo_edit_can_archive(self):
out = await self.call("tea_repo_edit", repo="o/r", archived=True, description="old")
self.assertEqual(self.sent("PATCH", R)[0][3], {"description": "old", "archived": True})
self.assertEqual((out["archived"], out["description"]), (True, "old"))
self.assertIn("nothing to change", await self.refused("tea_repo_edit", repo="o/r"))
async def test_an_unreachable_gitea_is_named(self):
def flaky(request):
if request.url.path.endswith((".diff", "/contents")):
raise httpx.ConnectError("refused", request=request)
return self.gitea(request)
server._transport = httpx.MockTransport(flaky)
self.assertIn("Gitea did not answer (ConnectError)", await self.refused("tea_tree", repo="o/r"))
self.assertIn("Gitea did not answer (ConnectError)", await self.refused("tea_pull_get", repo="o/r", number=2))
async def test_gitea_refusals_pass_through_but_its_internals_do_not(self):
self.assertIn("Gitea refused (403): user should have a permission", await self.refused("tea_repo", repo="o/forbidden"))
message = await self.refused("tea_repo", repo="o/broken")
self.assertIn("Gitea is unavailable", message)
self.assertNotIn("secret internals", message)
class App(unittest.IsolatedAsyncioTestCase):
async def test_health_and_sign_in_are_wired(self):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server.app), base_url="http://localhost") as web:
health = await web.get("/healthz")
anonymous = await web.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
headers={"Accept": "application/json, text/event-stream"})
metadata = await web.get("/.well-known/oauth-authorization-server")
self.assertEqual((health.json()["version"], anonymous.status_code), (server.VERSION, 401))
self.assertEqual(metadata.json()["issuer"].rstrip("/"), "https://mcp.test")
if __name__ == "__main__":
unittest.main()