Commit graph

15 commits

Author SHA1 Message Date
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