[EPIC] Install forgejo-mcp on the Guest as a LAN-reachable Service #17

Closed
opened 2026-10-06 12:02:18 +00:00 by pit · 1 comment
Owner

Problem Statement

An Agent reaches the Forgejo instance at forgejo.thepit.space only by shelling out to curl + jq against the Gitea-style REST API, following docs/agents/issue-tracker.md. That convention works, but it is a document, not a mechanism: every Agent must read it, compose raw HTTP by hand, and parse JSON with no schema. Nothing type-checks the calls, and nothing bounds what an Agent may do to the tracker beyond the token it happens to hold — today the same broad Admin-scoped token in ~/.config/forgejo/.env, with no split between "read an issue" and "rewrite the repository".

Solution

Run the upstream forgejo-mcp MCP server as a Service on the forgejo Guest, LAN-reachable at http://10.12.0.141:8089/mcp, provisioned by a new Ansible role and pinned like the other two Services (Forgejo, the Actions runner).

The endpoint is credential-less. In its default passthrough auth mode every request must carry its own Forgejo token, and a request without one is refused 401, so the Service holds no credential that could be stolen from it. An Agent on the LAN points its MCP client at the endpoint and presents a token scoped to its job — the token, not the server, is the capability boundary.

User Stories

  1. As an Agent, I want to discover Forgejo's issue and PR operations as typed MCP tools, so that I stop hand-composing REST calls from a document.
  2. As an Agent, I want to list issues in pit/infra-forge, so that I can find the ticket I am working on.
  3. As an Agent, I want to read an issue with its comments, so that I have the full context of a ticket.
  4. As an Agent, I want to open an issue, so that I can file a finding without a human relaying it.
  5. As an Agent, I want to comment on an issue, so that I can report progress on a ticket.
  6. As an Agent, I want to read and add issue labels, so that triage stays in the tracker rather than in a chat.
  7. As an Agent, I want to read pull requests and their diffs, so that I can review a proposed change.
  8. As an Agent, I want to browse repository files and their contents, so that I can ground a change in the real tree.
  9. As an Agent, I want to read Actions workflow runs and their logs, so that I can tell whether a change went green.
  10. As the Operator, I want each Agent to present its own token, so that no single credential is shared across the fleet.
  11. As the Operator, I want the Agent token scoped to write:issue, so that an Agent can triage issues but cannot push code, merge a PR, or change repository settings.
  12. As the Operator, I want the Agent token to be denied the repository write category, so that the blast radius of a leaked Agent token stops at issues.
  13. As the Operator, I want the endpoint to hold no Forgejo credential of its own, so that compromising the Service yields nothing an attacker can reuse against Forgejo.
  14. As the Operator, I want the endpoint reachable only from 10.12.0.0/24, so that the homelab's agent interface is not addressable from the internet.
  15. As the Operator, I want a refused, unauthenticated request to be answered 401, so that I can use a probe as a liveness check.
  16. As the Operator, I want the Service to refuse to start if it is bound to a non-loopback address with no host allowlist, so that it can never come up wide open by accident.
  17. As the Operator, I want the running version pinned in one place, so that a re-provision reproduces the exact binary.
  18. As the Operator, I want the binary fetched checksum-first against a literal pinned hash, so that a moved upstream tag cannot silently swap the artifact.
  19. As the Operator, I want the playbook to be idempotent, so that a second run reports zero changes.
  20. As the Operator, I want the Service managed by systemd and restarted on config change, so that it survives a reboot and picks up a new pin.
  21. As the Operator, I want to rehearse the role on the PRE Guest, so that a broken unit never reaches Prod.
  22. As the Operator, I want the Service to talk to Forgejo over loopback, so that it keeps working even if the Edge is down.
  23. As the Operator, I want the Service to take no Edge entry, certificate, or router rule, so that the LAN-only promise is kept by construction.
  24. As the Operator, I want the Agent side (client package, client config, restart) written down as a runbook section rather than provisioned, so that the workstation stays out of this repo's scope.

Implementation Decisions

Shape. A new Ansible role, separate from the forgejo role, with its own play in site.yml. The forgejo role is a six-section linear flow whose handler definition order is a load-bearing contract (the runner restart must land after the Forgejo restart); a second Service as its own role leaves that contract untouched and gives the new pin a convention-4 home.

