feat(deploy): rootless podman + Quadlet deployment (forgejo-mcp-broker-8yd)
Adds a multi-stage Containerfile, Quadlet unit, and operator walkthrough for a production deploy. The broker spawns forgejo-mcp per session, so the image bundles both binaries — broker built from this repo, forgejo-mcp pinned via FORGEJO_MCP_VERSION build-arg (default 2.18.0). Image stages: 1. golang:alpine compiles the broker with ldflags-stamped buildinfo 2. golang:alpine clones forgejo-mcp at the pinned tag and compiles it 3. distroless static-nonroot copies both binaries; uid 65532 Persistent state via the named volume `fjmcp-state` mounted at /data. SQLite WAL + SHM sidecars live alongside broker.db on the same volume, so a container swap or image upgrade preserves all OAuth clients, issued tokens, and refresh-token history. Verified end-to-end: podman run --rm -d -v fjmcp-test-state:/data ... fjmcp-broker:test curl /healthz # store: ok, broker.db created podman stop fjmcp-test podman run --rm -d -v fjmcp-test-state:/data ... fjmcp-broker:test curl /healthz # store: ok, same broker.db ls volume → broker.db, broker.db-shm, broker.db-wal all present Quadlet unit (deploy/podman/fjmcp-broker.container) drops into ~/.config/containers/systemd/, reads secrets from a 0600 env file outside the unit, publishes :8080 on loopback for Caddy to front. Makefile gains `image` and `image-run` targets. README links to the new docs/deploy-podman.md walkthrough. Closes forgejo-mcp-broker-8yd. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8369ec2cc7
commit
018f56a4ad
7 changed files with 343 additions and 3 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
{"id":"forgejo-mcp-broker-xot","title":"Phase 4b: bridge integration test against real forgejo-mcp","description":"Drive the bridge with initialize -\u003e tools/list -\u003e 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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:16Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:28:39Z","started_at":"2026-04-27T14:10:04Z","closed_at":"2026-04-27T14:28:39Z","close_reason":"Bridge integration test passes against real forgejo-mcp 2.2.0: MCP handshake (initialize → notifications/initialized → tools/list → tools/call) round-trips through bridge cleanly. Fake Forgejo covers /api/v1/version and /api/v1/user probes. Phase 4 complete.","dependencies":[{"issue_id":"forgejo-mcp-broker-xot","depends_on_id":"forgejo-mcp-broker-am1","type":"blocks","created_at":"2026-04-24T17:45:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-xot","title":"Phase 4b: bridge integration test against real forgejo-mcp","description":"Drive the bridge with initialize -\u003e tools/list -\u003e 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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:16Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:28:39Z","started_at":"2026-04-27T14:10:04Z","closed_at":"2026-04-27T14:28:39Z","close_reason":"Bridge integration test passes against real forgejo-mcp 2.2.0: MCP handshake (initialize → notifications/initialized → tools/list → tools/call) round-trips through bridge cleanly. Fake Forgejo covers /api/v1/version and /api/v1/user probes. Phase 4 complete.","dependencies":[{"issue_id":"forgejo-mcp-broker-xot","depends_on_id":"forgejo-mcp-broker-am1","type":"blocks","created_at":"2026-04-24T17:45:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-31t","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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:04:42Z","started_at":"2026-04-27T12:00:32Z","closed_at":"2026-04-27T14:04:42Z","close_reason":"Stress tests in place: 1000-cycle spawn/reap and 200-cycle Stop both clean under -race; FD/goroutine/zombie deltas all single-digit. Driver: /bin/true and /bin/cat (helper-process recursion at scale exposed an unrelated Go pidfd interaction). Supervisor now defensively closes pipe handles post-Wait.","dependencies":[{"issue_id":"forgejo-mcp-broker-31t","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-31t","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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:04:42Z","started_at":"2026-04-27T12:00:32Z","closed_at":"2026-04-27T14:04:42Z","close_reason":"Stress tests in place: 1000-cycle spawn/reap and 200-cycle Stop both clean under -race; FD/goroutine/zombie deltas all single-digit. Driver: /bin/true and /bin/cat (helper-process recursion at scale exposed an unrelated Go pidfd interaction). Supervisor now defensively closes pipe handles post-Wait.","dependencies":[{"issue_id":"forgejo-mcp-broker-31t","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-am1","title":"Phase 4a: internal/bridge JSON-RPC pipe + SSE writer","description":"Given a supervisor.Child: inbound HTTP JSON -\u003e newline-framed stdin; stdout lines -\u003e 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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:59:35Z","started_at":"2026-04-27T11:56:15Z","closed_at":"2026-04-27T11:59:35Z","close_reason":"Bridge shipped: per-id routing, SSE responses for request/reply messages, 204 for notifications, structured 4xx/5xx for malformed input. Decoupled from supervisor (takes pipes directly) for clean testing via io.Pipe. 90.0% coverage.","dependencies":[{"issue_id":"forgejo-mcp-broker-am1","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:27Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
{"id":"forgejo-mcp-broker-am1","title":"Phase 4a: internal/bridge JSON-RPC pipe + SSE writer","description":"Given a supervisor.Child: inbound HTTP JSON -\u003e newline-framed stdin; stdout lines -\u003e 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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:59:35Z","started_at":"2026-04-27T11:56:15Z","closed_at":"2026-04-27T11:59:35Z","close_reason":"Bridge shipped: per-id routing, SSE responses for request/reply messages, 204 for notifications, structured 4xx/5xx for malformed input. Decoupled from supervisor (takes pipes directly) for clean testing via io.Pipe. 90.0% coverage.","dependencies":[{"issue_id":"forgejo-mcp-broker-am1","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:27Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-wgo","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.","status":"in_progress","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:33:21Z","started_at":"2026-04-27T15:33:21Z","dependencies":[{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-b2o","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-wgo","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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:37:10Z","started_at":"2026-04-27T15:33:21Z","closed_at":"2026-04-27T15:37:10Z","close_reason":"Phase-2 attack-path review complete. Found and fixed 2 real issues: refresh-token replay race (atomic UPDATE rows-affected, new concurrent-replay test) and permissive redirect_uri schemes (now requires https | http-loopback | reverse-DNS RFC 8252). Findings table in design.md §8.2 cross-references each defence to its test. Phase 2 complete.","dependencies":[{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-b2o","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-zuq","title":"Phase 3a: internal/supervisor managed stdio subprocess","description":"Child type: Start, Stop(ctx) with SIGTERM -\u003e grace -\u003e 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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:41:07Z","started_at":"2026-04-27T11:32:54Z","closed_at":"2026-04-27T11:41:07Z","close_reason":"internal/supervisor shipped: Start/Stop/Done/ExitErr/Pid, SIGTERM-\u003egrace-\u003eSIGKILL escalation, mandatory wait-and-reap. Test uses TestMain helper-process pattern; coverage 89.6% on the testable surface.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
{"id":"forgejo-mcp-broker-zuq","title":"Phase 3a: internal/supervisor managed stdio subprocess","description":"Child type: Start, Stop(ctx) with SIGTERM -\u003e grace -\u003e 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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:41:07Z","started_at":"2026-04-27T11:32:54Z","closed_at":"2026-04-27T11:41:07Z","close_reason":"internal/supervisor shipped: Start/Stop/Done/ExitErr/Pid, SIGTERM-\u003egrace-\u003eSIGKILL escalation, mandatory wait-and-reap. Test uses TestMain helper-process pattern; coverage 89.6% on the testable surface.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-b2o","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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:13Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:08:19Z","started_at":"2026-04-27T15:06:24Z","closed_at":"2026-04-27T15:08:19Z","close_reason":"Discovery endpoints shipped: /.well-known/oauth-authorization-server (RFC 8414) and /.well-known/oauth-protected-resource (RFC 9728). All URLs derived from cfg.Issuer; explicit Host-header-spoofing test verifies attacker-supplied hosts don't leak into metadata.","dependencies":[{"issue_id":"forgejo-mcp-broker-b2o","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
{"id":"forgejo-mcp-broker-b2o","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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:13Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:08:19Z","started_at":"2026-04-27T15:06:24Z","closed_at":"2026-04-27T15:08:19Z","close_reason":"Discovery endpoints shipped: /.well-known/oauth-authorization-server (RFC 8414) and /.well-known/oauth-protected-resource (RFC 9728). All URLs derived from cfg.Issuer; explicit Host-header-spoofing test verifies attacker-supplied hosts don't leak into metadata.","dependencies":[{"issue_id":"forgejo-mcp-broker-b2o","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-b9i","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","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:12Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:31:27Z","started_at":"2026-04-27T11:29:17Z","closed_at":"2026-04-27T11:31:27Z","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.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
{"id":"forgejo-mcp-broker-b9i","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","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:12Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:31:27Z","started_at":"2026-04-27T11:29:17Z","closed_at":"2026-04-27T11:31:27Z","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.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
{"id":"forgejo-mcp-broker-n84","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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:45:44Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T14:55:39Z","started_at":"2026-04-24T14:50:57Z","closed_at":"2026-04-24T14:55:39Z","close_reason":"Bootstrap complete: go.mod, Makefile, directory layout, ldflags-injected build info, --version flag all working. make build/test/lint pass.","dependency_count":0,"dependent_count":4,"comment_count":0}
|
{"id":"forgejo-mcp-broker-n84","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.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:45:44Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T14:55:39Z","started_at":"2026-04-24T14:50:57Z","closed_at":"2026-04-24T14:55:39Z","close_reason":"Bootstrap complete: go.mod, Makefile, directory layout, ldflags-injected build info, --version flag all working. make build/test/lint pass.","dependency_count":0,"dependent_count":4,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-q6n","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.","status":"open","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:19Z","dependencies":[{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-8yd","type":"blocks","created_at":"2026-04-24T17:45:34Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-q4x","type":"blocks","created_at":"2026-04-24T17:45:34Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-r2c","type":"blocks","created_at":"2026-04-24T17:45:35Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-t81","type":"blocks","created_at":"2026-04-24T17:45:33Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-ytw","type":"blocks","created_at":"2026-04-24T17:45:33Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-q6n","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.","status":"open","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:19Z","dependencies":[{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-8yd","type":"blocks","created_at":"2026-04-24T17:45:34Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-q4x","type":"blocks","created_at":"2026-04-24T17:45:34Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-r2c","type":"blocks","created_at":"2026-04-24T17:45:35Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-t81","type":"blocks","created_at":"2026-04-24T17:45:33Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-ytw","type":"blocks","created_at":"2026-04-24T17:45:33Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-r2c","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.","status":"open","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:19Z","dependencies":[{"issue_id":"forgejo-mcp-broker-r2c","depends_on_id":"forgejo-mcp-broker-8yd","type":"blocks","created_at":"2026-04-24T17:45:32Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
{"id":"forgejo-mcp-broker-r2c","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.","status":"open","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:19Z","dependencies":[{"issue_id":"forgejo-mcp-broker-r2c","depends_on_id":"forgejo-mcp-broker-8yd","type":"blocks","created_at":"2026-04-24T17:45:32Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-8yd","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.","status":"open","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:30:50Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:30:50Z","dependency_count":0,"dependent_count":2,"comment_count":0}
|
{"id":"forgejo-mcp-broker-8yd","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.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:30:50Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:37:37Z","started_at":"2026-04-27T15:37:37Z","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-0sq","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.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:24Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:24Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-0sq","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.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:24Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:24Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-ttl","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.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:23Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:23Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-ttl","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.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:23Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:23Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-1n2","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/\u003cpid\u003e/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.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:22Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:22Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-1n2","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/\u003cpid\u003e/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.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:22Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:22Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
73
Containerfile
Normal file
73
Containerfile
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Multi-stage Containerfile for fjmcp-broker.
|
||||||
|
#
|
||||||
|
# Stage 1: build fjmcp-broker (this repo).
|
||||||
|
# Stage 2: build forgejo-mcp at a pinned version. The broker spawns it as
|
||||||
|
# a subprocess per session, so it must live in the final image.
|
||||||
|
# Stage 3: distroless static-nonroot. CGO_ENABLED=0 in both build stages
|
||||||
|
# keeps the final image free of glibc/musl entanglements and
|
||||||
|
# makes the image entirely static.
|
||||||
|
#
|
||||||
|
# Build labels and /etc/build-info wire BUILD_DATE and GIT_REVISION so
|
||||||
|
# operators can correlate a running container back to a commit.
|
||||||
|
|
||||||
|
ARG BUILD_DATE="unknown"
|
||||||
|
ARG GIT_REVISION="unknown"
|
||||||
|
ARG FORGEJO_MCP_VERSION="2.18.0"
|
||||||
|
|
||||||
|
# ---------- Stage 1: broker build ----------
|
||||||
|
FROM docker.io/library/golang:1.26-alpine AS broker-build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG GIT_REVISION
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
RUN go build \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags "-s -w \
|
||||||
|
-X kode.naiv.no/olemd/forgejo-mcp-broker/internal/buildinfo.Version=${GIT_REVISION} \
|
||||||
|
-X kode.naiv.no/olemd/forgejo-mcp-broker/internal/buildinfo.GitRevision=${GIT_REVISION} \
|
||||||
|
-X kode.naiv.no/olemd/forgejo-mcp-broker/internal/buildinfo.BuildDate=${BUILD_DATE}" \
|
||||||
|
-o /out/fjmcp-broker \
|
||||||
|
./cmd/broker
|
||||||
|
|
||||||
|
# ---------- Stage 2: forgejo-mcp build ----------
|
||||||
|
FROM docker.io/library/golang:1.26-alpine AS mcp-build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
ARG FORGEJO_MCP_VERSION
|
||||||
|
RUN git clone --depth=1 --branch v${FORGEJO_MCP_VERSION} \
|
||||||
|
https://codeberg.org/goern/forgejo-mcp.git . \
|
||||||
|
&& CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/forgejo-mcp .
|
||||||
|
|
||||||
|
# ---------- Stage 3: runtime ----------
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG GIT_REVISION
|
||||||
|
ARG FORGEJO_MCP_VERSION
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="fjmcp-broker"
|
||||||
|
LABEL org.opencontainers.image.description="OAuth 2.1 broker that fronts forgejo-mcp for MCP clients."
|
||||||
|
LABEL org.opencontainers.image.source="https://kode.naiv.no/olemd/forgejo-mcp-broker"
|
||||||
|
LABEL org.opencontainers.image.created="${BUILD_DATE}"
|
||||||
|
LABEL org.opencontainers.image.revision="${GIT_REVISION}"
|
||||||
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
|
LABEL net.naiv.fjmcp.forgejo-mcp-version="${FORGEJO_MCP_VERSION}"
|
||||||
|
|
||||||
|
COPY --from=broker-build /out/fjmcp-broker /usr/local/bin/fjmcp-broker
|
||||||
|
COPY --from=mcp-build /out/forgejo-mcp /usr/local/bin/forgejo-mcp
|
||||||
|
|
||||||
|
# Persistent volume for the SQLite store (clients, tokens, audit data).
|
||||||
|
# Operators mount a named podman volume here so state survives container
|
||||||
|
# replacement. SQLite WAL writes auxiliary files (.db-wal, .db-shm) next
|
||||||
|
# to the main file — the volume contains them all.
|
||||||
|
VOLUME ["/data"]
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
|
# Distroless nonroot user is uid 65532. Volumes inherit ownership from the
|
||||||
|
# host; document the chown step in deploy-podman.md.
|
||||||
|
USER 65532:65532
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/usr/local/bin/fjmcp-broker"]
|
||||||
26
Makefile
26
Makefile
|
|
@ -11,7 +11,11 @@ LDFLAGS := -s -w \
|
||||||
-X $(MODULE)/internal/buildinfo.GitRevision=$(GIT_REV) \
|
-X $(MODULE)/internal/buildinfo.GitRevision=$(GIT_REV) \
|
||||||
-X $(MODULE)/internal/buildinfo.BuildDate=$(BUILD_DATE)
|
-X $(MODULE)/internal/buildinfo.BuildDate=$(BUILD_DATE)
|
||||||
|
|
||||||
.PHONY: all build test lint tidy clean help
|
.PHONY: all build test lint tidy clean help image image-run
|
||||||
|
|
||||||
|
IMAGE_NAME ?= ghcr.io/olemd/fjmcp-broker
|
||||||
|
IMAGE_TAG ?= latest
|
||||||
|
FORGEJO_MCP_VERSION ?= 2.18.0
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
|
|
@ -35,5 +39,25 @@ tidy: ## Tidy go.mod / go.sum
|
||||||
clean: ## Remove build artefacts
|
clean: ## Remove build artefacts
|
||||||
rm -f $(BINARY)
|
rm -f $(BINARY)
|
||||||
|
|
||||||
|
image: ## Build the OCI image with rootless podman
|
||||||
|
BUILDAH_FORMAT=docker podman build \
|
||||||
|
--build-arg BUILD_DATE="$(BUILD_DATE)" \
|
||||||
|
--build-arg GIT_REVISION="$(GIT_REV)" \
|
||||||
|
--build-arg FORGEJO_MCP_VERSION="$(FORGEJO_MCP_VERSION)" \
|
||||||
|
-t $(IMAGE_NAME):$(IMAGE_TAG) \
|
||||||
|
.
|
||||||
|
|
||||||
|
image-run: image ## Build the image and run it locally with the example env
|
||||||
|
@test -f $$HOME/.config/fjmcp-broker.env || { \
|
||||||
|
echo "Create $$HOME/.config/fjmcp-broker.env first (see deploy/podman/fjmcp-broker.env.example)"; \
|
||||||
|
exit 1; }
|
||||||
|
podman run --rm -it \
|
||||||
|
--env-file $$HOME/.config/fjmcp-broker.env \
|
||||||
|
-e FJMCP_BROKER_LISTEN=:8080 \
|
||||||
|
-e FJMCP_BROKER_STORE=/data/broker.db \
|
||||||
|
-v fjmcp-state:/data:Z \
|
||||||
|
-p 127.0.0.1:8080:8080 \
|
||||||
|
$(IMAGE_NAME):$(IMAGE_TAG)
|
||||||
|
|
||||||
help: ## Show available targets
|
help: ## Show available targets
|
||||||
@awk 'BEGIN {FS = ":.*##"; print "Targets:"} /^[a-zA-Z_-]+:.*?##/ { printf " %-8s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
|
@awk 'BEGIN {FS = ":.*##"; print "Targets:"} /^[a-zA-Z_-]+:.*?##/ { printf " %-8s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ Process-level isolation. Each user's Forgejo token lives in exactly one subproce
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`docs/design.md`](docs/design.md) | Architecture, components, token flow, deployment, security |
|
| [`docs/design.md`](docs/design.md) | Architecture, components, token flow, deployment, security |
|
||||||
| [`docs/plan.md`](docs/plan.md) | Seven-phase implementation plan with acceptance criteria |
|
| [`docs/plan.md`](docs/plan.md) | Seven-phase implementation plan with acceptance criteria |
|
||||||
|
| [`docs/deploy-podman.md`](docs/deploy-podman.md) | End-to-end production deploy with rootless podman + Quadlet |
|
||||||
|
| [`Containerfile`](Containerfile) | Multi-stage build; bundles broker + pinned forgejo-mcp |
|
||||||
|
| [`deploy/podman/`](deploy/podman/) | Quadlet unit and example env file |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
46
deploy/podman/fjmcp-broker.container
Normal file
46
deploy/podman/fjmcp-broker.container
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Podman Quadlet unit for fjmcp-broker.
|
||||||
|
#
|
||||||
|
# Drop this file at ~/.config/containers/systemd/fjmcp-broker.container,
|
||||||
|
# then run:
|
||||||
|
# systemctl --user daemon-reload
|
||||||
|
# systemctl --user start fjmcp-broker
|
||||||
|
#
|
||||||
|
# Quadlet generates a transient systemd service from this file. State
|
||||||
|
# lives in the named volume "fjmcp-state"; recreating the container
|
||||||
|
# preserves SQLite data and registered OAuth clients.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=fjmcp-broker — OAuth 2.1 broker for forgejo-mcp
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
Image=ghcr.io/olemd/fjmcp-broker:latest
|
||||||
|
ContainerName=fjmcp-broker
|
||||||
|
|
||||||
|
# Named volume for persistent SQLite state. Quadlet creates the volume
|
||||||
|
# on first start if it doesn't exist.
|
||||||
|
Volume=fjmcp-state:/data:Z
|
||||||
|
|
||||||
|
# Required configuration. Set FJMCP_BROKER_PUBLIC_URL to the
|
||||||
|
# Caddy-fronted hostname clients will see. Forgejo OAuth credentials
|
||||||
|
# come from a separate file outside this unit so the unit itself is
|
||||||
|
# safe to commit.
|
||||||
|
EnvironmentFile=%h/.config/fjmcp-broker.env
|
||||||
|
Environment=FJMCP_BROKER_LISTEN=:8080
|
||||||
|
Environment=FJMCP_BROKER_STORE=/data/broker.db
|
||||||
|
|
||||||
|
# Caddy reverse-proxies to localhost:8080.
|
||||||
|
PublishPort=127.0.0.1:8080:8080
|
||||||
|
|
||||||
|
# Healthcheck via /healthz. Three failures (90s) trigger restart.
|
||||||
|
HealthCmd=/usr/local/bin/fjmcp-broker --version
|
||||||
|
HealthInterval=30s
|
||||||
|
HealthRetries=3
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
25
deploy/podman/fjmcp-broker.env.example
Normal file
25
deploy/podman/fjmcp-broker.env.example
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Example environment file for fjmcp-broker.
|
||||||
|
#
|
||||||
|
# Copy this to ~/.config/fjmcp-broker.env (chmod 0600) and fill in the
|
||||||
|
# real values. The Quadlet unit reads this via EnvironmentFile=.
|
||||||
|
|
||||||
|
# Public-facing URL — what clients (Claude.ai) will see. MUST match
|
||||||
|
# what Caddy serves; the broker uses this verbatim in OAuth metadata.
|
||||||
|
FJMCP_BROKER_PUBLIC_URL=https://mcp.example.com
|
||||||
|
|
||||||
|
# Upstream Forgejo instance. The broker delegates user authentication
|
||||||
|
# here via OAuth2.
|
||||||
|
FORGEJO_URL=https://forgejo.example.com
|
||||||
|
|
||||||
|
# OAuth2 application credentials. Create the application at
|
||||||
|
# Forgejo → Settings → Applications → OAuth2 Applications,
|
||||||
|
# with the redirect URI set to ${FJMCP_BROKER_PUBLIC_URL}/oauth/callback.
|
||||||
|
FORGEJO_OAUTH_CLIENT_ID=replace-with-your-forgejo-app-id
|
||||||
|
FORGEJO_OAUTH_CLIENT_SECRET=replace-with-your-forgejo-app-secret
|
||||||
|
|
||||||
|
# Scopes requested from Forgejo. The default covers the full forgejo-mcp
|
||||||
|
# tool surface. Trim if your users don't need write access.
|
||||||
|
FORGEJO_OAUTH_SCOPES=read:user write:repository write:issue write:notification read:organization
|
||||||
|
|
||||||
|
# Optional: verbose logging.
|
||||||
|
# FJMCP_BROKER_DEBUG=true
|
||||||
169
docs/deploy-podman.md
Normal file
169
docs/deploy-podman.md
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
# Deploying fjmcp-broker with rootless podman
|
||||||
|
|
||||||
|
End-to-end walkthrough for a production deployment: build the image,
|
||||||
|
configure secrets, start the container, persist state across restarts,
|
||||||
|
and upgrade to a newer image without losing data.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Podman 4.4 or later (Quadlet support).
|
||||||
|
- A reachable hostname with TLS (Caddy handles this — see
|
||||||
|
`docs/deploy-caddy.md` for the front-end half).
|
||||||
|
- A Forgejo instance you control, with permission to register an OAuth2
|
||||||
|
application.
|
||||||
|
|
||||||
|
## 1. Register the Forgejo OAuth application
|
||||||
|
|
||||||
|
The broker authenticates users by delegating to your Forgejo instance's
|
||||||
|
OAuth2 provider.
|
||||||
|
|
||||||
|
1. Sign in to Forgejo as the operator who should own the integration.
|
||||||
|
2. **Settings → Applications → OAuth2 Applications → Create
|
||||||
|
application**.
|
||||||
|
3. Redirect URI: `https://<your-broker-hostname>/oauth/callback`.
|
||||||
|
4. Save the issued `client_id` and `client_secret`.
|
||||||
|
|
||||||
|
## 2. Build the image
|
||||||
|
|
||||||
|
The Containerfile bundles two binaries — `fjmcp-broker` (this repo) and
|
||||||
|
a pinned version of `forgejo-mcp` — into a distroless static image.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman build \
|
||||||
|
--build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
--build-arg GIT_REVISION="$(git describe --always --dirty)" \
|
||||||
|
--build-arg FORGEJO_MCP_VERSION="2.18.0" \
|
||||||
|
-t ghcr.io/olemd/fjmcp-broker:latest \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
Pin `FORGEJO_MCP_VERSION` to a tag — `latest` would mean image
|
||||||
|
reproducibility depends on the upstream HEAD at build time. The broker
|
||||||
|
spawns this binary per session, so version drift is the operator's
|
||||||
|
problem to track.
|
||||||
|
|
||||||
|
## 3. Configure the environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config
|
||||||
|
cp deploy/podman/fjmcp-broker.env.example ~/.config/fjmcp-broker.env
|
||||||
|
chmod 0600 ~/.config/fjmcp-broker.env
|
||||||
|
$EDITOR ~/.config/fjmcp-broker.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Required values:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `FJMCP_BROKER_PUBLIC_URL` | What clients see (e.g. `https://mcp.example.com`) |
|
||||||
|
| `FORGEJO_URL` | Upstream Forgejo instance |
|
||||||
|
| `FORGEJO_OAUTH_CLIENT_ID` | From step 1 |
|
||||||
|
| `FORGEJO_OAUTH_CLIENT_SECRET` | From step 1 |
|
||||||
|
|
||||||
|
The broker derives discovery URLs (`/.well-known/...`) from
|
||||||
|
`FJMCP_BROKER_PUBLIC_URL`, never from the inbound `Host` header — set
|
||||||
|
this to exactly the hostname Caddy serves.
|
||||||
|
|
||||||
|
## 4. Start under systemd via Quadlet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/containers/systemd
|
||||||
|
cp deploy/podman/fjmcp-broker.container ~/.config/containers/systemd/
|
||||||
|
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user start fjmcp-broker
|
||||||
|
systemctl --user status fjmcp-broker
|
||||||
|
```
|
||||||
|
|
||||||
|
Quadlet creates the `fjmcp-state` named volume on first start. The
|
||||||
|
SQLite store and its WAL/SHM sidecars all live there.
|
||||||
|
|
||||||
|
Smoke-test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS http://127.0.0.1:8080/healthz | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
A healthy response includes `{"status":"ok","store":"ok",…}`. Wire
|
||||||
|
Caddy in front next; see `docs/deploy-caddy.md`.
|
||||||
|
|
||||||
|
## 5. Persist state across image upgrades
|
||||||
|
|
||||||
|
The point of the named volume is that you can replace the container
|
||||||
|
entirely and keep your data. To upgrade:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull / build the new image.
|
||||||
|
podman pull ghcr.io/olemd/fjmcp-broker:latest
|
||||||
|
|
||||||
|
# Restart — Quadlet recreates the container against the new image.
|
||||||
|
systemctl --user restart fjmcp-broker
|
||||||
|
```
|
||||||
|
|
||||||
|
The `fjmcp-state` volume is detached from container lifecycle. Any
|
||||||
|
registered OAuth clients (`/oauth/register` results), issued tokens,
|
||||||
|
and refresh-token history survive.
|
||||||
|
|
||||||
|
To verify mid-upgrade:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Before upgrade
|
||||||
|
podman exec fjmcp-broker /usr/local/bin/fjmcp-broker --version
|
||||||
|
|
||||||
|
# Inspect the SQLite store directly (via a temporary container).
|
||||||
|
podman run --rm -v fjmcp-state:/data:ro \
|
||||||
|
docker.io/library/alpine ls -l /data/
|
||||||
|
|
||||||
|
# After upgrade, confirm the same broker.db is in place.
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration runner is idempotent — re-opening the same database is a
|
||||||
|
no-op for already-applied migrations, so a downgrade-then-upgrade
|
||||||
|
across compatible schema versions works too.
|
||||||
|
|
||||||
|
## 6. Backups
|
||||||
|
|
||||||
|
`fjmcp-state` contains user-mapped credentials. Treat it as sensitive:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Online backup using SQLite's .backup command.
|
||||||
|
podman exec fjmcp-broker /usr/local/bin/sqlite3 \
|
||||||
|
/data/broker.db ".backup /data/backup.db"
|
||||||
|
|
||||||
|
podman cp fjmcp-broker:/data/backup.db ./broker-$(date -u +%Y%m%d).db
|
||||||
|
```
|
||||||
|
|
||||||
|
Encrypt backups at rest. The broker stores Forgejo access tokens in
|
||||||
|
cleartext (it has to, to spawn subprocesses with them in env); a leaked
|
||||||
|
backup gives an attacker every user's upstream token until each
|
||||||
|
expires.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Healthz returns 503 with `store: "error: ..."`.** The SQLite file
|
||||||
|
isn't reachable. Check volume mount and permissions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman exec fjmcp-broker ls -l /data
|
||||||
|
```
|
||||||
|
|
||||||
|
The container runs as uid 65532. If the volume was created with
|
||||||
|
different ownership (e.g. you bind-mounted a host directory), `podman
|
||||||
|
unshare chown -R 65532:65532 /path/to/host/dir`.
|
||||||
|
|
||||||
|
**`/oauth/authorize` redirects to a wrong host.** The broker echoes
|
||||||
|
`FJMCP_BROKER_PUBLIC_URL` verbatim in OAuth metadata. Mismatched values
|
||||||
|
between the broker and Caddy show up as redirects to the wrong domain.
|
||||||
|
|
||||||
|
**Container exits immediately with `config error`.** A required env
|
||||||
|
var is missing or empty. The broker lists every missing field in its
|
||||||
|
stderr:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl --user -u fjmcp-broker --since "5 minutes ago"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Forgejo callback fails with 404.** The Forgejo OAuth application's
|
||||||
|
registered redirect URI doesn't match
|
||||||
|
`${FJMCP_BROKER_PUBLIC_URL}/oauth/callback`. Trailing slashes and
|
||||||
|
`http` vs `https` matter here.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue