forgejo-mcp/openspec/specs/stateless-http-auth/spec.md
Christoph Görn f107ca364a
docs: 📝 archive stateless-http-auth change, sync spec
PR #185 merged the last blocker fix and smoke tests 6.4-6.6 are
resolved (6.4/6.5 verified E2E against codeberg.org, 6.6 covered by
unit test TestClient_EphemeralConstructionError). The change is now
complete.

- sync delta -> openspec/specs/stateless-http-auth/spec.md (new
  normative capability, 7 requirements)
- archive change to openspec/changes/archive/2026-06-02-stateless-http-auth/
- close bead forgejo-mcp-8if
2026-06-02 00:01:15 +02:00

8.9 KiB

stateless-http-auth

Purpose

Enable per-request authentication for the HTTP and SSE transports by extracting a token from the Authorization header and constructing a request-scoped Forgejo client, while preserving the pre-existing global-token (singleton) behavior for the stdio transport. This allows a single server process to serve multiple distinct Forgejo identities concurrently without leaking credentials across requests.

Requirements

Requirement: HTTP transport extracts per-request token from Authorization header

When the server is started with --transport http, every incoming MCP request SHALL be inspected for an Authorization header. The header SHALL be parsed for one of two schemes, case-insensitively:

  1. token <X> — Forgejo's native token scheme.
  2. Bearer <X> — OAuth2-style bearer transport.

When a recognized scheme is present, the parsed token value SHALL be injected into the request context.Context via forgejo.WithToken(ctx, token). The MCP handler invoked for that request receives the augmented context.

When the Authorization header is absent, empty, or carries an unrecognized scheme, the request context.Context SHALL NOT carry a token. Downstream code falls back to the global singleton client (see "Token-aware client factory" below).

The transport MUST NOT accept tokens without a scheme prefix; a header value that does not match one of the two named schemes SHALL be treated as if no header were present.

Scenario: Request with token scheme injects the token

  • WHEN an HTTP request arrives with header Authorization: token abc123
  • THEN the system SHALL inject abc123 into the request context via forgejo.WithToken
  • AND the MCP handler SHALL see that token via forgejo.Client(ctx)

Scenario: Request with Bearer scheme injects the token

  • WHEN an HTTP request arrives with header Authorization: Bearer abc123
  • THEN the system SHALL inject abc123 into the request context

Scenario: Scheme matching is case-insensitive

  • WHEN an HTTP request arrives with header Authorization: bearer abc123 (lowercase)
  • OR with header Authorization: TOKEN abc123 (uppercase)
  • THEN the system SHALL inject abc123 into the request context the same as it would for the canonical-case form

Scenario: Absent header falls through to global client

  • WHEN an HTTP request arrives with no Authorization header
  • THEN the request context SHALL NOT carry a token
  • AND forgejo.Client(ctx) SHALL return the process-wide singleton initialized from the --token flag

Scenario: Bare token (no scheme) is rejected

  • WHEN an HTTP request arrives with header Authorization: abc123 (no scheme prefix)
  • THEN the system SHALL treat the request as if no Authorization header were present
  • AND the system SHALL fall through to the global singleton client

Requirement: SSE transport extracts per-request token from Authorization header

When the server is started with --transport sse, the SSE server SHALL apply the same Authorization-header extraction rules as the HTTP transport (above), via the SDK's WithSSEContextFunc. All scheme-matching scenarios apply identically.

Scenario: SSE request with Bearer scheme injects the token

  • WHEN an SSE request arrives with header Authorization: Bearer abc123
  • THEN the system SHALL inject abc123 into the request context the same as the HTTP transport would

Requirement: Token-aware client factory selects ephemeral or singleton client

The function forgejo.Client(ctx context.Context) *forgejo.Client SHALL be the single entry point used by every tool handler to obtain a Forgejo SDK client.

When the supplied ctx carries a non-empty token (set via forgejo.WithToken), Client SHALL construct a fresh, request-scoped SDK client via forgejo.NewClient(flag.URL, forgejo.SetToken(token)) and return it. This client SHALL NOT be cached, shared, or reused across requests.

