12 KiB
gitea-mcp
The first sherlock MCP. Wraps a small slice of the Gitea REST API
(whoami, list_repos) using a Gitea-issued OAuth2 bearer token.
How auth works
Gitea has two relevant features that share the word "OAuth":
- Gitea as OAuth client — Gitea logs users into its web UI via Authentik (your homelab SSO). This is how human accounts get provisioned the first time someone visits Gitea.
- Gitea as OAuth server — Gitea is itself an OAuth 2.0 / OIDC provider that third-party apps can authenticate against. The resulting access token is accepted by Gitea's own REST API.
gitea-mcp uses (2). The flow does not touch Authentik at all from
sherlock's perspective — sherlock OAuths directly against Gitea using
PKCE, gets a Gitea-minted bearer token, and uses it as
Authorization: Bearer <token> on /api/v1/... calls. The fact that
Gitea behind the scenes might bounce you through Authentik for SSO is
invisible to sherlock.
This is deliberate. Going through Authentik would require Gitea to trust Authentik-issued bearer tokens at its API layer, which Gitea doesn't do out of the box. Going through Gitea's own OAuth2 server is the documented, supported path.
Known Gitea scope limitation
Gitea's OAuth2 server does not honour the full scope list sherlock
requests. We send openid profile email read:user read:repository read:issue read:organization read:package, but Gitea silently grants
only read:repository read:user regardless. Verified end-to-end
2026-05-28 against gitea.alexandru.macocian.me: the token's scope
attribute on /login/oauth/access_token comes back narrowed, and any
subsequent call into an ungranted area returns:
HTTP 403: token does not have at least one of required scope(s),
required=[read:issue|read:organization|read:package],
token scope=read:repository,read:user
Affected tools (every one returns 403 with this token, regardless of
how many times you sherlock logout gitea and re-auth):
- Issues / PRs:
list_issues,get_issue,list_issue_comments(PRs themselves go throughread:repositoryand do work). - Organisations:
list_my_orgs,get_org,list_org_repos,list_org_members,list_org_teams,search_org_teams,list_org_activity_feed,list_org_workflow_runs,list_org_workflow_jobs,list_org_runners,get_org_runner. - Packages:
list_packages.
This is a Gitea-side issue (the OAuth2 server doesn't surface those scopes on the consent screen, so the user can't grant them even if they wanted to). Workarounds we may consider later:
- Use a long-lived PAT scoped explicitly to issue/org/package read
instead of an OAuth token for these tool families (would require a
second wallet entry and a
--use-patknob on the MCP). - Patch / upgrade Gitea once upstream wires these scopes into the OAuth2 consent flow.
- Drop the affected tools from the surface and document the gap.
Until one of those lands, those tools stay registered but will 403 at call time. The MCP itself does not refuse to start.
One-time setup
- Sign in to https://gitea.alexandru.macocian.me as the operator.
- Settings → Applications → Manage OAuth2 Applications → New
Application:
- Application Name:
sherlock - Redirect URIs:
http://127.0.0.1:6990/callback - Confidential Client: ⬜ UNCHECK — sherlock uses PKCE,
not a client secret. Leaving this checked makes Gitea demand a
client_secreton the token request, which sherlock never sends.
- Application Name:
- Click Create Application.
- Copy the Client ID Gitea displays.
Build & install
For Charlie (default — Client ID embedded in the binary):
cd ~/Dev/charlie/sherlock
go install ./cmd/gitea-mcp
That's it. No -ldflags, no flags at all. The Charlie Gitea OAuth2
app's Client ID lives in cmd/gitea-mcp/gitea_clientid_charlie.go
and is baked in unless you build with -tags noembed.
For other deployments:
go build -tags noembed \
-ldflags "-X main.giteaIssuer=https://gitea.example.com \
-X main.giteaClientID=<CLIENT_ID> \
-X main.giteaBaseURL=https://gitea.example.com" \
./cmd/gitea-mcp
Modern Gitea (≥ 1.16-ish) accepts PKCE-alone for OAuth2 applications that have the "Confidential Client" checkbox unchecked, so no client secret is needed even though Gitea generates one in the UI.
Knobs
Variable / -X main.… |
Charlie default |
|---|---|
giteaIssuer |
https://gitea.alexandru.macocian.me |
giteaBaseURL |
https://gitea.alexandru.macocian.me |
giteaClientID |
embedded; see gitea_clientid_charlie.go |
giteaClientSecret |
"" (only needed for older / confidential Gitea apps) |
Version |
0.0.0-dev |
When pointing at a different Gitea deployment, override giteaIssuer,
giteaBaseURL, and giteaClientID together (with -tags noembed).
Sherlock treats the wallet entry as service gitea regardless of
which deployment the token came from, so don't mix.
If
gitea-mcp --probeever hitsinvalid_clientor similar after you click Authorize in the browser, your Gitea is enforcing client auth on token exchange. Rebuild with-X main.giteaClientSecret=<the secret>to include it. The secret is baked into the local binary, never written to the source tree.
Verify (without an agent)
gitea-mcp --probe
First run: opens a browser, you click through Gitea's "Authorize sherlock" page (which itself goes through Authentik SSO if you're not already signed in), browser shows "Logged in. You may close this tab.", terminal prints:
OK: logged in to https://gitea.alexandru.macocian.me as <login> <<email>>
Subsequent runs are silent until the refresh window opens.
To force a re-login: sherlock logout gitea.
Use with an agent
sherlock copilot (or sherlock claude) automatically renders an MCP
config that lists gitea-mcp. Copilot spawns it as a stdio
subprocess; the first call into it triggers the OAuth flow described
above (browser pops while you're in the middle of a chat — accept
once, never again).
Tools exposed (all read-only; write tools land in a follow-up):
User
| Tool | Description |
|---|---|
whoami |
Authenticated Gitea user. |
Repos & content
| Tool | Description |
|---|---|
list_repos |
Search/list repos accessible to the user. |
get_file |
Read file contents (≤ 256 KiB, truncated tail). |
list_dir |
List one directory level at a given ref. |
get_tree |
Recursive listing of files/dirs (≤ 2000 entries) at a given ref. |
file_history |
Commits touching a specific file. |
list_branches |
Branches of a repo. |
get_branch |
One branch's details + tip SHA. |
list_tags |
Tags of a repo. |
get_tag |
One tag's details. |
Commits
| Tool | Description |
|---|---|
list_commits |
Repo commit history with date / SHA filters. |
get_commit |
Single commit by SHA (full message, parents). |
get_commit_status |
Combined CI / status check state for a ref. |
Issues
| Tool | Description |
|---|---|
list_issues |
Issues with state/labels/type filters. |
get_issue |
One issue or PR by index. |
list_issue_comments |
Comments on a single issue / PR. |
Pull requests
| Tool | Description |
|---|---|
list_pulls |
PRs with state/sort/labels filters. |
get_pull |
Full PR details incl. requested reviewers. |
list_pull_files |
Files changed by a PR (counts; no diffs). |
list_pull_reviews |
Reviews on a PR (state, body, reviewer). |
Releases
| Tool | Description |
|---|---|
list_releases |
Releases of a repo (drafts & pre-releases opt-in). |
get_release |
One release by ID, incl. assets. |
Actions (CI)
| Tool | Description |
|---|---|
list_workflow_runs |
Per-repo pipeline runs. |
list_workflow_jobs |
Jobs of a specific run. |
get_job_logs |
Tail (100 KiB) of a job's log output. |
list_org_workflow_runs |
All runs across an org's repos. |
list_org_workflow_jobs |
All jobs across an org's runs. |
list_org_runners |
Self-hosted runners registered to an org. |
get_org_runner |
Details for a single org runner. |
Packages
| Tool | Description |
|---|---|
list_packages |
Packages owned by a user/org (type + name). |
Wiki
| Tool | Description |
|---|---|
list_wiki_pages |
Wiki page titles/slugs/last-update. |
get_wiki_page |
One wiki page content. |
Organisations
| Tool | Description |
|---|---|
list_my_orgs |
Orgs the authenticated user is a member of. |
get_org |
One org's details. |
list_org_repos |
Repos owned by an org. |
list_org_members |
Members of an org. |
list_org_teams |
Teams of an org. |
search_org_teams |
Search teams in an org by name substring. |
list_org_activity_feed |
Recent activity feed of an org. |
Write operations and admin-scoped tools (admin runners, admin
workflow runs, admin org management) land in a separate
gitea-mcp-admin MCP later so the per-tool permission surface stays
small.
Troubleshooting
| Symptom | Cause |
|---|---|
gitea-mcp: giteaClientID not configured. |
Built without -ldflags "-X main.giteaClientID=...". See above. |
| Browser opens, Gitea says "Client ID not registered" | Either the OAuth2 application was never created in Gitea, or the Client ID was mistyped. |
| Browser opens, Gitea says "client_secret required" | The OAuth2 application has "Confidential Client" checked. Edit it and uncheck. |
gitea: 401 (token rejected ...) |
Token was revoked or the wallet has stale tokens for a different Gitea deployment. Run sherlock logout gitea and retry. |
bind: address already in use |
A previous OAuth flow (or another MCP) still holds 127.0.0.1:6990. Wait a few seconds or kill the stuck process. |
| EOF | |
| echo "gitea-mcp doc created" |