Commit graph

20 commits

Author SHA1 Message Date
9c8cf40501 feat(oauth): bearer-token middleware (forgejo-mcp-broker-ytw)
Adds Authenticator.RequireBearer — http middleware that gates downstream
handlers on a valid broker access token.

Lookup path:
  1. Read Authorization: Bearer <token> header.
  2. SHA-256 the token, query access_tokens by token_hash.
  3. Reject expired or revoked rows.
  4. Build a Session (client_id, forgejo user info, upstream token,
     scopes) and attach to r.Context() under a typed key.

Downstream handlers (the MCP endpoint shipping in 5a) read the
upstream Forgejo token via SessionFromContext to spawn forgejo-mcp
subprocesses scoped to the right user.

Failures emit 401 with an RFC 6750 §3 WWW-Authenticate header carrying
distinct error codes (invalid_request for missing/malformed headers,
invalid_token with reason=expired/revoked/unknown for token problems).
The body stays empty so a confused browser doesn't render auth errors;
all detail rides in the header where compliant clients look for it.

Tests: 90.9% on RequireBearer, 91.7% on lookupSession. Covers valid
token, missing/wrong-scheme/empty Authorization, unknown token,
expired token (clock-advanced past AccessTokenTTL), revoked token (via
the public /oauth/revoke endpoint).

Closes forgejo-mcp-broker-ytw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:10:21 +02:00
fee12a2ac0 feat(oauth): /.well-known discovery endpoints (forgejo-mcp-broker-b2o)
Adds RFC 8414 (oauth-authorization-server) and RFC 9728 (oauth-
protected-resource) metadata documents.

Both URLs are derived from cfg.Issuer at construction time, never from
inbound request headers. Test TestDiscovery_IssuerIgnoresHostHeader
explicitly probes this — a malicious Host: evil.example.com value must
not leak into the published metadata. Defense against the OAuth
metadata-spoofing class starts at the discovery layer.

Capabilities published reflect the actual OAuth surface:
  - response_types_supported = ["code"]
  - grant_types_supported = ["authorization_code", "refresh_token"]
  - code_challenge_methods_supported = ["S256"]   (PKCE only, no plain)
  - token_endpoint_auth_methods_supported = ["none"]   (PKCE-only public clients)

Protected-resource metadata advertises /mcp as the resource; phase 5
will mount the gated MCP endpoint there.

Closes forgejo-mcp-broker-b2o.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:08:12 +02:00
d16b18ea38 feat(oauth): authorization-server endpoints (forgejo-mcp-broker-pur)
Implements internal/oauth, the broker's OAuth 2.1 AS surface that
Claude.ai (and other MCP clients) talk to. User authentication is
delegated to upstream Forgejo via internal/forgejo.

Endpoints:
  POST /oauth/register   — RFC 7591 dynamic client registration
  GET  /oauth/authorize  — RFC 6749 + 7636 PKCE (S256 only)
  GET  /oauth/callback   — Forgejo redirects back here after consent
  POST /oauth/token      — authorization_code + refresh_token grants
  POST /oauth/revoke     — RFC 7009

Security model:
- PKCE required, S256 only — plain method rejected per OAuth 2.1
- Every broker-issued access/refresh token stored as hex(sha256(plain));
  plaintext leaves the broker exactly once in the /token response body
- Refresh-token rotation: each refresh issues a new token pair and
  revokes the old refresh (RFC 6749 §10.4)
- Auth-code single-use enforced atomically via UPDATE...WHERE used_at IS
  NULL with rows-affected check, blocking the concurrent-replay race
- Issuer URL sourced from cfg.Issuer at construction time, never from
  inbound headers — prevents host-header injection on /.well-known
  metadata that ships in 2d
- redirect_uri must match a registered URI exactly (no prefix/wildcard)

Pending-authorization state (between /authorize and /callback) lives in
an in-memory sync.Map with a 1-minute reaper goroutine. A broker restart
drops them, forcing the user to re-authorize — acceptable trade-off
versus introducing a fifth table.

