title: Phase 5c: idle reaper + Forgejo token rotation + child respawn

description: Reaper (30s tick) applies idle timeout. Rotation (1-min tick) refreshes Forgejo tokens expiring <2min, SIGTERMs child, respawns on next request (design.md §6). Token revocation tears down sessions.

acceptance_criteria: Clock-injected tests: idle kill, rotation triggers respawn, revocation tears down sessions. Smoke test: 20 concurrent sessions for 10min with mid-test rotations.

status: open

issue_type: task

created_by: Ole-Morten Duesund

depends_on_id: forgejo-mcp-broker-pur

type: blocks


title: Phase 5b: bearer-token middleware on /mcp

description: Middleware reads Authorization: Bearer <mcp_token>, resolves via store, attaches Forgejo access token to request context. 401 for missing/expired/revoked.

acceptance_criteria: Table-driven tests: missing header, malformed, unknown token, expired, revoked, valid. Valid-token path puts Forgejo token on ctx via typed key.

issue_id: forgejo-mcp-broker-ytw


title: Phase 5a: session registry + spawn-on-initialize

description: Map Mcp-Session-Id -> supervisor.Child + user metadata. On first initialize for unknown sid, spawn forgejo-mcp with user's Forgejo token in env, bind to bridge. LastActive bumped per request.

acceptance_criteria: Tests with fake supervisor + fake bridge cover: spawn-on-initialize, reuse for subsequent messages, unknown-sid returns 410, max-sessions cap enforced.

depends_on_id: forgejo-mcp-broker-zuq


title: Phase 4b: bridge integration test against real forgejo-mcp

description: Drive the bridge with initialize -> tools/list -> tools/call get_forgejo_mcp_server_version against a real forgejo-mcp subprocess. Validates the opaque-pipe assumption.

acceptance_criteria: Full handshake, tools/list returns expected set, tools/call returns a version string. Tagged as integration test if runtime exceeds 2s.

issue_id: forgejo-mcp-broker-xot


title: Phase 3b: supervisor stress tests (FD/goroutine/zombie leak detection)

description: 1000 spawn/stop cycles under -race. Verify no FD leak, no goroutine leak (go.uber.org/goleak), no zombies (wait4 returns ECHILD when idle).

acceptance_criteria: Cycle test passes under -race. FD count stable within a small constant. goleak detects no extra goroutines after test.


title: Phase 4a: internal/bridge JSON-RPC pipe + SSE writer

description: Given a supervisor.Child: inbound HTTP JSON -> newline-framed stdin; stdout lines -> SSE frames. Handle client disconnect without killing the child.

acceptance_criteria: Unit tests with mock Child that echoes: request/response round trip, multiple concurrent requests with correct id routing, client disconnect mid-stream.


title: Phase 2e: OAuth security review + attack-path tests

description: Phase 2 exit gate. Review every handler for classic OAuth vulns (open redirect, code replay, mix-up, token leak in logs, host spoofing). Add at least one test per attack class. Update design.md §8 with findings.

acceptance_criteria: Review checklist documented. Tests added for: PKCE mismatch, stale code, token absent from log attributes, bad redirect_uri, mismatched state, replay of used code.

issue_id: forgejo-mcp-broker-wgo


title: Phase 3a: internal/supervisor managed stdio subprocess

description: Child type: Start, Stop(ctx) with SIGTERM -> grace -> SIGKILL, Wait+reap goroutine (no zombies), stderr drainer with prefix. Protocol-agnostic.

acceptance_criteria: Unit tests against an echo-loop helper: round trip, graceful stop, kill-after-grace, child-exits-on-own detection, stderr capture. Manual spawn of real forgejo-mcp --transport stdio works.

assignee: Ole-Morten Duesund


