First real port of the MCP chassis library — abort-criterion check for
spike S3 of the 2026-05 homelab architecture review.
Changes:
- Drop internal/auth/jwt.go (~79 LOC) — chassis provides JWTValidator
with identical signature.
- Drop internal/auth/bearer.go (~42 LOC) — chassis BearerMiddleware
has the same static-or-JWT semantics plus an optional WWW-Authenticate
resource_metadata challenge (consumed via new resourceMetadataURL arg).
- Drop internal/auth/bearer_test.go — same scenarios are covered in
the chassis bearer_test.go now.
- main.go: import chassis as `chassisauth`, build resourceMetadataURL
only when both DexIssuerURL + MCPResourceURL are set, replace the
inline /.well-known/oauth-protected-resource handler with the chassis
ProtectedResourceHandler.
internal/auth/caller.go (oauth2-proxy header → context) stays — chassis
out-of-scope.
Net LOC change: -~150 LOC duplicated infra + a 5-LOC import.
go.mod gains gitea.d-ma.be/mathias/mcp-chassis v0.1.0 (jwx/v2 + testify
already transitive, no new top-level deps).
Verifies abort criterion: one PR, one binary's worth of port, task check
green (lint + test + vet + govulncheck clean). Per the S3 spike spec,
this clears the chassis to continue. Next port: hyperguild/ingestion
(brain-mcp), filed as a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the *_list partners that the existing *_get tools have been
missing. Same pattern as repo_list — owner allowlisted, capLimit
helper for pagination, next_page surfaced when the page is full.
internal/gitea/issues.go:
- ListIssues(owner, repo, args) hitting
GET /api/v1/repos/{owner}/{repo}/issues with type=issues server-side
so PRs don't leak in (gitea conflates them on this endpoint).
- ListIssuesArgs struct: State, Labels, Since (ISO 8601), Page, Limit.
internal/gitea/workflows.go:
- ListWorkflowRuns(owner, repo, args) hitting
GET /api/v1/repos/{owner}/{repo}/actions/runs.
- Expanded WorkflowRun struct with DisplayTitle, Event, HeadSHA,
HeadBranch, WorkflowID, RunNumber, UpdatedAt, Actor so callers
can pin runs to a commit / branch without a second lookup.
- ListWorkflowRunsArgs: Branch, HeadSHA, Status, Event, Workflow,
Page, Limit. Status/Event 'all' treated as no-filter.
internal/tools/issue_list.go:
- Default state=open, default limit=30 (matches repo_list).
- next_page returned only when len(issues) == limit.
internal/tools/workflow_run_list.go:
- Default limit=10 (most common use is 'what just happened',
not paging).
- Returns runs + total + optional next_page.
Tests: table-driven for both — happy path, empty result, filter
combinations, allowlist rejection. workflow_run_list also asserts
the 'status=all is no-op' behavior (no query param emitted).
Closes#28Closes#29
Adds two MCP tools that PATCH /api/v1/repos/{owner}/{name}/issues/{number}
with {"state":"closed"} or {"state":"open"}. Both use a shared
SetIssueState helper on the gitea client.
- internal/gitea/issues.go: SetIssueState method using the existing
PatchJSON + MapStatus + json.Unmarshal pattern from GetIssue.
- internal/tools/issue_close.go: IssueClose tool. owner+name+number
args. Owner allowlist enforced. Returns the updated issue. Reversible
via issue_reopen, classified LOW risk.
- internal/tools/issue_reopen.go: mirror of IssueClose with
state="open". Same risk profile.
- Registered both tools in cmd/gitea-mcp/main.go.
- Tests for both: success (asserts PATCH method, path, body), 404,
and allowlist rejection — same shape as issue_get_test.go.
Closes#30
issue_get: GET /repos/{owner}/{repo}/issues/{number} — full issue with labels, assignees, comment count
release_create: POST /repos/{owner}/{repo}/releases — create release and tag in one call
repo_delete: DELETE /repos/{owner}/{repo} — confirm=<repo name> required, blocks accidents
repo_tree: GET /git/trees/{ref}?recursive=1 — full recursive file tree
repo_topics_update: PUT /repos/{owner}/{repo}/topics — replace topic list
file_read: detect array response and return descriptive error for dir paths
repo_create: POST /user/repos or /orgs/{org}/repos, is_org flag routes
repo_update: PATCH /repos/{owner}/{repo}, confirm required when private=false
repo_mirror_push: add/list/delete push mirrors, password never returned
Previously BearerMiddleware allowed requests with no Authorization
header to pass through whenever GITEA_MCP_DEFAULT_TOKEN was set. The
intent was "fall back to the service PAT for upstream Gitea calls,"
but the side effect was that anyone could hit /mcp anonymously and the
server would happily proxy requests as the service account.
Drop that path. Auth on /mcp now requires either:
- a valid Dex-issued JWT, or
- a Bearer matching GITEA_MCP_STATIC_TOKEN.
The Gitea service PAT (GITEA_MCP_DEFAULT_TOKEN) is no longer wired
into BearerMiddleware at all — it stays an upstream-client concern,
used by gitea.NewClient for outbound API calls only. This decouples
"can this caller invoke a tool" from "what credentials does the tool
use against Gitea".
Tests updated: drop the NoAuthHeader_WithDefault permissive case, add
NoAuthHeader_RejectsEvenWhenStaticConfigured to lock in the new
behavior.
Closes part of mathias/infra#2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- internal/auth/jwt.go: JWTValidator via lestrrat-go/jwx/v2, JWKS auto-refresh
- internal/auth/bearer.go: replace Gitea PAT validation with JWT->static->default chain
- internal/gitea/client.go: always use service PAT; remove TokenFromContext lookup
- internal/config/config.go: add DexIssuerURL, MCPAudience, MCPResourceURL, StaticToken
- cmd/gitea-mcp/main.go: wire validator, fix /.well-known to return real AS list
- bearer_test.go: rewrite for new API
Root cause confirmed (claude.ai sends no auth header); fallback token
is in place. Logging no longer needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
claude.ai connectors call the server with no Authorization header (confirmed
via request logging). Add a configurable default Gitea PAT so unauthenticated
clients (like claude.ai) can still reach the server.
Claude Code continues to pass per-request PATs; defaultToken="" preserves
the existing strict behaviour when the env var is unset.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Logs method, path, origin, has_auth, user_agent per request so we can
see exactly what claude.ai sends. Temporary; remove once root cause found.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Callers now supply their own Gitea PAT as a Bearer token; the server validates
it against GET /api/v1/user and threads it through context to all downstream
Gitea API calls. GITEA_API_TOKEN env var and the GiteaAPIToken config field are
removed.
Wires branch_list, branch_delete, branch_protection_get, pr_list,
pr_merge, dir_list, file_delete, tag_create, and repo_status into the
MCP server registry so they are discoverable and callable by agents.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements RFC 9728 protected resource metadata and HEAD probe so
claude.ai can complete its pre-handshake discovery without hitting 404.
- GET /.well-known/oauth-protected-resource → 200 {"authorization_servers":[]}
- GET /.well-known/oauth-authorization-server → 404 (no auth server)
- HEAD /mcp → 200 + MCP-Protocol-Version: 2025-06-18 header
Closes#2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Generates a new repo from mathias/template-go-web via Gitea's generate
API, then substitutes __PROJECT_NAME__ and __MODULE_PATH__ placeholders
in six known files (best-effort, partial failure surfaced in result).
Validates name regex, allowlist, template flag, and destination
non-existence before generating. Adds Template field to gitea.Repo.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Returns per-file unified diff for a PR, capped at 20KB/file and 200KB
total response. Files exceeding per-file cap report truncated+omitted_lines;
files that would push the response over 200KB go to omitted_files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SearchCode to gitea.Client and code_search MCP tool for single-repo
code search via GET /api/v1/repos/{owner}/{repo}/search?type=code.
Fan-out placeholder returns ErrValidation (lands in 7.3).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add BranchExists/CreateBranch/UpsertFile gitea client methods and the
file_write_branch MCP tool. Branch is auto-created from base (or repo
default_branch) when it doesn't exist; file is upserted via PUT contents.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds GetFileContents to the gitea client and a file_read MCP tool.
When ref is omitted, the tool resolves the repo default_branch via
GetRepo before fetching contents. Decoded content capped at 1 MiB.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements the Streamable HTTP transport: POST routing handles initialize
(issues session ID), tools/list, tools/call, and unknown methods; GET SSE
emits a keepalive comment then blocks on context cancellation. A minimal
registry stub is introduced so the server compiles and tools/list returns
an empty array until Phase 6+ registers real tools.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add internal/config package with Config struct and Load() function.
Reads GITEA_BASE_URL, GITEA_API_TOKEN, GITEA_MCP_ALLOWED_OWNERS,
GITEA_MCP_ORIGIN_ALLOWLIST, GITEA_MCP_PORT with sensible defaults.
Wire cfg.Port into main.go. TDD: tests written first, then impl.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Go module gitea.d-ma.be/mathias/gitea-mcp, minimal HTTP server with a
/healthz probe, Taskfile build targets, and .gitignore/README updates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>