Tests: 81.0% coverage with ~20 cases across happy paths, every required-
field error, PKCE failure, code-replay, refresh expiry/revocation,
client-id and redirect-uri mismatches, Forgejo-side errors, and the
reaper logic itself (internal test).

Closes forgejo-mcp-broker-pur. The OAuth keystone is in place; phase 2c
unblocks discovery (2d) and security review (2e), and combined with the
existing supervisor + bridge it unblocks the session glue work in
phase 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:04:34 +02:00
9d3b16e7b3 test(bridge): integration test against real forgejo-mcp (forgejo-mcp-broker-xot)
Spawns a real forgejo-mcp --transport stdio subprocess, runs the
canonical MCP handshake (initialize → notifications/initialized →
tools/list → tools/call get_forgejo_mcp_server_version) through the
bridge, and verifies each step. Validates that the opaque-pipe
assumption holds end-to-end: every JSON-RPC message round-trips
correctly with no hand-rolled framing surprises.

Binary discovery (skipped if none found):
  1. $FORGEJO_MCP_BIN
  2. ../../../forgejo-mcp/forgejo-mcp (sibling-repo built binary)
  3. go build of ../../../forgejo-mcp into a temp dir

Fake Forgejo (httptest.Server) covers the two probes the SDK and
forgejo-mcp's testConnection do at startup:
  - GET /api/v1/version → returns 11.0.0 (>=1.11.0 satisfies SDK gate)
  - GET /api/v1/user    → returns a minimal authenticated user blob

Skipped under -short. Catch-all 404 handler logs unexpected probes so
new SDK or forgejo-mcp behaviour surfaces clearly in test output.

Closes forgejo-mcp-broker-xot. Phase 4 complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:28:32 +02:00
03f67786be test(supervisor): stress tests for FD/goroutine/zombie leaks (forgejo-mcp-broker-31t)
Adds two stress tests:

  TestStress_NoLeaksAcross1000Cycles — spawns and reaps 1000 children
  in sequence, asserts FD count, goroutine count, and zombie status are
  all stable.

  TestStress_StopMidLifecycle — 200 cycles that exercise the Stop path
  (SIGTERM via Close+Signal) rather than relying on natural exit.

Bypassed by -short for the unit-test inner loop.

Notable findings:

* Using the helper-process pattern at this scale was a dead end. Each
  spawn re-execs the test binary, which inherits the parent's open FDs
  and runs Go's `testing` package init. Past a few hundred cycles the
  inner test binaries drag delivery of EOF on their inherited stderr
  pipe ends, leaving drainStderr goroutines blocked in bufio.ReadString
  even after Wait returned. Replacing the helper with /bin/true (for
  quick-exit) and /bin/cat (for echo-loop) sidesteps the recursion and
  is closer to the production case anyway: the broker spawns
  forgejo-mcp, not itself.

* Defensively close stdout/stderr handles in supervisor's reap goroutine
  after cmd.Wait returns. cmd.StderrPipe is supposed to be closed by
  Wait, but under load the kernel doesn't always deliver EOF promptly
  through Go 1.26's pidfd-based wait path; an explicit Close ensures
  drainStderr exits and FDs aren't held longer than needed.

Tests pass under -race with FD/goroutine deltas in single digits across
1000+200 cycles, and Wait4(-1) confirms no zombie children.

Closes forgejo-mcp-broker-31t. Phase 3 complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:04:34 +02:00
bd68d7ed06 feat(bridge): JSON-RPC pipe + SSE writer (forgejo-mcp-broker-am1)
Adds internal/bridge: connects HTTP-side MCP clients to a stdio-side
child via JSON-RPC framing. Decoupled from internal/supervisor — takes
io.Writer + *bufio.Reader + done channel directly so it tests cleanly
with io.Pipe pairs and could later wrap something other than a child
process.

