3 Commits
Author SHA1 Message Date
teahubxyzandClaude Fable 5.1 060f3a3154 MCP: tests for tea_file_edit (#12)
Replacements apply in order and the answer says what was made; every refusal comes before anything is committed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKUMBXauq7XVN4BhTXjCSM
2026-10-16 22:46:02 +00:00
teahubxyzandClaude Fable 5.1 fcd70f294d MCP: tea_file_edit, exact-text replacements in one file (#12)
For a file too long to send whole: the edits apply in order, each old text must occur exactly once in the file as it stands, and the answer gives the new file's size and sha256 to check against the caller's copy. Version 2.3.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKUMBXauq7XVN4BhTXjCSM
2026-10-16 22:43:48 +00:00
teahubxyzandClaude Opus 5.5 f853d7c97a Teahub home-screen icon and link-preview card (#11)
Teahub home-screen icon and link-preview card (#11)

- apple-touch-icon.png: the cup on Teahub green (#2f9e44), opaque 180x180, linked from header.tmpl
- og-card.png: 1200x630 link-preview card (the home page hero), 15 KB
- head_opengraph.tmpl: Gitea 28.0.0's; site pages get the absolute card URL with size and alt,
  and twitter:card summary_large_image
- src/: the HTML the two PNGs are rendered from

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvDrpBDoAJjW8z1kaa3GAa
2026-10-16 10:31:26 +00:00
10 changed files with 174 additions and 11 deletions

No files matched your search

+16 -6
View File
@@ -6,16 +6,26 @@ when it starts, so a template change needs a restart; files under `public/` are
- `templates/home.tmpl`: the signed-out home page, Teahub = Git + Tea + Time beside the cup.
- `templates/user/dashboard/dashboard.tmpl`: Gitea 28's dashboard with the repository list and the contribution
heatmap moved into the sidebar.
- `templates/custom/header.tmpl`, `templates/custom/footer.tmpl`: load `teahub.css` and `teahub.js`. Bump their
`?v=` when those files change, because browsers keep assets for hours.
- `templates/base/head_opengraph.tmpl`: Gitea 28's link-preview tags. Pages that aren't a repository or a profile
(home, explore, sign-in) point `og:image` at the absolute URL of `og-card.png`, give its size, and ask for the large
card (`twitter:card`), so Discord, WhatsApp, iMessage and Slack show it. Repositories, issues and profiles keep
Gitea's avatars.
- `templates/custom/header.tmpl`, `templates/custom/footer.tmpl`: load `teahub.css` and `teahub.js`, and link the
home-screen icon. Bump their `?v=` when those files change, because browsers keep assets for hours.
- `public/assets/css/teahub.css`: green tea colours for Gitea's light, dark and auto themes, the name beside the
navbar's cup, the home page and the sidebar.
- `public/assets/js/teahub.js`: the home page's title, "Powered by Teahub", and the dashboard's Domains tab, which asks
Tea MCP's `GET /domains` (Cloudflare Registrar) and shows exact names and suggestions with their prices.
- `public/assets/img/`: the 🍵 mark. The SVGs draw the emoji in the reader's own emoji font. The PNGs (link previews,
browser tabs, phone home screens) are Noto Emoji's U+1F375 (googlefonts/noto-emoji, Apache License 2.0).
- `public/assets/img/`: the 🍵 mark. The SVGs draw the emoji in the reader's own emoji font. The PNGs are Noto Emoji's
U+1F375 (googlefonts/noto-emoji, Apache License 2.0). `apple-touch-icon.png` is the cup on Teahub green (#2f9e44),
180×180 and opaque (iOS turns transparency black), for phone home screens. `og-card.png` is the 1200×630 link
preview, the home page's hero; keep it under 300 KB, because WhatsApp drops bigger preview images.
- `src/`: the HTML those two PNGs are rendered from. Not copied to prod. To redo one: headless Chrome at 1× with the
page's size (`--headless=new --force-device-scale-factor=1 --window-size=1200,630 --virtual-time-budget=6000
--screenshot`), then quantise to 256 colours (Pillow `quantize(256, MEDIANCUT, dither NONE)`) and look at it.
The two page templates are Gitea 28.0.0's with Teahub's changes. After a Gitea upgrade, compare them with the new
release's (`gitea embedded extract --destination x 'templates/**'`) before deploying it.
The three Gitea templates here (home, dashboard, head_opengraph) are Gitea 28.0.0's with Teahub's changes. After a
Gitea upgrade, compare them with the new release's (`gitea embedded extract --destination x 'templates/**'`) before
deploying it.
`../sync-custom.sh` puts this directory on prod (it needs flyctl signed in to Teahub's Fly organisation).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+4
View File
@@ -0,0 +1,4 @@
<!doctype html><html><head><meta charset="utf-8"><title>Teahub home-screen icon, 180x180</title><style>
html,body{margin:0;width:180px;height:180px;overflow:hidden;background:#2f9e44}
img{position:absolute;left:15px;top:13px;width:150px;height:150px}
</style></head><body><img src="../public/assets/img/logo.png" alt=""></body></html>
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html><head><meta charset="utf-8">
<title>Teahub link preview card, 1200x630</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400..800&display=block" rel="stylesheet">
<style>
html,body{margin:0;width:1200px;height:630px;overflow:hidden}
body{background:#f3f9f4;color:#181c21;font-family:Inter,sans-serif;position:relative}
.wrap{position:absolute;inset:0;display:grid;grid-template-columns:1fr 360px;align-items:center;gap:32px;padding:0 84px 0 96px}
.brand{margin:0 0 42px;font-size:60px;font-weight:700;letter-spacing:-.045em;line-height:1}
.eq{display:flex;align-items:baseline;white-space:nowrap;margin:0;font-size:112px;font-weight:540;line-height:.9}
.eq>span:not(.plus),.eq em{letter-spacing:-.07em}
.eq em{font-family:Georgia,"Times New Roman",serif;font-size:.94em;font-weight:400;letter-spacing:-.055em}
.plus{color:#2f9e44;font-size:.64em;font-weight:560;margin-inline:.16em}
.cup{width:360px;height:360px;display:block}
.url{position:absolute;left:96px;bottom:56px;font-size:28px;font-weight:560;color:#2f9e44;letter-spacing:-.01em}
</style></head><body>
<div class="wrap"><div><h1 class="brand">Teahub</h1><div class="eq"><span>Git</span><span class="plus">+</span><span>Tea</span><span class="plus">+</span><em>Time</em></div></div><img class="cup" src="../public/assets/img/logo.png" alt=""></div>
<div class="url">teahub.xyz</div>
</body></html>
@@ -0,0 +1,51 @@
{{- /* og:description - a one to two sentence description of your object, maybe it only needs at most 300 bytes */ -}}
{{if .PageIsUserProfile}}
<meta property="og:title" content="{{.ContextUser.DisplayName}}">
<meta property="og:type" content="profile">
<meta property="og:image" content="{{.ContextUser.AvatarLink ctx}}">
<meta property="og:url" content="{{.ContextUser.HTMLURL ctx}}">
{{if .ContextUser.Description}}
<meta property="og:description" content="{{StringUtils.EllipsisString .ContextUser.Description 300}}">
{{end}}
{{else if .Repository}}
{{if .Issue}}
<meta property="og:title" content="{{.Issue.Title}}">
<meta property="og:url" content="{{.Issue.HTMLURL ctx}}">
{{if .Issue.Content}}
<meta property="og:description" content="{{StringUtils.EllipsisString .Issue.Content 300}}">
{{end}}
{{else if or .PageIsDiff .IsViewFile}}
<meta property="og:title" content="{{.Title}}">
<meta property="og:url" content="{{ctx.AppFullLink $.Link}}">
{{if and .PageIsDiff .Commit}}
{{- $commitMessageBody := .Commit.MessageBody -}}
{{- if $commitMessageBody -}}
<meta property="og:description" content="{{StringUtils.EllipsisString $commitMessageBody 300}}">
{{- end -}}
{{end}}
{{else}}
<meta property="og:title" content="{{.Repository.Name}}">
<meta property="og:url" content="{{.Repository.HTMLURL ctx}}">
{{if .Repository.Description}}
<meta property="og:description" content="{{StringUtils.EllipsisString .Repository.Description 300}}">
{{end}}
{{end}}
<meta property="og:type" content="object">
{{if (.Repository.AvatarLink ctx)}}
<meta property="og:image" content="{{.Repository.AvatarLink ctx}}">
{{else}}
<meta property="og:image" content="{{.Repository.Owner.AvatarLink ctx}}">
{{end}}
{{else}}
{{/* Teahub: the 1200x630 card at an absolute URL (WhatsApp needs one), its size, and the large-card layout. */}}
<meta property="og:title" content="{{AppName}}">
<meta property="og:type" content="website">
<meta property="og:image" content="{{ctx.AppFullLink "/assets/img/og-card.png"}}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Teahub: Git + Tea + Time, beside a cup of green tea">
<meta property="og:url" content="{{ctx.AppFullLink}}">
<meta property="og:description" content="{{MetaDescription}}">
<meta name="twitter:card" content="summary_large_image">
{{end}}
<meta property="og:site_name" content="{{AppName}}">
@@ -1,2 +1,3 @@
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/teahub.css?v=20261015">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍵</text></svg>">
<link rel="apple-touch-icon" href="{{AssetUrlPrefix}}/img/apple-touch-icon.png?v=20261016">
+2 -2
View File
@@ -4,11 +4,11 @@ Teahub's MCP (Fly app `green-tea-mcp`, `https://mcp.teahub.xyz/mcp`) against the
Env: `GITEA_URL`, `PUBLIC_URL`, `ALLOWED_HOSTS`; secrets `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET` (an OAuth2 application on the Gitea with redirect `PUBLIC_URL/auth/callback`), `JWT_SIGNING_KEY`. State: `/data` (FastMCP's OAuth store).
Tools (31), all acting as you. Their parameters are typed, so a bad argument is refused before Gitea is asked anything.
Tools (32), all acting as you. Their parameters are typed, so a bad argument is refused before Gitea is asked anything.
- Issues and pull requests: `tea_issues`, `tea_issue_get/create/edit`, `tea_issue_comments/comment`, `tea_labels`, `tea_label` (add or remove by name), `tea_pull_get` (branches, mergeability, checks, diff), `tea_pull_create`, `tea_pull_review`, `tea_pull_merge` (keeps the head branch).
- Repositories and organisations: `tea_whoami`, `tea_repo`, `tea_repo_edit` (description, website, visibility, default branch, archived), `tea_repo_create`, `tea_org_create`, `tea_org_repo_create`, `tea_org_add_member` (name the team), `tea_hook_create` (one hook per URL).
- Files: `tea_tree`, `tea_file_get`, `tea_file_change`, `tea_commit` (up to 50 files, text or base64, 3 MB in all), `tea_branch_create`.
- Files: `tea_tree`, `tea_file_get`, `tea_file_change`, `tea_file_edit` (exact-text replacements in one file, for a file too long to send whole; it answers with the new file's size and sha256), `tea_commit` (up to 50 files, text or base64, 3 MB in all), `tea_branch_create`.
- Domains: `tea_domains` (Cloudflare Registrar: exact names checked live, and suggestions, with prices).
- Actions: `tea_actions` (recent runs), `tea_action_logs` (a run's jobs, steps and log tails), `tea_workflow_dispatch`, `tea_secret_set` (never echoed), `tea_variable_set`.
+39 -1
View File
@@ -46,7 +46,7 @@ GITEA_OAUTH_CLIENT_SECRET = required_env("GITEA_OAUTH_CLIENT_SECRET")
JWT_SIGNING_KEY = required_env("JWT_SIGNING_KEY")
ALLOWED_HOSTS = [h.strip() for h in os.getenv("ALLOWED_HOSTS", "mcp.teahub.xyz,localhost").split(",") if h.strip()]
VERSION = "2.2.0"
VERSION = "2.3.0"
OAUTH_SCOPES = ["openid", "profile", "email"]
CLAUDE_CIMD_CLIENT_ID = "https://claude.ai/oauth/mcp-oauth-client-metadata"
CLAUDE_REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback"
@@ -143,6 +143,13 @@ class FileChange(BaseModel):
content_base64: str | None = None
class Edit(BaseModel):
"""One replacement in a text file: `old` occurs exactly once in the file as it stands (after the edits before it),
and `new` takes its place (empty removes it)."""
old: Annotated[str, Field(min_length=1, max_length=MAX_BYTES)]
new: Annotated[str, Field(max_length=MAX_BYTES)] = ""
class InputError(ToolError):
pass
@@ -595,6 +602,37 @@ async def commit(repo: str, message: str, files: list[FileChange], branch: str,
"files": {o["path"]: o["operation"] for o in ops}}
@mcp.tool(description="Edit one text file on a branch by exact-text replacements, as a commit by you: for a file too "
"long to send whole (tea_file_change takes 200 KB). The edits apply in order; each `old` must "
"occur exactly once in the file as it stands, and `new` takes its place. The answer gives the new "
"file's size and sha256, to check against your copy. Branch protection applies.",
annotations=WRITE)
async def tea_file_edit(repo: Repo, path: FilePath, edits: Annotated[list[Edit], Field(min_length=1, max_length=50)],
message: Message, branch: Ref = "") -> dict[str, Any]:
rp, p = repo_path(repo), file_path(path)
branch = branch or await default_branch(rp)
x = await gitea("GET", contents(rp, p), params={"ref": branch})
if not isinstance(x, dict) or x.get("type") != "file":
raise InputError(f"{p} is not a file")
try:
text = base64.b64decode(x.get("content") or "").decode("utf-8")
except (binascii.Error, ValueError):
raise InputError(f"{p} is not UTF-8 text") from None
for n, e in enumerate(edits, 1):
found = text.count(e.old)
if found != 1:
raise InputError(f"edit {n}: its old text occurs {found} times in {p}, not once")
text = text.replace(e.old, e.new, 1)
data = text.encode("utf-8")
if len(data) > MAX_COMMIT_BYTES:
raise InputError(f"{p} would come to more than {MAX_COMMIT_BYTES} bytes")
payload = {"files": [{"operation": "update", "path": p, "sha": x.get("sha"),
"content": base64.b64encode(data).decode()}], "message": message.strip(), "branch": branch}
c = (await gitea("POST", f"{rp}/contents", json=payload)).get("commit") or {}
return {"repo": repo, "branch": branch, "path": p, "edits": len(edits), "size": len(data),
"sha256": hashlib.sha256(data).hexdigest(), "commit": (c.get("sha") or "")[:12], "url": c.get("html_url")}
@mcp.tool(description="Create a branch from another branch, a tag or a commit (default: the default branch).",
annotations=WRITE)
async def tea_branch_create(repo: Repo, name: Branch, from_ref: Ref = "") -> dict[str, Any]:
+42 -2
View File
@@ -25,8 +25,8 @@ READS = {"tea_whoami", "tea_issues", "tea_issue_get", "tea_issue_comments", "tea
"tea_tree", "tea_file_get", "tea_actions", "tea_action_logs", "tea_domains"}
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"}
"tea_org_add_member", "tea_hook_create", "tea_file_change", "tea_file_edit", "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"}
@@ -346,6 +346,46 @@ class TeaMCP(unittest.IsolatedAsyncioTestCase):
self.assertEqual((created["files"][0]["operation"], created["branch"]), ("create", "trunk"))
self.assertEqual((updated["files"][0]["operation"], updated["branch"]), ("update", "dev"))
async def test_file_edit_replaces_exact_text_in_order_and_says_what_it_made(self):
import hashlib
self.gitea.files["notes.md"] = "one\ntwo — deux\nthree\n".encode("utf-8")
out = await self.call("tea_file_edit", repo="o/r", path="/notes.md", message=" edit ", edits=[
{"old": "two — deux", "new": "two"}, {"old": "two\nthree", "new": "two\nthree\nfour"}, {"old": "one\n"}])
payload = self.sent("POST", f"{R}/contents")[0][3]
(op,) = payload["files"]
self.assertEqual((payload["message"], payload["branch"], "new_branch" in payload), ("edit", "trunk", False))
self.assertEqual((op["operation"], op["path"], op["sha"]), ("update", "notes.md", "sha-notes.md"))
made = base64.b64decode(op["content"])
self.assertEqual(made, b"two\nthree\nfour\n")
self.assertEqual((out["path"], out["edits"], out["size"], out["sha256"], out["branch"], out["commit"]),
("notes.md", 3, len(made), hashlib.sha256(made).hexdigest(), "trunk", "c0ffeec0ffee"))
self.assertEqual(self.sent("GET", f"{R}/contents/notes.md")[0][2], {"ref": "trunk"}, "read from the branch it edits")
await self.call("tea_file_edit", repo="o/r", path="README.md", message="m", branch="dev", edits=[{"old": "hello", "new": "hi"}])
self.assertEqual((self.sent("GET", f"{R}/contents/README.md")[-1][2], self.sent("POST", f"{R}/contents")[-1][3]["branch"]),
({"ref": "dev"}, "dev"))
async def test_file_edit_refuses_what_it_cannot_do_exactly(self):
self.gitea.files["notes.md"] = b"one two one\n"
for edits, expected in [([{"old": "three"}], "edit 1: its old text occurs 0 times in notes.md, not once"),
([{"old": "one"}], "edit 1: its old text occurs 2 times in notes.md, not once"),
([{"old": "two", "new": "one"}, {"old": "one"}], "edit 2: its old text occurs 3 times")]:
with self.subTest(expected=expected):
self.assertIn(expected, await self.refused("tea_file_edit", repo="o/r", path="notes.md", message="m", edits=edits))
self.assertIn("is not a file", await self.refused("tea_file_edit", repo="o/r", path="docs", message="m", edits=[{"old": "x"}]))
self.assertIn("is not UTF-8 text", await self.refused("tea_file_edit", repo="o/r", path="img/logo.png", message="m",
edits=[{"old": "x"}]))
self.assertEqual(self.sent("POST"), [], "nothing was committed")
for args in [{"edits": []}, {"edits": [{"old": ""}]}, {"edits": [{"new": "x"}]}, {"edits": [{"old": "x"}], "path": "../x"}]:
with self.subTest(args=args):
await self.refused("tea_file_edit", **{"repo": "o/r", "path": "notes.md", "message": "m", **args})
cap, server.MAX_COMMIT_BYTES = server.MAX_COMMIT_BYTES, 10
try:
self.assertIn("more than 10 bytes", await self.refused("tea_file_edit", repo="o/r", path="notes.md", message="m",
edits=[{"old": "two", "new": "x" * 20}]))
finally:
server.MAX_COMMIT_BYTES = cap
self.assertEqual(self.sent("POST"), [])
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"})