Wire gitea-mcp into koala's local .mcp.json (Tailscale Serve) -- needs a NodePort first #33

Closed
opened 2026-08-29 06:58:31 +00:00 by mathias · 3 comments
Owner

gitea-mcp is live at git-mcp.d-ma.be (mathias/infra k3s/apps/gitea-mcp/) but isn't wired as a local plugin MCP for sessions running on koala itself, unlike brain/infra which already are. (Sessions on koala can currently reach gitea only via a claude.ai custom connector -- OAuth-based, works fine, different mechanism from this repo's local .mcp.json.)

Pattern to follow (.mcp.json in this repo)

Both existing entries proxy through Tailscale Serve on koala to a NodePort over loopback, not the public hostname -- avoids Cloudflare/NPM rate limits + message-size caps, satisfies default-deny-ingress NetworkPolicy with no cluster change (mathias/skills#24). Confirmed live:

tailscale serve status
https://koala.tail30472e.ts.net:8443 -> 127.0.0.1:30330  (brain)
https://koala.tail30472e.ts.net:8444 -> 127.0.0.1:30342  (infra)

gitea-mcp specifics -- unlike cad-mcp, this needs a small cluster change first

gitea-mcp's Service is ClusterIP only today -- confirmed live: kubectl get svc -n gitea-mcp gitea-mcp -> 8080/TCP, no NodePort assigned. The Tailscale-Serve-to-loopback pattern needs a NodePort to proxy to. Do in mathias/infra:

# k3s/apps/gitea-mcp/service.yaml
spec:
  type: NodePort   # add this
  ports:
    - name: http
      port: 8080
      targetPort: 8080
      # nodePort: <let k8s assign, or pick explicitly like cad did with 30083>

commit to mathias/infra main (Flux-managed, reconciles in ~60s), then kubectl get svc -n gitea-mcp gitea-mcp to read the assigned NodePort.

Do

  1. Add the NodePort to gitea-mcp's Service (infra repo, one commit, kubectl diff first per that repo's own convention).
  2. tailscale serve --bg --https=8446 http://127.0.0.1:<assigned-nodeport> on koala.
  3. Add to .mcp.json:
    "gitea": {
      "type": "http",
      "url": "https://koala.tail30472e.ts.net:8446/mcp",
      "headers": { "Authorization": "Bearer ${GITEA_MCP_TOKEN}" }
    }
    
    GITEA_MCP_TOKEN should already be sourced on koala (used by other MCP clients per infra#39's credential inventory) -- confirm before assuming it needs minting.

Risk to check BEFORE wiring, not after

brain is currently in .mcp.json's _disabled block: "parked until hyperguild#9 fixes static Bearer precedence -- claude MCP client does OAuth discovery on 401 and ignores the header, breaking on Dex (no dynamic client registration)". gitea-mcp's deployment wires the same dual-auth shape (Authentik OAuth for the claude-ai app issuer, alongside a static-bearer fallback via GITEA_MCP_STATIC_TOKEN/GITEA_MCP_DEFAULT_TOKEN) -- the same structural conflict that broke brain's static-bearer path may apply here too when accessed as a local plugin (as opposed to the working claude.ai OAuth connector, which is a different, already-proven-working path). Verify a static-bearer local-plugin connection actually authenticates end-to-end before calling this done.

Acceptance

  • gitea-mcp Service has a live NodePort, applied via the normal infra GitOps flow (not a manual kubectl patch).
  • claude mcp list shows gitea connected via the Tailscale Serve URL, not erroring into an OAuth redirect.
  • At least one gitea tool (e.g. repo_list) callable from a koala session through this new local wiring.
  • If blocked by the OAuth-discovery bug, this issue says so explicitly and links hyperguild#9 rather than staying vague.
gitea-mcp is live at `git-mcp.d-ma.be` (`mathias/infra` `k3s/apps/gitea-mcp/`) but isn't wired as a local plugin MCP for sessions running on koala itself, unlike `brain`/`infra` which already are. (Sessions on koala can currently reach gitea only via a claude.ai custom connector -- OAuth-based, works fine, different mechanism from this repo's local `.mcp.json`.) ## Pattern to follow (`.mcp.json` in this repo) Both existing entries proxy through Tailscale Serve on koala to a NodePort over loopback, not the public hostname -- avoids Cloudflare/NPM rate limits + message-size caps, satisfies default-deny-ingress NetworkPolicy with no cluster change (mathias/skills#24). Confirmed live: ``` tailscale serve status https://koala.tail30472e.ts.net:8443 -> 127.0.0.1:30330 (brain) https://koala.tail30472e.ts.net:8444 -> 127.0.0.1:30342 (infra) ``` ## gitea-mcp specifics -- unlike cad-mcp, this needs a small cluster change first `gitea-mcp`'s Service is `ClusterIP` only today -- confirmed live: `kubectl get svc -n gitea-mcp gitea-mcp` -> `8080/TCP`, no NodePort assigned. The Tailscale-Serve-to-loopback pattern needs a NodePort to proxy to. Do in `mathias/infra`: ```yaml # k3s/apps/gitea-mcp/service.yaml spec: type: NodePort # add this ports: - name: http port: 8080 targetPort: 8080 # nodePort: <let k8s assign, or pick explicitly like cad did with 30083> ``` commit to `mathias/infra` main (Flux-managed, reconciles in ~60s), then `kubectl get svc -n gitea-mcp gitea-mcp` to read the assigned NodePort. ## Do 1. Add the NodePort to gitea-mcp's Service (infra repo, one commit, `kubectl diff` first per that repo's own convention). 2. `tailscale serve --bg --https=8446 http://127.0.0.1:<assigned-nodeport>` on koala. 3. Add to `.mcp.json`: ```json "gitea": { "type": "http", "url": "https://koala.tail30472e.ts.net:8446/mcp", "headers": { "Authorization": "Bearer ${GITEA_MCP_TOKEN}" } } ``` `GITEA_MCP_TOKEN` should already be sourced on koala (used by other MCP clients per infra#39's credential inventory) -- confirm before assuming it needs minting. ## Risk to check BEFORE wiring, not after `brain` is currently in `.mcp.json`'s `_disabled` block: *"parked until hyperguild#9 fixes static Bearer precedence -- claude MCP client does OAuth discovery on 401 and ignores the header, breaking on Dex (no dynamic client registration)"*. gitea-mcp's deployment wires the same dual-auth shape (Authentik OAuth for the `claude-ai` app issuer, alongside a static-bearer fallback via `GITEA_MCP_STATIC_TOKEN`/`GITEA_MCP_DEFAULT_TOKEN`) -- the same structural conflict that broke brain's static-bearer path may apply here too when accessed as a local plugin (as opposed to the working claude.ai OAuth connector, which is a different, already-proven-working path). Verify a static-bearer local-plugin connection actually authenticates end-to-end before calling this done. ## Acceptance - gitea-mcp Service has a live NodePort, applied via the normal infra GitOps flow (not a manual kubectl patch). - `claude mcp list` shows `gitea` connected via the Tailscale Serve URL, not erroring into an OAuth redirect. - At least one gitea tool (e.g. `repo_list`) callable from a koala session through this new local wiring. - If blocked by the OAuth-discovery bug, this issue says so explicitly and links hyperguild#9 rather than staying vague.
Author
Owner