Routing model: one reader goroutine consumes child stdout line-by-line.
Each line is parsed only enough to extract the JSON-RPC `id` field
(string/number/null kept as raw JSON, so `1` and `"1"` don't collide).
HTTP requests register a per-id waiter channel before forwarding their
body to the child; the reader delivers the response to whichever waiter
matches. Concurrent in-flight requests are safe; a duplicate id while
the first is still pending returns 409.

HandleSSE response shapes:
  - request with id + child reply → 200 text/event-stream, one
    `event: message` SSE event carrying the JSON-RPC response
  - request without id (notification) → 204 No Content (no waiter
    needed; MCP notifications are fire-and-forget)
  - empty body → 400
  - duplicate in-flight id → 409
  - send-to-child fails → 502
  - client disconnect mid-wait → bridge cleans up its waiter; child
    keeps running, other in-flight requests unaffected
  - child exits before reply → SSE `error` event with reason=child_exited

Tests cover all of the above plus stale unsolicited replies, malformed
lines from the child, and reader robustness across both. 90.0%
coverage. The remaining gap is splitLines' empty-data branch (only
reachable if the child sends a literal `\n` line).

Closes forgejo-mcp-broker-am1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:59:28 +02:00
7be7f5e199 feat(supervisor): managed stdio subprocess (forgejo-mcp-broker-zuq)
Adds internal/supervisor: a thin wrapper around os/exec that handles the
zombie/leak/escalation concerns once, so phase-4 (bridge) and phase-5
(session glue) don't each have to re-derive them.

Lifecycle (Stop):
  1. Close stdin — well-behaved stdio servers exit on EOF
  2. Send SIGTERM
  3. Wait up to StopGrace (default 5s) for exit
  4. SIGKILL if still alive

Reaping is mandatory: a goroutine calls cmd.Wait so the kernel actually
collects the child. Without it you accumulate zombies under N concurrent
sessions. Tests exercise this via the helper-process pattern (TestMain
re-execs the test binary in helper mode) — no shell or external binary
dependency.

Tests cover: empty Cmd validation, missing-binary error, echo round
trip via stdin/stdout, stderr drainer collecting lines, SIGTERM-friendly
graceful stop, SIGTERM-ignoring child escalating to SIGKILL (with a
ready-on-stdout sync barrier so the test isn't racing the helper's
signal.Notify), idempotent Stop, clean exit detection, non-zero exit
detection, env override propagation. 89.6% coverage; remaining gap is
unreachable-from-public-API defensive branches (pipe-creation failures
under FD exhaustion, post-release Pid).

Manual smoke test against a real `forgejo-mcp --transport stdio` is
deferred to phase 4b's integration test (where it adds the most value).

Closes forgejo-mcp-broker-zuq.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:41:00 +02:00
006d5c1448 feat(forgejo): upstream OAuth client (forgejo-mcp-broker-b9i)
Adds internal/forgejo: a stateless OAuth 2.1 client for upstream Forgejo.
Covers what the broker AS needs:
  - AuthorizeURL: builds the user-agent redirect to /login/oauth/authorize
  - ExchangeCode: code → access+refresh tokens (PKCE verifier included)
  - Refresh: refresh_token grant (Forgejo rotates the refresh token)
  - FetchUserInfo: OIDC userinfo claims (sub, preferred_username, etc.)

OAuth errors come back as a structured *forgejo.Error so the AS can
distinguish "user must re-authenticate" (invalid_grant) from "transient
network problem" via errors.As. Forgejo doesn't currently expose a token
revocation endpoint, so revocation lives in the broker's own store —
upstream tokens expire naturally.

Defaults:
  - 30s HTTP timeout (Forgejo OAuth is sub-second when healthy)
  - User-Agent "fjmcp-broker" if not overridden
  - 64 KiB cap on response bodies (these endpoints return ~kilobytes)

Tests: 95.1% coverage. httptest.Server fake Forgejo exercises every
public method, every error shape (OAuth-formatted, plain {"message":...},
malformed JSON, missing required fields, network failure), and verifies
form params hit the wire as expected.

Closes forgejo-mcp-broker-b9i.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:31:19 +02:00
e4a7baa0bc feat(store): OAuth tables migration (forgejo-mcp-broker-cpb)
Adds migrations/0002_oauth_tables.sql per design.md §4.2: clients,
auth_codes, access_tokens, refresh_tokens. Cascading foreign keys
guarantee that revoking a client tears down every dependent row, and
that a refresh token can never outlive its access token.

Storage choices:
- Broker access/refresh tokens stored as hex-encoded SHA-256 hashes;
  plaintext leaves the broker exactly once (when handed to the MCP
  client). Lookups by hash are O(log n) via the PK index.
- Forgejo tokens stored cleartext (subprocess spawning needs them).
  At-rest protection is the volume permissions + optional encrypted
  volume; application-layer encryption is tracked as backlog item -sd4.
- Timestamps are unix epoch INTEGERs, set by the application — keeps
  deadline comparisons trivial and lets phase 5c inject a test clock.
- Tables are not STRICT to stay consistent with the phase-1 broker_meta
  table; converting both is a future cleanup if we want it.

Tests verify column sets via PRAGMA table_info, expected indexes are
present, the FK CASCADE works in both directions (client → tokens, and
access_token → refresh_token), and the oauth_schema_version marker is
written. Existing migration-count assertions parameterised on
embeddedMigrationCount so adding a third migration only needs that
constant bumped.

Closes forgejo-mcp-broker-cpb.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:28:12 +02:00
c5b19110c8 chore: bd export after filing phase 2-7 + backlog issues (21 new) 2026-04-24 17:46:05 +02:00
1555c285ba docs: fill CLAUDE.md with phase-1 session learnings
Populate the Build & Test, Architecture Overview, and Conventions
& Patterns sections of CLAUDE.md with context captured during the
phase-1 implementation work. Highlights the non-obvious gotchas
(http.Server.Shutdown not interrupting active conns; bd link
direction; bd init auto-committing) so future sessions don't
re-discover them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:37:36 +02:00
b7ef6b2070 chore: update beads export after creating -8yd (podman deployment) 2026-04-24 17:31:02 +02:00
09fcdc5af4 feat(cmd/broker): wire config → log → store → httpserver (forgejo-mcp-broker-t37)
Final phase-1 step: the broker now starts. run() parses config, opens
the store, builds the httpserver, and blocks on signal.NotifyContext
until SIGTERM/SIGINT fires, at which point it drains through
httpserver.Run's graceful-shutdown path and closes the store.

--version is handled before config.Load so operators can inspect a
binary without providing the rest of the config. flag.ErrHelp is passed
through so -h exits 0. Config failure exits 2; runtime failure exits 1.

Integration tests build the binary once in TestMain and exercise three
acceptance scenarios against it:
- --version: prints build info, exits 0
- no config: exits nonzero with stderr listing every missing field
- full startup: /healthz returns 200 with correct JSON; SIGTERM triggers
  clean exit within 2s

Closes forgejo-mcp-broker-t37. Phase 1 complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:29:37 +02:00
36722940eb feat(httpserver,log): /healthz, graceful shutdown, slog constructor
Implements internal/httpserver and internal/log.

httpserver (forgejo-mcp-broker-8ei):
- Server struct owns the HTTP lifecycle; Run(ctx) blocks, Handler() returns
  the composed handler for unit tests
- GET /healthz returns JSON with status, version, git_revision, build_date,
  and store probe result. Returns 503 when the store reports unhealthy
- Signal handling delegated to the caller via ctx cancellation — main wires
  signal.NotifyContext, httpserver just responds to Done()
- Graceful shutdown with a configurable deadline (default 10s). When the
  deadline expires, falls back to http.Server.Close() so lingering
  connections are forcibly terminated — http.Server.Shutdown alone never
  interrupts active connections
- ExtraHandler extension point for the OAuth + MCP routes that land in
  phase 2 and phase 5, so the server doesn't need to be re-plumbed later