Packaging and pinning. The release tarball forgejo-mcp_3.2.0_linux_amd64.tar.gz from the upstream forge git.b4mad.industries, pinned in the role defaults alongside its version and port (convention 4: one pin per Service). Upstream publishes a single multi-artifact forgejo-mcp_3.2.0_checksums.txt and no per-file .sha256, so "checksum-first" here means pinning the literal sha256 of the archive (bf8f744d53dd06c0e7830ee13a0507464b3ab301fcf01de4744db03d770039df, verified by download-and-hash) rather than pointing at a remote checksum URL — a stronger pin than the service binary's pattern, not a weaker one. The signed OCI image was rejected: it is linux/amd64 only and would introduce a persistent-container pattern into a repo whose only Docker use today is ephemeral Actions job containers.

Transport. Streamable HTTP (/mcp), not SSE (upstream's own "legacy" label) and not stdio. The Agent client path is mcp.client.streamable_http.

Binding and identity of the endpoint. The Service binds the Guest's interface (0.0.0.0) and declares a host allowlist of 10.12.0.141. A bare allowlist entry matches any port, so the single entry suffices. This is fail-closed: upstream refuses to start when bound to a non-loopback address with no allowlist, and answers 403 to a request whose Host is not declared. No --allowed-origins is set: a non-browser MCP client sends no Origin, and only a present but unlisted Origin is rejected.

Authentication. passthrough (the default), with --allow-operator-token-fallback left off. No token is configured on the Service at all; the server boots without one and uses its own token, if any, only for a startup reachability check. Turning the fallback on would make any LAN client that reaches the port act as the identity behind the configured token — explicitly warned against upstream and rejected here.

Upstream connection. The Service reaches Forgejo at loopback, not through the Edge; it runs on the same Guest as Forgejo, so no DNS hairpin or proxy round-trip is involved. ROOT_URL and DOMAIN are already set in app.ini, so Forgejo's API responses carry the public URL and the MCP server does not need to rewrite them.

Exposure. LAN-reachable by address only: no DNS record, no NPM proxy host, no certificate, no router rule. Not WAN-published.

Service lifecycle. A systemd unit under a dedicated service user. There is no health endpoint and no signal handler upstream: readiness is a TCP probe of the MCP path expecting 401, optionally corroborated by the listening on a network-reachable address line on stderr; SIGTERM gets the default disposition, which is what systemd's stop wants.

Token scope (the identity decision). Each Agent presents its own Forgejo token. Forgejo 15 splits the issue category out of repository, so the grant is tight: triage = write:issue (write implies read); browsing PRs and code = add read:repository; write:repository — which unlocks contents, Actions, PR merge and push — is refused. Read-only start is a one-scope downgrade to read:issue (± read:repository). The MCP tool surface includes mutations (merge_pull_request, writes and deletes); the token scopes them out, and that is the only boundary — the server enforces none of it.

Secrets. No new Vault entry: the Service is credential-less in passthrough mode and holds no Forgejo token. Nothing new is committed in ciphertext.

Client half (documented, not provisioned). Installing the mcp Python package and its mcp.client.streamable_http extra, adding the mcp_servers: entry, and restarting the Agent (no hot-reload) is a runbook section, because it edits a workstation config, not this repo's IaC.

Testing Decisions

A good test exercises external behaviour only — a request and its observable answer, or a run and its reported change count — never the role's internals. Three seams, all existing points of the stack, no new seam:

  1. Idempotence seam (Ansible). A second playbook run reports 0 changed. This is the repo's established seam.
  2. HTTP seam. A TCP probe of :8089/mcp from another LAN host answers 401 (the readiness signal), and an mcp client initialize + tools/list round-trip succeeds when the request carries a valid token.
  3. Capability seam. The same issue tool call succeeds with a write:issue token and is refused 403 with a token lacking the issue category; a write:issue token is refused 403 on a PR-merge or file-write call. This asserts the one boundary that matters.

Prior art: the role's existing "wait for the instance to see the runner online" retry probe, and the README's verify steps (curl status assertions, "second run of the playbook = 0 changed"). The whole role is rehearsed on the PRE Guest before Prod, per the deploy runbook.

Out of Scope

  • Any DNS record or pretty hostname (e.g. mcp.thepit.space): there is no DNS mechanism in this repo. LAN-reachable by IP only.
  • WAN exposure, a TLS certificate, an NPM proxy host, or a router rule.
  • The OAuth 2.0 resource-server auth mode: it needs Forgejo ≥ 16.0 and this instance pins 15.0.9.
  • Provisioning per-Agent tokens. The Operator mints and scopes them.
  • Retiring the existing curl + jq issue-tracker convention; it remains the fallback.
  • The Actions-based wiki-sync workflow (issue #2) and the open README/docs issues (#10–#12).
  • Making the MCP server authenticate or authorise its own callers.

Further Notes

  • Vocabulary follows GLOSSARY.md: Agent, Operator, Service, and the LAN-reachable / WAN-published split.
  • Two ADRs accompany this spec: the Service's shape and credential-less endpoint (ADR 0003), and the Agent token scope (ADR 0005).
  • Upstream moved off Codeberg; its canonical home is git.b4mad.industries/agentic-forges/forgejo-mcp. Latest release is v3.2.0 (2026-09-16); the old mirrors lag (≈ v2.30.2). Issue numbers are preserved across the move.
  • The endpoint is reachable by any host on 10.12.0.0/24; upstream's SECURITY.md says to treat the port as sensitive. Consistent with LAN-reachability today (nothing on the guest is firewalled).
  • Token names are free-form in Forgejo (the role already mints a transient admin token named for the run). Per-Agent tokens should be named for the Agent so they are distinguishable in the UI.

Sub-tasks

Split into four tickets, published in dependency order. Each blocking edge is
set natively on the tracker, so the dependency links — not prose — carry it.

  • #21 — ADR: the forgejo-mcp Service's shape and credential-less endpoint. Unblocked; this is the gate.
  • #22 — Install forgejo-mcp as a Service on the forgejo Guest. Blocked by #21.
  • #23 — An Agent round-trips through the MCP endpoint. Blocked by #22.
  • #24 — Agent token scope: the capability boundary, and its ADR. Blocked by #23.
## Problem Statement An Agent reaches the Forgejo instance at `forgejo.thepit.space` only by shelling out to `curl` + `jq` against the Gitea-style REST API, following `docs/agents/issue-tracker.md`. That convention works, but it is a document, not a mechanism: every Agent must read it, compose raw HTTP by hand, and parse JSON with no schema. Nothing type-checks the calls, and nothing bounds what an Agent may do to the tracker beyond the token it happens to hold — today the same broad Admin-scoped token in `~/.config/forgejo/.env`, with no split between "read an issue" and "rewrite the repository". ## Solution Run the upstream `forgejo-mcp` MCP server as a Service on the `forgejo` Guest, LAN-reachable at `http://10.12.0.141:8089/mcp`, provisioned by a new Ansible role and pinned like the other two Services (Forgejo, the Actions runner). The endpoint is **credential-less**. In its default `passthrough` auth mode every request must carry its own Forgejo token, and a request without one is refused `401`, so the Service holds no credential that could be stolen from it. An Agent on the LAN points its MCP client at the endpoint and presents a token scoped to its job — the token, not the server, is the capability boundary. ## User Stories 1. As an Agent, I want to discover Forgejo's issue and PR operations as typed MCP tools, so that I stop hand-composing REST calls from a document. 2. As an Agent, I want to list issues in `pit/infra-forge`, so that I can find the ticket I am working on. 3. As an Agent, I want to read an issue with its comments, so that I have the full context of a ticket. 4. As an Agent, I want to open an issue, so that I can file a finding without a human relaying it. 5. As an Agent, I want to comment on an issue, so that I can report progress on a ticket. 6. As an Agent, I want to read and add issue labels, so that triage stays in the tracker rather than in a chat. 7. As an Agent, I want to read pull requests and their diffs, so that I can review a proposed change. 8. As an Agent, I want to browse repository files and their contents, so that I can ground a change in the real tree. 9. As an Agent, I want to read Actions workflow runs and their logs, so that I can tell whether a change went green. 10. As the Operator, I want each Agent to present its own token, so that no single credential is shared across the fleet. 11. As the Operator, I want the Agent token scoped to `write:issue`, so that an Agent can triage issues but cannot push code, merge a PR, or change repository settings. 12. As the Operator, I want the Agent token to be denied the `repository` write category, so that the blast radius of a leaked Agent token stops at issues. 13. As the Operator, I want the endpoint to hold no Forgejo credential of its own, so that compromising the Service yields nothing an attacker can reuse against Forgejo. 14. As the Operator, I want the endpoint reachable only from `10.12.0.0/24`, so that the homelab's agent interface is not addressable from the internet. 15. As the Operator, I want a refused, unauthenticated request to be answered `401`, so that I can use a probe as a liveness check. 16. As the Operator, I want the Service to refuse to start if it is bound to a non-loopback address with no host allowlist, so that it can never come up wide open by accident. 17. As the Operator, I want the running version pinned in one place, so that a re-provision reproduces the exact binary. 18. As the Operator, I want the binary fetched checksum-first against a literal pinned hash, so that a moved upstream tag cannot silently swap the artifact. 19. As the Operator, I want the playbook to be idempotent, so that a second run reports zero changes. 20. As the Operator, I want the Service managed by systemd and restarted on config change, so that it survives a reboot and picks up a new pin. 21. As the Operator, I want to rehearse the role on the PRE Guest, so that a broken unit never reaches Prod. 22. As the Operator, I want the Service to talk to Forgejo over loopback, so that it keeps working even if the Edge is down. 23. As the Operator, I want the Service to take no Edge entry, certificate, or router rule, so that the LAN-only promise is kept by construction. 24. As the Operator, I want the Agent side (client package, client config, restart) written down as a runbook section rather than provisioned, so that the workstation stays out of this repo's scope. ## Implementation Decisions **Shape.** A new Ansible role, separate from the `forgejo` role, with its own play in `site.yml`. The `forgejo` role is a six-section linear flow whose handler *definition* order is a load-bearing contract (the runner restart must land after the Forgejo restart); a second Service as its own role leaves that contract untouched and gives the new pin a convention-4 home. **Packaging and pinning.** The release tarball `forgejo-mcp_3.2.0_linux_amd64.tar.gz` from the upstream forge `git.b4mad.industries`, pinned in the role defaults alongside its version and port (convention 4: one pin per Service). Upstream publishes a single multi-artifact `forgejo-mcp_3.2.0_checksums.txt` and no per-file `.sha256`, so "checksum-first" here means pinning the **literal sha256 of the archive** (`bf8f744d53dd06c0e7830ee13a0507464b3ab301fcf01de4744db03d770039df`, verified by download-and-hash) rather than pointing at a remote checksum URL — a stronger pin than the service binary's pattern, not a weaker one. The signed OCI image was rejected: it is `linux/amd64` only and would introduce a persistent-container pattern into a repo whose only Docker use today is ephemeral Actions job containers. **Transport.** Streamable HTTP (`/mcp`), not SSE (upstream's own "legacy" label) and not stdio. The Agent client path is `mcp.client.streamable_http`. **Binding and identity of the endpoint.** The Service binds the Guest's interface (`0.0.0.0`) and declares a host allowlist of `10.12.0.141`. A bare allowlist entry matches any port, so the single entry suffices. This is fail-closed: upstream *refuses to start* when bound to a non-loopback address with no allowlist, and answers `403` to a request whose `Host` is not declared. No `--allowed-origins` is set: a non-browser MCP client sends no `Origin`, and only a *present but unlisted* `Origin` is rejected. **Authentication.** `passthrough` (the default), with `--allow-operator-token-fallback` left **off**. No token is configured on the Service at all; the server boots without one and uses its own token, if any, only for a startup reachability check. Turning the fallback on would make any LAN client that reaches the port act as the identity behind the configured token — explicitly warned against upstream and rejected here. **Upstream connection.** The Service reaches Forgejo at loopback, not through the Edge; it runs on the same Guest as Forgejo, so no DNS hairpin or proxy round-trip is involved. `ROOT_URL` and `DOMAIN` are already set in `app.ini`, so Forgejo's API responses carry the public URL and the MCP server does not need to rewrite them. **Exposure.** LAN-reachable by address only: no DNS record, no NPM proxy host, no certificate, no router rule. Not WAN-published. **Service lifecycle.** A systemd unit under a dedicated service user. There is no health endpoint and no signal handler upstream: readiness is a TCP probe of the MCP path expecting `401`, optionally corroborated by the `listening on a network-reachable address` line on stderr; `SIGTERM` gets the default disposition, which is what systemd's stop wants. **Token scope (the identity decision).** Each Agent presents its own Forgejo token. Forgejo 15 splits the `issue` category out of `repository`, so the grant is tight: triage = `write:issue` (write implies read); browsing PRs and code = add `read:repository`; `write:repository` — which unlocks contents, Actions, PR merge and push — is refused. Read-only start is a one-scope downgrade to `read:issue` (± `read:repository`). The MCP tool surface includes mutations (`merge_pull_request`, writes and deletes); the token scopes them out, and that is the only boundary — the server enforces none of it. **Secrets.** No new Vault entry: the Service is credential-less in `passthrough` mode and holds no Forgejo token. Nothing new is committed in ciphertext. **Client half (documented, not provisioned).** Installing the `mcp` Python package and its `mcp.client.streamable_http` extra, adding the `mcp_servers:` entry, and restarting the Agent (no hot-reload) is a runbook section, because it edits a workstation config, not this repo's IaC. ## Testing Decisions A good test exercises external behaviour only — a request and its observable answer, or a run and its reported change count — never the role's internals. Three seams, all existing points of the stack, no new seam: 1. **Idempotence seam (Ansible).** A second playbook run reports `0 changed`. This is the repo's established seam. 2. **HTTP seam.** A TCP probe of `:8089/mcp` from another LAN host answers `401` (the readiness signal), and an `mcp` client `initialize` + `tools/list` round-trip succeeds when the request carries a valid token. 3. **Capability seam.** The same issue tool call succeeds with a `write:issue` token and is refused `403` with a token lacking the `issue` category; a `write:issue` token is refused `403` on a PR-merge or file-write call. This asserts the one boundary that matters. Prior art: the role's existing "wait for the instance to see the runner online" retry probe, and the README's verify steps (curl status assertions, "second run of the playbook = 0 changed"). The whole role is rehearsed on the PRE Guest before Prod, per the deploy runbook. ## Out of Scope - Any DNS record or pretty hostname (e.g. `mcp.thepit.space`): there is no DNS mechanism in this repo. LAN-reachable by IP only. - WAN exposure, a TLS certificate, an NPM proxy host, or a router rule. - The OAuth 2.0 resource-server auth mode: it needs Forgejo ≥ 16.0 and this instance pins 15.0.9. - Provisioning per-Agent tokens. The Operator mints and scopes them. - Retiring the existing `curl` + `jq` issue-tracker convention; it remains the fallback. - The Actions-based wiki-sync workflow (issue #2) and the open README/docs issues (#10–#12). - Making the MCP server authenticate or authorise its own callers. ## Further Notes - Vocabulary follows `GLOSSARY.md`: **Agent**, **Operator**, **Service**, and the **LAN-reachable** / **WAN-published** split. - Two ADRs accompany this spec: the Service's shape and credential-less endpoint (ADR 0003), and the Agent token scope (ADR 0005). - Upstream moved off Codeberg; its canonical home is `git.b4mad.industries/agentic-forges/forgejo-mcp`. Latest release is `v3.2.0` (2026-09-16); the old mirrors lag (≈ `v2.30.2`). Issue numbers are preserved across the move. - The endpoint is reachable by any host on `10.12.0.0/24`; upstream's `SECURITY.md` says to treat the port as sensitive. Consistent with LAN-reachability today (nothing on the guest is firewalled). - Token names are free-form in Forgejo (the role already mints a transient admin token named for the run). Per-Agent tokens should be named for the Agent so they are distinguishable in the UI. ## Sub-tasks Split into four tickets, published in dependency order. Each blocking edge is set natively on the tracker, so the dependency links — not prose — carry it. - #21 — ADR: the forgejo-mcp Service's shape and credential-less endpoint. Unblocked; this is the gate. - #22 — Install forgejo-mcp as a Service on the forgejo Guest. Blocked by #21. - #23 — An Agent round-trips through the MCP endpoint. Blocked by #22. - #24 — Agent token scope: the capability boundary, and its ADR. Blocked by #23.
pit changed title from Install forgejo-mcp on the Guest as a LAN-reachable Service to [EPIC] Install forgejo-mcp on the Guest as a LAN-reachable Service 2026-10-06 15:08:30 +00:00
Author
Owner

Closing: all four sub-tasks are merged to main and closed.

  • #21 — ADR 0003 (docs/adr/0003-forgejo-mcp-service-shape-and-endpoint.md)
  • #22 — the forgejo_mcp role, its play in site.yml, and the pin in the role defaults
  • #23 — the client-half runbook section (#56)
  • #24 — ADR 0005 (docs/adr/0005-agent-token-scope.md) and the capability seam verified live on PRE (#56)

Body corrected: the Further Notes ADR numbers were stale (0002/0003) and now read 0003/0005, which is what landed.

Closing: all four sub-tasks are merged to `main` and closed. - #21 — ADR 0003 (`docs/adr/0003-forgejo-mcp-service-shape-and-endpoint.md`) - #22 — the `forgejo_mcp` role, its play in `site.yml`, and the pin in the role defaults - #23 — the client-half runbook section (#56) - #24 — ADR 0005 (`docs/adr/0005-agent-token-scope.md`) and the capability seam verified live on PRE (#56) Body corrected: the Further Notes ADR numbers were stale (0002/0003) and now read 0003/0005, which is what landed.
pit closed this issue 2026-10-07 20:36:48 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
olympus/infra-forge#17
No description provided.