cmd/broker/main.go now composes every phase-2-5 component into a live
binary:
/healthz → internal/httpserver
/oauth/* → internal/oauth.Server.Handler()
/.well-known → internal/oauth.Server.Handler()
/mcp → oauth.Authenticator.RequireBearer
over session.Registry.Handler()
The SpawnFunc passed to the registry composes supervisor + bridge: each
new MCP session forks `forgejo-mcp --transport stdio` with the user's
upstream token in env, wraps stdio with a bridge, and returns the
bridge's HandleSSE as the per-session http.Handler. The reaper is wired
with a refresh callback that calls forgejo.Client.Refresh and persists
rotated tokens back to access_tokens before the rotator swaps the
session's child.
cmd/broker/e2e_test.go is the gating local validation: builds the
binary, builds forgejo-mcp from the sibling repo (skipped if absent),
stands up a fake Forgejo, runs the broker, and walks
register → authorize → callback → token → /mcp initialize → tools/list.
This catches:
- any component left unwired
- the subprocess-context bug fixed in this commit (using a request
context in supervisor.Start kills the child when the request that
minted it returns; the fix is a long-lived childCtx)
- the happy-path Mcp-Session-Id mint+reuse cycle that unit tests
can't exercise without a real subprocess
docs/phase7-findings.md documents both the local automated validation
(this test) and the manual Claude.ai-side checklist (OAuth completes,
tool discovery, tool invocation, session reuse, idle reap, mid-session
token refresh, revocation). The Claude.ai half is fundamentally manual
and stays that way; the automated test catches the broker bugs that
would otherwise hide behind operator setup mistakes.
Closes forgejo-mcp-broker-q6n. Phase 7 — and the project's primary
implementation track — complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds deploy/caddy/Caddyfile and docs/deploy-caddy.md, the front-end
half of the production deployment that pairs with deploy-podman.md.
Caddyfile:
- reverse_proxy with flush_interval -1 (mandatory for /mcp SSE)
- structured JSON access log to a separate file
- validated with `caddy validate` and formatted with `caddy fmt`
- omits explicit X-Forwarded-{For,Proto,Host} since Caddy forwards
them by default (caddy validate flags them as redundant)
deploy-caddy.md walks operators through:
- why a reverse proxy at all (TLS, SSE, future rate limits)
- the host-header trap and why FJMCP_BROKER_PUBLIC_URL is the
trusted source of issuer URLs (cross-references the existing
TestDiscovery_IssuerIgnoresHostHeader regression)
- SSE buffering as the most common deployment foot-gun
- optional rate-limit recipe via caddy-ratelimit (defers to backlog
issue -ttl)
- troubleshooting for the four failure modes the broker has actually
seen during dev: wrong issuer, buffered SSE, unreachable upstream,
TLS conflict
README updated to link both deploy guides and the deploy/ subtree.
Closes forgejo-mcp-broker-r2c.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Structured review of every OAuth/auth handler against the standard
attack catalog. Findings table added to design.md §8.2.
Two real issues found and fixed:
- Refresh-token replay race: tokenRefreshGrant read the row, validated
it, then minted a new pair before unconditionally revoking the old
refresh. Two concurrent /token requests with the same refresh would
both pass validation and both mint a fresh pair — token-quota
duplication and a hint to a stolen-refresh attacker. Fixed with the
same atomic UPDATE rows-affected pattern already used for auth-code
single-use. New TestToken_Refresh_ConcurrentReplay_OnlyOneSucceeds
races two goroutines and verifies exactly one wins.
- Permissive redirect_uri schemes: validateRedirectURI accepted any
non-empty scheme, including javascript: and data:. Tightened to
require https, http for loopback only, or a reverse-DNS private-use
scheme per RFC 8252 §7.1. TestValidateRedirectURI updated to cover
each variant including the rejected javascript:/data: cases.
Items deferred to backlog (already filed):
- Rate limits on /oauth/register and /oauth/token (-ttl)
- --token-fd to close the /proc/<pid>/environ window (-1n2)
- AES-GCM at-rest encryption of Forgejo tokens (-sd4)
Closes forgejo-mcp-broker-wgo. Phase 2 complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds StartReaper to internal/session — two background goroutines that
keep the session map healthy under steady load.
Idle reaper:
- Sweeps every ReapInterval (default 30s) for sessions whose
LastActive is older than IdleTimeout (default 15m).
- Evicts via SIGTERM through the Backend.Stop hook.
Token rotator:
- Sweeps every RotateInterval (default 1m) for sessions whose Forgejo
token is within RefreshLead (default 5m) of expiry.
- Calls the operator-supplied RefreshForgejo to obtain new
access+refresh tokens, then Respawn to mint a new Backend with the
updated token in env.
- Atomically swaps e.backend (now an atomic.Pointer[Backend]); the
sid is preserved so the client just re-issues an MCP `initialize`
on its next request rather than re-authenticating.
- On refresh failure, evicts so the next /mcp produces a clean
re-auth instead of carrying a stale token.
Two race fixes uncovered by -race during this work:
- The Done-watcher started in spawnSession captured the original
backend pointer; after rotation it still saw Done close (because
the old backend was Stopped) and would yank the entire entry. Fixed
by comparing watched-backend == e.backend.Load() before evicting.
- The fakeSpawner test helper let tests read the backends slice
without the lock the spawn callback held. Replaced with a
spawnerControl type whose count/at/snapshot methods all lock.
Tests cover idle eviction, recently-active sessions surviving sweeps,
successful rotation+respawn (sid preserved), refresh failure → eviction,
and Stop idempotency.
Closes forgejo-mcp-broker-q4x. Phase 5 complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds internal/session.Registry, the MCP session glue that maps
Mcp-Session-Id to a running forgejo-mcp child + bridge.
Lifecycle:
- First /mcp POST without Mcp-Session-Id: SpawnFunc creates a backend
(in production: supervisor.Start + bridge.New); registry mints a
192-bit hex session id, attaches it to the response header, and
dispatches the request to the new backend.
- Subsequent POSTs with the header dispatch to the existing backend.
- Unknown sids → 410 Gone (per MCP guidance, so clients re-initialise
instead of retrying forever).
- Sids are bound to the OAuth token that minted them: a different
bearer probing a stolen sid gets 403, distinct from "your token is
bad" (401) and "sid unknown" (410).
Cleanup:
- When backend.Done closes (child exited on its own — crash, OOM,
user-driven shutdown), a goroutine reaps the entry.
- Stop tears every session down on broker shutdown. The 30s idle
reaper and Forgejo token rotation come in 5c.
The Registry is decoupled from supervisor and bridge via SpawnFunc, so
tests don't need to fork real processes — they hand the registry a fake
that returns a controllable Backend. Also added oauth.ContextWithSession
so the session tests can inject an oauth.Session into request contexts
without standing up the full bearer-middleware chain.
Tests: 83.3% coverage. Cover spawn-on-initialize, sid reuse, unknown
sid, max-session cap with Retry-After, no-auth-context guard, sid
hijack defense (token mismatch → 403), Done-channel reaping, and
graceful Stop.
Closes forgejo-mcp-broker-t81.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>