Two of this issue's premises are stale, and the real blocker is a third thing. Serve port is up; wiring is parked.

The NodePort already exists — no infra change needed

This issue says gitea-mcp's Service is ClusterIP-only and step 1 is a mathias/infra commit. Live state:

$ kubectl get svc -n gitea-mcp
NAME                 TYPE        CLUSTER-IP      PORT(S)
gitea-mcp            ClusterIP   10.43.253.121   8080/TCP
gitea-mcp-nodeport   NodePort    10.43.19.4      8080:30340/TCP     93d

A separate gitea-mcp-nodeport Service has existed for 93 days. Step 1 is already done, by a different shape than proposed — a second Service rather than a type change on the first. Worth knowing which is intended before anyone "fixes" it.

Done

tailscale serve --bg --https=8446 http://127.0.0.1:30340 (run by Mathias on koala). Unauthenticated probe → 401, so the path is right and auth is enforced.

Blocked, and not on the token this issue expected

The issue says GITEA_MCP_TOKEN "should already be sourced on koala — confirm before assuming it needs minting". It is sourced, and it is the wrong token:

$ op run --env-file ~/.op-env -- curl ... -H "Authorization: Bearer $GITEA_MCP_TOKEN" .../mcp
401
unauthorized

The server takes its static token from a different item. From the ExternalSecret:

gitea-mcp-static-token: remoteRef.key=gitea-mcp-shared-token, property=password
                        -> secretKey GITEA_MCP_STATIC_TOKEN

~/.op-env's GITEA_MCP_TOKEN points at op://HomeLab/GITEA_MCP_TOKEN/password — a different item entirely. So the confirmation this issue asked for has an answer, and it is "no".

And the correct item is not reachable from here either:

$ op vault list
uormr2z26h5zz3nlbgarfcnx24    HomeLab          # and nothing else

$ op run --env-file=<(echo 'T=op://HomeLab/gitea-mcp-shared-token/password') -- ...
[ERROR] could not find item gitea-mcp-shared-token in vault uormr2z26h5zz3nlbgarfcnx24

It lives in AgentSecrets, which koala's op identity cannot see — infra#224, the same blocker as #32. No secret value was retrieved, printed or transformed; the item names come from ExternalSecret references, and the in-cluster secret was deliberately not read.

Not the OAuth bug

This issue and #32 both flagged the static-Bearer-vs-OAuth conflict that parked brain as the likely wall. Two things argue otherwise: brain is no longer parked — it is live in mcpServers and its tools worked all session — and the 401 here is a plain token rejection with body unauthorized, not an OAuth challenge. Not conclusive until a valid token is tried, since a client that ignored a good header would look the same from outside, but the evidence points at the vault, not the protocol.

Recorded, not enabled

.mcp.json gains a _parked block (7ba01ab) with the entry and the blocker inline, referencing ${GITEA_MCP_STATIC_TOKEN} rather than the misleading GITEA_MCP_TOKEN. A live entry that 401s would fail on every session start.

To finish:

  1. Resolve infra#224, or expose gitea-mcp-shared-token via HomeLab.
  2. GITEA_MCP_STATIC_TOKEN=op://<vault>/gitea-mcp-shared-token/password in ~/.op-env.
  3. Move the entry into mcpServers.
  4. Then the acceptance test: repo_list through the Tailscale Serve URL.

Also worth deciding separately: what GITEA_MCP_TOKEN in ~/.op-env is actually for, since it does not authenticate against this server. Either it serves another client or it is dead config, and dead credential config is its own small hazard.

Two of this issue's premises are stale, and the real blocker is a third thing. Serve port is up; wiring is parked. ## The NodePort already exists — no infra change needed This issue says `gitea-mcp`'s Service is ClusterIP-only and step 1 is a `mathias/infra` commit. Live state: ``` $ kubectl get svc -n gitea-mcp NAME TYPE CLUSTER-IP PORT(S) gitea-mcp ClusterIP 10.43.253.121 8080/TCP gitea-mcp-nodeport NodePort 10.43.19.4 8080:30340/TCP 93d ``` A separate `gitea-mcp-nodeport` Service has existed for 93 days. Step 1 is already done, by a different shape than proposed — a second Service rather than a type change on the first. Worth knowing which is intended before anyone "fixes" it. ## Done `tailscale serve --bg --https=8446 http://127.0.0.1:30340` (run by Mathias on koala). Unauthenticated probe → **401**, so the path is right and auth is enforced. ## Blocked, and not on the token this issue expected The issue says `GITEA_MCP_TOKEN` "should already be sourced on koala — confirm before assuming it needs minting". It is sourced, and it is the **wrong token**: ``` $ op run --env-file ~/.op-env -- curl ... -H "Authorization: Bearer $GITEA_MCP_TOKEN" .../mcp 401 unauthorized ``` The server takes its static token from a different item. From the ExternalSecret: ``` gitea-mcp-static-token: remoteRef.key=gitea-mcp-shared-token, property=password -> secretKey GITEA_MCP_STATIC_TOKEN ``` `~/.op-env`'s `GITEA_MCP_TOKEN` points at `op://HomeLab/GITEA_MCP_TOKEN/password` — a different item entirely. So the confirmation this issue asked for has an answer, and it is "no". And the correct item is not reachable from here either: ``` $ op vault list uormr2z26h5zz3nlbgarfcnx24 HomeLab # and nothing else $ op run --env-file=<(echo 'T=op://HomeLab/gitea-mcp-shared-token/password') -- ... [ERROR] could not find item gitea-mcp-shared-token in vault uormr2z26h5zz3nlbgarfcnx24 ``` It lives in **AgentSecrets**, which koala's `op` identity cannot see — **infra#224**, the same blocker as #32. No secret value was retrieved, printed or transformed; the item names come from ExternalSecret references, and the in-cluster secret was deliberately not read. ## Not the OAuth bug This issue and #32 both flagged the static-Bearer-vs-OAuth conflict that parked `brain` as the likely wall. Two things argue otherwise: `brain` is no longer parked — it is live in `mcpServers` and its tools worked all session — and the 401 here is a plain token rejection with body `unauthorized`, not an OAuth challenge. Not conclusive until a valid token is tried, since a client that ignored a good header would look the same from outside, but the evidence points at the vault, not the protocol. ## Recorded, not enabled `.mcp.json` gains a `_parked` block (`7ba01ab`) with the entry and the blocker inline, referencing `${GITEA_MCP_STATIC_TOKEN}` rather than the misleading `GITEA_MCP_TOKEN`. A live entry that 401s would fail on every session start. To finish: 1. Resolve infra#224, or expose `gitea-mcp-shared-token` via HomeLab. 2. `GITEA_MCP_STATIC_TOKEN=op://<vault>/gitea-mcp-shared-token/password` in `~/.op-env`. 3. Move the entry into `mcpServers`. 4. Then the acceptance test: `repo_list` through the Tailscale Serve URL. Also worth deciding separately: what `GITEA_MCP_TOKEN` in `~/.op-env` is actually for, since it does not authenticate against this server. Either it serves another client or it is dead config, and dead credential config is its own small hazard.
Author
Owner

Correcting my previous comment on the blocker, and confirming the token finding — which turns out to be a known, already-diagnosed structural issue rather than a new one.

gitea-mcp authenticates

koala carries a second scoped service account at ~/.op-agent-write.env (koala-agentsecrets-write), provisioned when infra#224 was closed on 2026-08-04. Citing #224 as an open blocker was wrong; it is closed, and by design rather than by neglect.

$ set -a; . ~/.op-agent-write.env; set +a
$ op run --env-file=/tmp/probe.env -- curl -X POST .../8446/mcp \
    -H "Authorization: Bearer $GITEA_MCP_STATIC_TOKEN" ...
200
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"protocolVersion":"2025-03-26",
 "serverInfo":{"name":"gitea-mcp","version":"0.1.0"}}}

So: NodePort exists (93 days), Serve proxy works, static bearer authenticates, no OAuth wall. Everything this issue set out to verify, verified.

The GITEA_MCP_TOKEN 401 has a documented cause

I reported it as "the wrong token, possibly dead config". The brain has the actual history — a 2026-07-30 entry recorded during infra#228's rotation:

GITEA_MCP_TOKEN (client bearer, consumed by pi-runner/crush-runner/agentsquad-serve) and GITEA_MCP_STATIC_TOKEN (gitea-mcp's own server-side compare value) were two separate 1Password HomeLab items that happened to need the identical value for the static-bearer auth scheme to work. [...] Every rotation required remembering to edit both items to the same new value; nothing enforced it.

That was fixed by pointing every consumer at one item, and moving it to AgentSecrets — correct on both counts. What did not follow was ~/.op-env, which still references the old HomeLab GITEA_MCP_TOKEN item. The item still exists, so the reference resolves cleanly and then fails auth. A stale credential that resolves and silently 401s is worse than an absent one: it reads as configured, which is exactly how I read it an hour ago.

Cleanup is on mathias/infra#329 along with the real blocker.

What is actually blocking

Not the token — the delivery. No single identity on koala reads both vaults (deliberately, per #224's resolution), and the session env comes from one op run --env-file ~/.op-env under the HomeLab-only token. Appending an AgentSecrets reference does not fail gracefully:

$ op run --env-file=/tmp/mixed.env -- sh -c '[ -n "$BRAIN_MCP_TOKEN" ] && echo resolved'
[ERROR] "AgentSecrets" isn't a vault in this account.

op run aborts the whole file — brain would go down with it. Tested before proposing, on a copy, precisely because that is the obvious next move for whoever picks this up.

.mcp.json's _parked entry now names ${GITEA_MCP_STATIC_TOKEN} with the corrected reason (c377c23). Moving it into mcpServers is one env-var away.

Correcting my previous comment on the blocker, and confirming the token finding — which turns out to be a known, already-diagnosed structural issue rather than a new one. ## gitea-mcp authenticates koala carries a second scoped service account at `~/.op-agent-write.env` (`koala-agentsecrets-write`), provisioned when infra#224 was closed on 2026-08-04. Citing #224 as an open blocker was wrong; it is closed, and by design rather than by neglect. ``` $ set -a; . ~/.op-agent-write.env; set +a $ op run --env-file=/tmp/probe.env -- curl -X POST .../8446/mcp \ -H "Authorization: Bearer $GITEA_MCP_STATIC_TOKEN" ... 200 {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"protocolVersion":"2025-03-26", "serverInfo":{"name":"gitea-mcp","version":"0.1.0"}}} ``` So: NodePort exists (93 days), Serve proxy works, static bearer authenticates, no OAuth wall. Everything this issue set out to verify, verified. ## The `GITEA_MCP_TOKEN` 401 has a documented cause I reported it as "the wrong token, possibly dead config". The brain has the actual history — a 2026-07-30 entry recorded during infra#228's rotation: > `GITEA_MCP_TOKEN` (client bearer, consumed by pi-runner/crush-runner/agentsquad-serve) and `GITEA_MCP_STATIC_TOKEN` (gitea-mcp's own server-side compare value) were two *separate* 1Password HomeLab items that happened to need the identical value for the static-bearer auth scheme to work. [...] Every rotation required remembering to edit both items to the same new value; nothing enforced it. That was fixed by pointing every consumer at **one** item, and moving it to AgentSecrets — correct on both counts. What did not follow was `~/.op-env`, which still references the old HomeLab `GITEA_MCP_TOKEN` item. The item still exists, so the reference resolves cleanly and then fails auth. A stale credential that resolves and silently 401s is worse than an absent one: it reads as configured, which is exactly how I read it an hour ago. Cleanup is on **mathias/infra#329** along with the real blocker. ## What is actually blocking Not the token — the delivery. No single identity on koala reads both vaults (deliberately, per #224's resolution), and the session env comes from one `op run --env-file ~/.op-env` under the HomeLab-only token. Appending an AgentSecrets reference does not fail gracefully: ``` $ op run --env-file=/tmp/mixed.env -- sh -c '[ -n "$BRAIN_MCP_TOKEN" ] && echo resolved' [ERROR] "AgentSecrets" isn't a vault in this account. ``` `op run` aborts the whole file — `brain` would go down with it. Tested before proposing, on a copy, precisely because that is the obvious next move for whoever picks this up. `.mcp.json`'s `_parked` entry now names `${GITEA_MCP_STATIC_TOKEN}` with the corrected reason (`c377c23`). Moving it into `mcpServers` is one env-var away.
Author
Owner

Done in v0.19.0 (da76a6b).

Both stated prerequisites turned out to already be satisfied:

  • "needs a NodePort first" — gitea-mcp/gitea-mcp-nodeport has exposed 8080:30340 for 102 days, and tailscale serve already maps 8446 -> 127.0.0.1:30340. Nothing had to be provisioned.
  • "nothing puts its token in the session environment" (the _parked reason, pointing at the AgentSecrets split from infra#224) — this never applied to gitea-mcp. It authenticates by pass-through: the bearer is a Gitea PAT, which the server validates against Gitea and then acts as. There is no dedicated MCP token to be missing. DMABE_GITEA_API_TOKEN is op://HomeLab, already in the session env.

Evidence, koala 2026-09-07 against https://koala.tail30472e.ts.net:8446/mcp:

bearer result
none 401
GITEA_MCP_TOKEN 401
DMABE_GITEA_API_TOKEN 200, serverInfo: gitea-mcp, tools/list → 41 tools
DMABE_GITEA_API_GITOPS_TOKEN 200

Two different PATs both authenticating while a third bearer 401s is what proves pass-through validation rather than a static-token comparison — a static token would admit exactly one value.

Also lands bin/check-mcp-env + tests/mcp_config_test.sh, wired into task check, so a server interpolating a variable the env-file does not define fails the gate instead of failing silently at runtime as "requires authentication".

Done in v0.19.0 (`da76a6b`). Both stated prerequisites turned out to already be satisfied: - **"needs a NodePort first"** — `gitea-mcp/gitea-mcp-nodeport` has exposed `8080:30340` for 102 days, and `tailscale serve` already maps `8446 -> 127.0.0.1:30340`. Nothing had to be provisioned. - **"nothing puts its token in the session environment"** (the `_parked` reason, pointing at the AgentSecrets split from infra#224) — **this never applied to gitea-mcp.** It authenticates by *pass-through*: the bearer is a Gitea PAT, which the server validates against Gitea and then acts as. There is no dedicated MCP token to be missing. `DMABE_GITEA_API_TOKEN` is `op://HomeLab`, already in the session env. Evidence, koala 2026-09-07 against `https://koala.tail30472e.ts.net:8446/mcp`: | bearer | result | |---|---| | none | 401 | | `GITEA_MCP_TOKEN` | 401 | | `DMABE_GITEA_API_TOKEN` | 200, `serverInfo: gitea-mcp`, `tools/list` → **41 tools** | | `DMABE_GITEA_API_GITOPS_TOKEN` | 200 | Two *different* PATs both authenticating while a third bearer 401s is what proves pass-through validation rather than a static-token comparison — a static token would admit exactly one value. Also lands `bin/check-mcp-env` + `tests/mcp_config_test.sh`, wired into `task check`, so a server interpolating a variable the env-file does not define fails the gate instead of failing silently at runtime as "requires authentication".
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/skills#33