log:
- Small slog wrapper: New(w, debug) returns a JSON logger that stamps every
  record with service/version/git_rev for correlation across deployments
- Discard() helper for tests

Tests: 97.9% coverage on httpserver (all health states, wrong-method,
ExtraHandler dispatch, ctx-cancel shutdown, shutdown-deadline force-close
of hanging requests, missing-field errors), 100% on log.

Closes forgejo-mcp-broker-8ei.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:26:32 +02:00
df2253398b feat(store): SQLite with embedded migrations (forgejo-mcp-broker-9jh)
Implements internal/store on top of modernc.org/sqlite (pure-Go, no CGO).
Open applies any pending migrations, Close releases the handle, Ping
underpins /healthz.

Migration design:
- Files embedded via embed.FS under migrations/NNNN_name.sql
- schema_migrations table tracks applied versions; re-open is a no-op
- Each migration runs in its own transaction: no partial commits
- loadMigrations takes an fs.FS so tests can inject synthetic migration
  sets to exercise rollback and conflict paths

Connection pragmas (set via DSN so they apply to every pooled conn):
- journal_mode=WAL — better reader/writer concurrency
- foreign_keys=ON — off by default in SQLite, we always want them
- busy_timeout=5000 — absorb brief contention without surfacing SQLITE_BUSY
- synchronous=NORMAL — standard WAL pairing

Phase 1 schema (0001_initial.sql) is minimal: a broker_meta table with a
schema_version row. Real OAuth tables ship in phase 2.

Tests: 90.1% coverage across public API and internal migration runner,
including bad SQL rollback, PK-conflict record-step failure, and scan
errors on malformed schema_migrations rows.

Closes forgejo-mcp-broker-9jh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:22:47 +02:00
382356c61c feat(config): flag + env parsing with validation (forgejo-mcp-broker-9nq)
Implements internal/config. Flags override env; empty env values are treated
as unset so exported-but-empty vars don't clobber defaults. Validation
aggregates every problem via errors.Join so operators see the full list
at once, not one at a time.

Notable decisions:
- Issuer URL validation requires http/https and rejects plain http on
  non-loopback hosts (classic OAuth misconfig). Loopback http allowed so
  local dev doesn't need TLS.
- Store-path writability probed via os.CreateTemp in the parent dir — a
  real write test, not a string check. No side effects on the store file
  itself (that's the storage layer's job).
- Public API: Load(args, out) returns *Config or error. flag.ErrHelp is
  passed through so callers can exit 0 on -h.

Tests: 94.1% line coverage, covers env/flag precedence, empty-as-unset,
every required-field message, each URL failure mode, numeric bounds,
env-parse errors, and -help output.

Closes forgejo-mcp-broker-9nq.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:10:18 +02:00
81765d93d6 chore: update beads export after closing n84 2026-04-24 16:57:29 +02:00
de5ce2de94 feat: bootstrap Go project layout (forgejo-mcp-broker-n84)
- Initialize go.mod with module path kode.naiv.no/olemd/forgejo-mcp-broker
- Create directory layout: cmd/broker + internal/{buildinfo,config,log,store,httpserver}
- Add Makefile with build/test/lint/tidy/clean targets and ldflags-injected build info
- Stub cmd/broker/main.go with --version support; real wiring follows in -t37
- Stub doc.go for each internal/* package, pointing to the issue that fills it in

Closes forgejo-mcp-broker-n84.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:54:27 +02:00
021bf0502d bd init: initialize beads issue tracking 2026-04-24 16:34:50 +02:00
2c7b50012c docs: initial planning artifacts for fjmcp-broker
Establish project scope, architecture, and phased implementation plan
for an OAuth 2.1 broker that fronts forgejo-mcp, delegating user
authentication to Forgejo and spawning a per-session stdio
forgejo-mcp subprocess scoped to each authenticated user's token.

No code yet — planning only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:21:01 +02:00