title: Phase 2d: OAuth discovery endpoints (/.well-known/*)

description: GET /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server. Issuer URLs MUST derive from cfg.PublicURL, never inbound headers (host-header attack defense per design.md §8).

acceptance_criteria: Responses validate against RFC 8414/9728 shapes. Issuer URL sourced from config only. supported_scopes matches cfg.ForgejoOAuthScopes.


title: Phase 2b: internal/forgejo OAuth client

description: Broker-side OAuth client for upstream Forgejo: authorize URL builder, code-to-token exchange, refresh_token grant, userinfo fetch, revoke. Used by AS callback and refresh machinery. Stateless; caller owns persistence.

acceptance_criteria: Unit tests with httptest.Server fake Forgejo cover each grant plus error paths (wrong code, expired refresh, revoked token). No state persisted in this package.

status: closed

close_reason: internal/forgejo shipped: AuthorizeURL, ExchangeCode, Refresh, FetchUserInfo. Structured *forgejo.Error for OAuth failures (errors.As-friendly). 95.1% coverage. Stateless — caller owns persistence. Revocation deferred since upstream Forgejo lacks the endpoint.


title: Phase 2c: internal/oauth AS endpoints (register, authorize, callback, token, revoke)

description: Five OAuth handlers per design.md §4.1. RFC 7591 DCR with ephemeral client IDs, authorize -> Forgejo delegation, callback minting broker auth codes, token exchange with SHA-256 hashing at rest, revoke. PKCE S256 required.

acceptance_criteria: End-to-end curl walkthrough from plan.md phase 2 passes. All tokens hashed at rest. Auth codes single-use, 10-min TTL. Rejects missing PKCE, non-S256, wrong verifier, expired codes/tokens. Handler coverage >=80%.

issue_id: forgejo-mcp-broker-pur

depends_on_id: forgejo-mcp-broker-cpb


title: Phase 2a: OAuth tables migration

description: Add migrations/0002_oauth_tables.sql creating clients, auth_codes, access_tokens, refresh_tokens per design.md §4.2. Broker tokens stored as SHA-256 hashes; Forgejo tokens cleartext (subprocess spawning requires them). See plan.md phase 2.

acceptance_criteria: Migration applies on a fresh DB and is idempotent on reopen. Schema matches design.md §4.2. Tests verify every table and key column exists.

close_reason: 0002_oauth_tables.sql shipped: clients/auth_codes/access_tokens/refresh_tokens with cascading FKs, indexes on hot-path columns, and an oauth_schema_version marker. PRAGMA-driven tests verify columns; FK cascade tested in both directions.


title: Phase 1: internal/httpserver with /healthz and graceful shutdown

description: Implement internal/httpserver: constructs a *http.Server bound to cfg.Listen, mounts GET /healthz (returns 200 with JSON build-info from the build-info package, including version, git revision, build date, and current store status), handles SIGTERM/SIGINT by initiating graceful shutdown with a configurable deadline (default 10s). Uses log/slog for structured JSON logs. Exposes a Run(ctx) error method that blocks until shutdown completes.

acceptance_criteria: go test ./internal/httpserver passes; GET /healthz returns expected JSON; sending SIGTERM causes Run to return nil within 2 seconds after in-flight requests complete; slow in-flight request is allowed to finish within the deadline, then forcibly closed.

close_reason: httpserver shipped: /healthz with store probe, graceful shutdown with force-close fallback, ExtraHandler extension point. 97.9% coverage. internal/log also implemented in the same commit (100% coverage).


title: Phase 1: wire cmd/broker/main.go and integration test

description: Final phase 1 task: wire config → log → store → httpserver in cmd/broker/main.go. Parse config, init slog, open store, start httpserver, wait for shutdown signal, close store, exit. Add an integration test under cmd/broker/ that builds the binary, runs it with a valid env + temp store path, curls /healthz, sends SIGTERM, verifies clean exit within 2s. This is the acceptance gate for phase 1.

acceptance_criteria: make build; make test (incl. integration) pass; running the binary with missing config fails with a clear error; running with valid config serves /healthz; SIGTERM shuts down cleanly within 2s; /healthz JSON includes version, git revision, build date, and store OK status.

close_reason: Main wired, signal.NotifyContext triggers shutdown cascade, integration tests green. Phase 1 complete: binary starts with valid config, serves /healthz JSON, shuts down cleanly on SIGTERM within 2s.


title: Phase 1: internal/store with SQLite open and embedded schema migrations

description: Implement internal/store: wraps a modernc.org/sqlite connection, applies embedded schema migrations in order via a schema_migrations table, exposes a *sql.DB and a Close method. Phase 1 schema is just the migrations table itself plus a health_check row — real tables (clients, auth_codes, access_tokens, refresh_tokens) ship in phase 2. Store_path from config; creates parent dirs if missing; fails fast on unwritable path. Migrations embedded via embed.FS under internal/store/migrations/.

acceptance_criteria: go test ./internal/store passes; opening a fresh db file applies migrations; re-opening is idempotent (no re-application, no errors); corrupt/locked files yield a clear error; Close() leaves no file handles open.

close_reason: Store package shipped: modernc.org/sqlite, embed.FS migrations, WAL + FK pragmas, idempotent reopen, 90.1% coverage including bad-SQL rollback and record-step PK conflict.


title: Phase 1: internal/config package with flag + env parsing and validation

description: Implement internal/config: a Config struct populated from CLI flags and environment variables (flags win), with validation at startup. Parse public-url, listen addr, forgejo-url, forgejo-oauth-client-id/secret/scopes, forgejo-mcp-binary, store-path, max-sessions, session-idle-timeout, debug. Validation: required fields present and non-empty; public-url parses as an https URL; store-path writable; idle-timeout positive; max-sessions positive. Unit tests cover happy path + every validation error branch.

acceptance_criteria: go test ./internal/config passes with >=90% coverage; missing required env produces a clear error message listing all missing fields; flag values override env; --help prints all config options.

close_reason: Config package shipped with 94.1% coverage, handles all required-vs-optional fields, env/flag precedence, URL validation with loopback-http exception, and store-path writability. Full test suite green.


title: Phase 1: bootstrap Go project layout

description: Set up the Go project skeleton so all subsequent phase 1 packages have somewhere to live. Initialize go.mod with module path kode.naiv.no/olemd/forgejo-mcp-broker, create the directory layout (cmd/broker, internal/config, internal/log, internal/store, internal/httpserver), add a Makefile with build/test/lint targets, and wire build-info injection (version, git revision, build date) via -ldflags.

acceptance_criteria: go.mod present with correct module path; make build produces ./fjmcp-broker binary; make test and make lint targets exist and pass against an empty codebase; binary prints --version with injected build info.

close_reason: Bootstrap complete: go.mod, Makefile, directory layout, ldflags-injected build info, --version flag all working. make build/test/lint pass.


title: Phase 7a: Claude.ai end-to-end validation on staging

description: Stand up staging Forgejo + broker on a public hostname with real TLS. Register as a Claude.ai MCP connector. Walk through OAuth, tool discovery, tool invocation, session timeout, reconnect, Forgejo token refresh mid-session. Capture findings.

acceptance_criteria: Claude.ai completes OAuth and lists tools. All forgejo-mcp tools invocable. 30-min idle session reconnects. Token refresh during active session invisible to user. docs/phase7-findings.md written.

depends_on_id: forgejo-mcp-broker-ytw


title: Phase 6a: Caddy front-end example + docs/deploy-caddy.md

description: deploy/caddy/Caddyfile example and docs/deploy-caddy.md: TLS via Let's Encrypt, flush_interval -1 for SSE, X-Forwarded-* config (issuer URL still from broker config, never inbound headers).

acceptance_criteria: Caddyfile works against a local broker (curl --resolve test). docs/deploy-caddy.md linked from README and deploy-podman.md. Explains host-header attack defense.


title: Deploy via rootless podman with persistent state

description: Operators will deploy fjmcp-broker via rootless podman (user's stated preference over docker). The broker needs: (1) a working Containerfile that bundles both fjmcp-broker and a pinned forgejo-mcp binary so the broker can spawn subprocesses; (2) deployment artefacts that use a named podman volume for /data so the SQLite store persists across container restarts and image updates; (3) documentation an operator can follow end-to-end. Container must run as non-root, carry OCI labels (org.opencontainers.image.created, .revision) and /etc/build-info per the user's global container standards. Note: full usefulness depends on OAuth (phase 2) being implemented, but writing the Containerfile + deploy docs early forces runtime concerns (volumes, env vars, non-root) to surface before they become harder to fix.

design: Container: multi-stage build. Stage 1 compiles fjmcp-broker with -trimpath and ldflags for build info. Stage 2 fetches forgejo-mcp at a pinned version (pinning matters — the broker subprocess contract depends on flags/env). Final stage uses distroless/static or alpine-nonroot, copies both binaries, USER 65532, EXPOSE 8080, ENTRYPOINT ['/usr/local/bin/fjmcp-broker']. Volume strategy: /data is a podman named volume ('fjmcp-state') mounted with the right uid. SQLite WAL files live inside it and survive restarts. Env vars for secrets via --env-file pointing to a 0600-permissioned file outside the repo. Build args: BUILD_DATE and GIT_REVISION, wired into labels and /etc/build-info. Quadlet unit: use Podman Quadlet (not the deprecated podman generate systemd) — drop fjmcp-broker.container under ~/.config/containers/systemd/, daemon-reload, start.

acceptance_criteria: make image builds locally under rootless podman; podman run -p 8080:8080 -v fjmcp-state:/data --env-file .env fjmcp-broker:latest starts the broker and /healthz returns 200 with store=ok; stopping the container and starting a new one against the same fjmcp-state volume preserves schema_migrations rows; pulling a newer image and recreating the container preserves state; a Quadlet .container unit example brings the broker up under systemd --user; docs/deploy-podman.md walks a fresh operator through build → run → persistent state → secret injection → image upgrade → troubleshooting.

notes: Related to plan.md Phase 6 (packaging). If we build a compose example later for non-podman users, it can piggyback off the same Containerfile. Caddy sits in front of this container — include a brief note about Caddy+flush_interval requirements for SSE, referencing design.md §7.2.


title: Backlog: Forgejo Actions CI workflow

description: .forgejo/workflows/ci.yml running go vet, go test -race, go build, and golangci-lint on PRs and pushes to main. Block merge on failure.

acceptance_criteria: CI green on main. PR failures block merge. Badge/status referenced in README.


title: Backlog: rate limits on /oauth/register and /oauth/token

description: design.md §8: Start with Caddy-level rate limits in the example Caddyfile (coarse but free). Add broker-side middleware only if operator experience shows Caddy is too coarse.

acceptance_criteria: Caddy example extended with rate_limit directive. If broker-side added: unit tests cover quota exhaustion and reset windows.


title: Backlog: upstream forgejo-mcp --token-fd flag

description: Contribute --token-fd to upstream forgejo-mcp so broker passes token via inherited FD instead of env, closing /proc/<pid>/environ leak (design.md §11). Tracked in a sibling repo; this issue tracks our consumption side.

acceptance_criteria: Upstream PR merged and broker switches to --token-fd, OR design.md §11 records that we accept the env exposure for now.


title: Backlog: Prometheus /metrics endpoint

description: design.md §10 item 5: session count, spawn/reap rates, OAuth endpoint latencies, Forgejo refresh success rate. Prometheus textfile format.

acceptance_criteria: /metrics endpoint serves standard exposition. Optional auth/allow-list gate. Example Grafana dashboard JSON committed.


title: Backlog: optional AES-GCM encryption of Forgejo tokens at rest

description: design.md §4.2 notes Forgejo tokens are cleartext in SQLite. Evaluate application-layer AES-GCM with a key from env vs. rely on volume encryption only. Make a decision and record it.

acceptance_criteria: If implemented: transparent encrypt/decrypt in store, key rotation path. If rejected: design.md §4.2 decision recorded with rationale.


title: Backlog: dependency justification log (docs/deps.md)

description: plan.md conventions: every Go dep gets an entry in docs/deps.md. Backfill modernc.org/sqlite (and transitive deps). Add a PR-template hook so future additions land with a justification.

acceptance_criteria: docs/deps.md exists with entry for modernc.org/sqlite. Contributors guided to update when adding deps.


title: Backlog: .golangci.yml config committed

description: Commit a pragmatic golangci-lint config (errcheck, gosec, govet, ineffassign, staticcheck, unparam, etc). Aligns make lint output across machines.

acceptance_criteria: .golangci.yml in repo. make lint picks it up when golangci-lint installed. Ruleset documented in CONTRIBUTING or README.