When the supplied ctx carries no token, Client SHALL return the process-wide singleton client. The singleton SHALL be initialized lazily on first call via sync.Once, using flag.Token as the credential. This preserves the pre-existing stdio behavior verbatim.

Scenario: Context with token returns ephemeral client

  • WHEN forgejo.Client(ctx) is called with a context that carries token=abc123
  • THEN the system SHALL return a freshly constructed *forgejo.Client configured with abc123
  • AND the returned client SHALL NOT be the package singleton

Scenario: Context without token returns singleton

  • WHEN forgejo.Client(ctx) is called with a context that carries no token
  • THEN the system SHALL return the package singleton constructed from flag.Token
  • AND consecutive calls SHALL return the same pointer

Requirement: Failed ephemeral construction MUST NOT fall through to the singleton

When forgejo.Client(ctx) is called with a context that carries a token, but the SDK call forgejo.NewClient(...) returns an error (e.g. the SDK's server-version probe fails on a network error, slow response, or HTTP 5xx from Forgejo), the function MUST NOT return the package singleton. Returning the singleton would silently authenticate the request as the startup --token identity instead of the requested per-request identity — a silent identity downgrade with privilege-escalation implications.

Instead, the function SHALL surface the failure to the caller. The implementation MAY accomplish this by changing the function signature to return (*forgejo.Client, error), or by returning nil and logging an error at Error level. The chosen mechanism MUST cause the calling tool handler to fail the MCP request rather than silently process it under the wrong identity.

Scenario: Ephemeral construction failure surfaces an error

  • WHEN forgejo.Client(ctx) is called with ctx carrying token abc123
  • AND the underlying forgejo.NewClient(...) call returns an error
  • THEN the system SHALL NOT return the package singleton
  • AND the system SHALL surface the error such that the calling tool handler fails the request

Scenario: Ephemeral construction failure does NOT log the token

  • WHEN the failure scenario above occurs
  • THEN any logged error message or wrapped error chain SHALL NOT contain the value of abc123

Requirement: Per-request clients are isolated across concurrent requests

Two concurrent requests carrying distinct tokens T_A and T_B SHALL produce two distinct, independent SDK clients. Outbound HTTP requests made by request A's handler SHALL carry Authorization: token T_A; outbound requests made by request B's handler SHALL carry Authorization: token T_B. Under no circumstances SHALL request A's client carry T_B, or vice versa, or the global --token value when a per-request token was supplied.

Scenario: Two concurrent requests produce two distinct identities at Forgejo

  • GIVEN two goroutines, each obtaining a client via forgejo.Client(ctx) with distinct context tokens T_A and T_B
  • WHEN each goroutine makes an outbound SDK call concurrently
  • THEN the request originating from goroutine A SHALL arrive at Forgejo carrying Authorization: token T_A
  • AND the request originating from goroutine B SHALL arrive carrying Authorization: token T_B

Requirement: Raw HTTP path respects per-request token

The pkg/forgejo/rawhttp.go helper used for attachment upload and download SHALL read the request token from ctx (via tokenFromContext) and prefer it over the global flag.Token value when setting the Authorization header on outbound raw HTTP calls. When no token is present in ctx, the helper SHALL fall back to flag.Token.

Attachment tool handlers in operation/attachment/ SHALL pass their request ctx into this helper so per-request identity is honored for binary operations.

Scenario: Attachment upload uses per-request token

  • WHEN an HTTP MCP request with Authorization: token abc123 invokes create_issue_attachment
  • THEN the outbound HTTP POST to Forgejo's attachment endpoint SHALL carry Authorization: token abc123 in its headers, not the global --token value

Requirement: Stdio transport preserves pre-existing global-token behavior

When the server is started with --transport stdio, no Authorization extraction SHALL be performed. Tool handlers under stdio receive a context that carries no token; forgejo.Client(ctx) returns the package singleton initialized from --token. All pre-existing stdio behavior, including tests, SHALL remain valid.

Scenario: Stdio handler receives global-token client

  • WHEN the server is run with --transport stdio --token xyz789
  • AND a tool handler invokes forgejo.Client(ctx)
  • THEN the system SHALL return the package singleton client constructed from xyz789
  • AND consecutive calls within and across handlers SHALL return the same client pointer