Skip to content

feat(mcp): lazy auth-header deferral in proxy mode - #1

Merged
lorem-dev merged 7 commits into
developfrom
feature/lazy-fetch
May 13, 2026
Merged

lorem-dev merged 7 commits into
developfrom
feature/lazy-fetch

Conversation

@lorem-dev

@lorem-dev lorem-dev commented May 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

In locksmith mcp run --url (proxy mode), vault-resolved HTTP headers are now sent only after the remote MCP server responds 401/403. Servers that authorise the MCP initialize handshake without auth (a common pattern - capability negotiation usually does not require credentials) no longer trigger a vault prompt at all for that connection.

This builds on the lazy-fetch + session-refresh work already on develop and goes one step further: even the first MCP request from the AI client (initialize) no longer pays the auth cost up front.

Behaviour

Configured headers split into two groups based on the template syntax:

  • Static headers (no { token, e.g. User-Agent: MyClient): sent on every request from the very first one.
  • Templated headers (contain {key:...} or {vault:... path:...}): resolved and attached only after the server signals it needs them.

State machine:

unauthenticated  ──first request without auth──▶  200 → stay unauthenticated
                                            └▶  401/403 → resolve auth → retry once → switch to
                                                                                       ↓
                                                                                  authenticated (sticky)
authenticated    ──every subsequent request carries the cached auth headers──▶  forever

StreamableHTTP and SSETransport both retry on 401/403. AutoTransport delegates to its inner transport; the 404/405-driven fallback path is orthogonal to auth-retry.

What changed

Commit Description
b99ca82 refactor(mcp): plumb HeaderResolver through Transport (no behaviour change)
8fecc77 feat(mcp): Streamable HTTP transport retries with auth on 401/403
7000673 feat(mcp): SSE transport retries with auth on 401/403
1d6d64c feat(mcp): defer templated proxy headers to first 401/403 (proxy.go wiring + E2E tests + runLoop shutdown hardening)
a82fd42 docs: describe lazy auth-header deferral for proxy mode (README + configuration.md)
ff4da22 docs(architecture): describe auth-header deferral and SSE GET limit
c09e163 docs(changelog): record lazy auth-header deferral in proxy mode

Side fix in 1d6d64c: runLoop now closes the transport explicitly on stdin EOF / send error and drains pending server messages. Real StreamableHTTP transports never close msgCh on their own, so the prior loop would deadlock when stdin EOFs against a non-mock transport. The new end-to-end tests in proxy_test.go exposed it.

Known limitations

  • SSE GET stream not reopened with auth after late resolve. If a server accepts the long-lived GET /sse without credentials but then demands them on POST /messages, the existing GET is left as-is and notifications on it may stop arriving. Workaround: configure such servers with --transport http (Streamable HTTP has no long-lived GET).
  • Local mode unchanged. Env vars are baked into the child at exec; lazy auth is fundamentally not possible without changing the MCP server contract. The existing lazy-fetch (resolve on first stdin line) is as deep as local mode can go.
  • JSON-RPC-level auth errors are not detected. Servers that return HTTP 200 with a JSON-RPC error payload signalling auth must surface 401/403 instead.

Configuration

No new flags, no new config fields. The deferral kicks in automatically for any header whose template contains a { token. Plain static headers behave as before.

Test plan

  • make lint clean (golangci-lint v2 strict ruleset).
  • make test-race clean across the workspace.
  • make test-coverage shows >= 90% per package.
  • New tests cover: 200 path, missing resolver, 401 retry, 403 retry, sticky auth, retry-after-resolve still fails, SSE Connect retry, SSE Send retry, proxy E2E (templated deferred, static always-sent, no-templates no-resolver).
  • Manual: configure an MCP server in ~/.config/locksmith/config.yaml with a templated Authorization header against a real backend; observe that no vault prompt fires on locksmith mcp run --server <name> startup, only when the server returns 401/403.
  • Manual: confirm a server that always requires auth still works (pays one extra HTTP round-trip on the first message).

lorem-dev added 7 commits May 13, 2026 22:58
Introduce HeaderResolver type and extend NewTransport to accept a
static-headers + lazy-auth-resolver pair. Each Transport struct gains
staticHeaders, resolveAuth, authMu and cachedAuth fields plus
effectiveHeaders/ensureAuth helpers. Header reads route through
effectiveHeaders so a later commit can flip the auth-retry path on
without touching the request-building code again.

Behaviour is unchanged: all callers pass nil for resolveAuth so
cachedAuth is never populated and effectiveHeaders returns
staticHeaders only.
StreamableHTTP.Send now detects 401/403 responses and, if a
HeaderResolver is configured and no auth has been resolved yet,
invokes the resolver and retries the request once with the freshly
cached auth headers. Subsequent requests reuse the cached headers
(sticky authenticated state). 401/403 returned after auth is already
present is propagated as a genuine failure.

runGetStream silently closes on 401/403 (same as 404/405): the
optional server-notifications GET never drives auth resolution; the
next POST does.
SSETransport.Connect now retries the GET /sse handshake once with
resolved auth headers if the unauthenticated GET returns 401/403.
SSETransport.Send retries POST /messages the same way.

When auth is resolved late (server allowed GET without auth, then
demanded it on POST) the long-lived GET stream is NOT reopened with
auth. Documented as a known limitation: servers that filter
server-initiated notifications on auth should be configured with
--transport http instead of --transport sse (or auto).
RunProxy now splits cfg.Headers into static (sent on every request
from the start) and templated (resolved on demand from the vault).
buildAuthResolver builds a HeaderResolver closure that the transport
invokes the first time the remote MCP server returns 401/403.

Servers that authorise the MCP initialize handshake without auth (a
common pattern) no longer trigger a vault prompt at all until the AI
client invokes a tool that requires authorization.

Also tightens runLoop's shutdown so the server-message forwarder exits
once stdin EOFs (or any send error fires), draining any buffered
responses before closing the transport. Without this the new end-to-end
proxy tests against a real httptest server would block indefinitely
because real Transport implementations never close msgCh on their own.
Document that templated headers (those referencing the vault) are
held back until the remote MCP server responds 401/403, and that
servers needing auth on long-lived SSE GET streams should use
--transport http to avoid the documented limitation.
Extend the MCP wrapper proxy-mode bullet with the auth state machine
(static headers eager, templated headers resolved on 401/403, sticky
authenticated state). Document the SSE GET reopen limitation and the
--transport http workaround.
@lorem-dev
lorem-dev merged commit 4447e0e into develop May 13, 2026
3 checks passed
@lorem-dev
lorem-dev deleted the feature/lazy-fetch branch May 13, 2026 21:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant