Skip to content

docs(specs): Spec 113 upstream resilience - OAuth refresh, discovery, call-error taxonomy, health - #1495

Merged
Dumbris merged 2 commits into
mainfrom
113-upstream-resilience
Oct 6, 2026
Merged

Dumbris merged 2 commits into
mainfrom
113-upstream-resilience

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 5, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds Spec 113 (speckit format: spec, plan, research, tasks, requirements checklist). It drives fixes for the gaps found by the 2026-10-01 audit of mcpproxy against the talk "How Anthropic uses MCP for its own product" (https://www.youtube.com/watch?v=e8DMLtP5ibk). Only spec files and the regenerated ROADMAP.md change. There is no code in this PR.

Each gap was checked again against origin/main @ 8442428d1. Evidence is in research.md §1.

Gap Verdict
G1 OAuth refresh not serialized Confirmed. There are three refresh paths, not two: the mcp-go reactive path, RefreshOAuthTokenDirect via the handler, and the manual stored-DCR path, which writes back a stale whole record. SaveToken reads and writes in two separate transactions.
G1+ (new) RefreshManager looks up token records by display name instead of server key (refresh_manager.go:363, :666). The refreshed event therefore never fires.
G2 refresh error classification Confirmed with a refinement: invalid_grant is already terminal. The real gaps are that invalid_client and 5xx fall into failed_other and retry up to 50 times. mcp-go wraps transport.OAuthError with %w, so the errors can be classified by type.
G3 offline_access Confirmed, but only when scopes come from PRM. Scopes taken from AS metadata already include every advertised scope.
G4 CIMD Confirmed. Deferred: hosting a client metadata document on mcpproxy.app is a maintainer product decision.
G5 overrides, extra_params heuristic, discovery cache Confirmed. mcp-go OAuthConfig has no endpoint fields, so endpoint overrides have to rewrite the metadata document inside the transport wrapper.
G6 call-error taxonomy Confirmed, with one correction: the Spec 107 audit line already has an error_class vocabulary. It is audit-only, and its transport.HTTPError/JSONRPCError branches have no production producers.
G7 health ignores call failure rate Confirmed. HealthCalculatorInput is built at three places.
G8 session-terminated 404 Confirmed with refinements. mcp-go maps every 404 to ErrSessionTerminated and clears the session id. mcpproxy flips the server to Error because of the substring "terminated".
G9 cache hints Partly incorrect. mcp-go v1.0.0 already sends ttlMs:0, cacheScope:"private" to modern clients by default. The remaining work is making this explicit and adding tests.
G10 server-edition per-user creds Confirmed, and intentional: Spec 107 FR-034 froze the broker chain, as serveredition/setup.go says. Deferred.

Slices

Each slice is an independent PR based on main. Slices do not stack and do not edit the spec files. plan.md has a file-ownership table.

Slice Gaps FRs Tasks
113-a oauth-refresh-robustness G1, G2 FR-001–FR-014 (+FR-006a) T100–T129
113-b oauth-discovery-overrides G3, G5 FR-020–FR-033 (+FR-027a) T130–T159
113-c call-error-taxonomy G6 FR-040–FR-051 T160–T189
113-d health-call-failure-rate G7 FR-060–FR-068 T190–T219
113-e session-terminated-reinit G8 FR-080–FR-088 (+FR-083a) T220–T239
113-f mcp-cache-hints G9 FR-090–FR-094 T240–T249
deferred (design only) G4, G10 — T250–T259

Assumptions (made autonomously)

  • 113-a: the token store that mcp-go uses returns tokens with ExpiresAt zeroed, and mcpproxy owns expiry. This is the only way to stop mcp-go's unlocked getValidToken refresh without forking mcp-go.
  • 113-c adds cancelled to the 9-class list so that a caller cancelling a call is not counted as an upstream fault.
  • 113-e repeats tools/call after a session re-init only for read-only tools whose identity is unchanged. Write and destructive calls are not repeated, because a 404 does not prove the server never executed them.
  • 113-f keeps TTL at 0 and scope private. Every listing depends on the caller and on approval state.
  • Thresholds (5 min window, ≥5 calls, >50%) are constants. The only new config fields are the four 113-b OAuth overrides.

Review

Reviewer: opencode github-copilot/gpt-6.1-sol (variant high), split into 3 chunks.

  • Round 1: 9 / 6 / 10 findings across the three chunks.
    • Critical/high findings fixed: mcp-go re-checks expiry after GetToken; the reactive path lacked the DCR fallback; no terminal latch or cooldown; DCR clear was not compare-and-clear; override bypass through mcp-go default endpoints; redirect downgrade; Spec 105 pinning on re-init; double execution of non-idempotent calls on a 404; a health ping clearing the session id.
    • Mediums were also folded in: errors.As value target, query-string overrides, cache coverage, stdio jsonrpc/dispatched facts, the calculator's early healthy return, sanitisation audit mapping, and registry lifecycle.
  • Round 2: chunk 2 clean. Fixed: a stale refresh flight could overwrite a newer login (FR-006a); waiters on the re-init gate skipped generation re-checks, and the GET listener was not restored (FR-083a).
  • Round 3: all re-reviewed chunks returned VERDICT: CLEAN.
  • Not changed (recorded as a note): retrying a refresh whose response was lost is no worse than not retrying (FR-008 rationale).

… call-error taxonomy, health

Spec for closing the verified gaps from the audit against the talk
"How Anthropic uses MCP for its own product": serialized OAuth refresh
and RFC 6749 error classification (113-a), discovery overrides,
offline_access and a discovery cache (113-b), call-error taxonomy on
activity records (113-c), call-failure-rate health (113-d),
session-terminated re-init (113-e) and explicit cache hints (113-f).
CIMD (G4) and server-edition per-user credentials (G10) are deferred
with design sketches.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1dc5584
Status: ✅  Deploy successful!
Preview URL: https://d1b7bc1f.mcpproxy-docs.pages.dev
Branch Preview URL: https://113-upstream-resilience.mcpproxy-docs.pages.dev

View logs

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: 113-upstream-resilience

Available Artifacts

  • archive-darwin-amd64 (31 MB)
  • archive-darwin-arm64 (28 MB)
  • archive-linux-amd64 (19 MB)
  • archive-linux-arm64 (17 MB)
  • archive-windows-amd64 (31 MB)
  • archive-windows-arm64 (27 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (27 MB)
  • installer-dmg-darwin-arm64 (24 MB)
  • smart-mcp-proxymcpproxy-go8CN9OD.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 37499209487 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

#1498 drops the 'unless a login flow is active' exemption for the DCR clear:
tryOAuthAuth holds a connection-wide flow during its own reactive refresh, so
the exemption kept a rejected registration forever. The compare-and-clear on
client id and refresh token protects anything a login saved.
Dumbris added a commit that referenced this pull request Oct 6, 2026
… retry once (Spec 113-e) (#1501)

## Problem

When a remote Streamable HTTP upstream forgets a session (restart,
expiry) it answers HTTP 404 to the session id mcpproxy holds. mcp-go
v1.0.0 maps every such 404 to `transport.ErrSessionTerminated` and
clears the transport's session id. Today that error text ("terminated")
matches `isConnectionError`, so the server flips to Error and goes
through a full reconnect, and the failing call is lost. Gap G8 of the
Anthropic MCP-proxy talk audit (Spec 113, US5).

## What changed

- `core/session_reinit.go`: `SessionSnapshot()` (transport session id +
whether the negotiated protocol is stateless) and
`ReinitializeSession(ctx)`, a fresh initialize on the same mcp-go
client/transport. `connectionEpoch` is never touched.
- `managed/session_reinit.go`: single-flight re-init keyed by the stale
session id. Concurrent callers share one flight; a caller that finds a
newer session proceeds; a request about to go out with an empty session
id while one is known joins the flight instead of sending without a
session. The flight does initialize plus a synchronous tools/list,
refreshes the identity baseline, and schedules the normal discovery
callback if the toolset changed.
- Wired into: the `tools/call` path, the ListTools leader (so the
cached-tool-count path is covered), prompts list/get, and the health
ping. List/get/ping are retried once. `tools/call` is retried once only
for read-only tools (`readOnlyHint` and not destructive) whose identity
hash is unchanged (FR-081); otherwise the call returns
`ErrSessionReestablished` (session re-established, call not repeated)
and the server is not marked Error. A pinned call whose tool changed
gets `ErrConnectionGenerationChanged`.
- Only the final error reaches `isConnectionError` (retry also 404, or
re-init fails: existing path).
- Info log per re-init (session ids as 8-char prefixes) and a per-client
counter exposed as `session_reinit_count` in `GetConnectionStatus`.
- `docs/setup.md`: troubleshooting note.

## mcp-go v1.0.0 verification (FR-088)

Pinned by `TestMCPGo_SessionTerminated_ReinitializeOnSameTransport`: a
404 on a non-initialize POST gives `ErrSessionTerminated` and clears the
session id; a request with no session id that is 404'd gives it too; a
second `Initialize` on the same client/transport stores a new session id
and the client works again. No divergence from the plan.

## Tests run

- New: `core` (mcp-go behaviour pin, snapshot/reinit) and `managed`
(`TestSessionReinit_*`: single read call = 1 re-init and state stays
Ready; 10 concurrent calls = exactly 1 re-init; write tool not repeated
and next call works; changed hash not retried and pinned call refused;
ListTools retried; ping-first then call sends no session-less request;
retry also 404 fails; pinned call epoch unchanged). `-race -count=15`
stable.
- `go test -race ./internal/upstream/...` green; `go build
./cmd/mcpproxy` and `go build -tags server -o /dev/null ./cmd/mcpproxy`
green.
- golangci-lint (CI config, bare and `--build-tags server`) on
`./internal/upstream/...`: only 2 pre-existing govet `reflect.Ptr`
findings in files this PR does not touch
(`core/connection_stdio.go:375`, `core/client_secret_test.go:452`).
- `./scripts/test-api-e2e.sh` on an isolated port, run alone: 65/66; the
one failure ("Audit log: server-edition binary present") is
environmental and fails identically on origin/main. Earlier runs
overlapped with sibling agents' e2e runs (shared launcher port 39933)
and showed launcher failures that vanished when run alone.

## Assumptions

- Retrying `tools/call` is limited to read-only tools per FR-081 (a
non-conforming server or intermediary could 404 after executing). Spec
018 operation type is not visible to `managed.Client`, so only the
`readOnlyHint` annotation is used; tools without that hint get the
"re-established, not repeated" error. A follow-up could pass the
`call_tool_read` variant down.
- FR-083a listener restore: mcpproxy does not enable mcp-go's continuous
GET listener on the upstream hop in production
(`WithContinuousListening` is only used in an e2e test), so there is no
listener to restore; not implemented.
- Prometheus counter: no observability seam was touched; counter is in
the connection status/diagnostics map only.
- `error_class=session_terminated` labelling belongs to 113-c; this PR
exposes the sentinel `managed.ErrSessionReestablished` for it to map.
- Not covered by a dedicated test: modern-protocol and non-404 no-retry
branches (guarded by `SessionSnapshot` modern flag and `errors.Is`
check).

Spec: #1495
Review: opencode github-copilot/gpt-6.1-sol, 2 rounds, clean;
follow-ups: (1) Spec 018 call_tool_read variant not passed down so
unannotated read tools are not retried; (2) residual narrow window where
a request that snapshotted the old session id is sent on the new id
before the flight's tools/list verifies (closing it would serialize
sends behind long tool calls); (3) tool-change epoch bump is not a
mechanism in this codebase (FR-083 relies on the differential update
path); (4) test hardening: RetryAlso404 reaching the retry, listener
fixture; (5) a caller ctx deadline while waiting on a flight is classed
as a connection error like any other deadline.
Dumbris added a commit that referenced this pull request Oct 6, 2026
…1504)

## Problem
A connected upstream whose tool calls mostly fail still reported
`healthy`: health looked only at connection and OAuth state. Gap G7 of
the Anthropic MCP-proxy talk audit (Spec 113, FR-060..FR-068).

## What changed
- New `internal/upstream/callstats`: a 30 x 10 s bucketed rolling window
(5 min) per server, a registry, and a denylist predicate `Classify`. Not
counted at all: `isError` tool results, limiter rejections,
`ErrConnectionGenerationChanged`, caller cancellation (including the
plain-text "context cancelled" form), auth-required errors. Everything
else returned by the dispatch is a counted failure; a normal result is a
counted success. Proxy policy/quarantine refusals and argument
validation happen before dispatch and never reach the recorder.
- `upstream.Manager` records at `callTool` (covers direct surface,
`call_tool_*`, REST) and `code_execution` records via
`RecordClientCallOutcome` (it bypasses the manager). The window and
notifier state are dropped in `RemoveServer` inside the same critical
section as the client delete. Nothing in `managed/client.go` or
`ActivityRecord`.
- `health.CalculateHealth` is now a wrapper: only a ready, enabled,
healthy result is downgraded to `degraded` when `calls >= 5` and
`failures/calls > 0.5`. Summary: "N of M tool calls failed in the last 5
min"; detail names the dominant failure kind; action `view_logs`.
Disabled, quarantined, connecting, unhealthy, OAuth and refresh outcomes
all take precedence (table tests). Thresholds are constants
(`health.CallFailureWindow`, `CallFailureMinSamples`,
`CallFailureRatio`); no config field.
- The three `HealthCalculatorInput` construction sites (`runtime.go`,
`server.go`, `mcp.go`) read `Manager.CallStats`; a drift-guard test
fails if a site stops assigning `CallsInWindow`.
- T199 (SSE): the existing servers.changed producers are all
connection-state driven, so there is no poll-and-diff to ride on. Added
a push path: `Manager.SetCallHealthObserver`, wired in
`runtime/lifecycle.go` to `emitServersChanged("call_failure_rate")`
(already coalesced). Debounced to one per 10 s per server, with a
per-bucket recheck timer while the window holds calls so passive
recovery or expiry-driven degradation also notifies.
- Docs: `docs/designs/2025-12-10-unified-health-status.md`.

## Tests run
- TDD: callstats, calculator and manager tests written first and
observed failing (compile errors / red assertions) before the
implementation.
- `go test -race` on `internal/health`, `internal/upstream/...`,
`internal/runtime/...`: pass.
- `go test -race -tags server -skip
"E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint"
./internal/server/... ./internal/httpapi/...`: pass.
- SC-005 coverage: `TestGetAllServers_HealthDegradesOnCallFailureRate`
(runtime projection, 6 of 10 -> degraded), window expiry in `callstats`
and notifier tests with an injected clock.
- Personal and `-tags server` builds; golangci-lint (both runs): no
findings in touched files (the local linter reports 15/18 pre-existing
issues in untouched files).
- `scripts/test-api-e2e.sh` on an isolated port (18468): 65/66 pass. The
one failure, "Audit log: server-edition binary present", needs a
prebuilt `mcpproxy-server` binary (environmental, unrelated to this
change).
- No REST/OAS/config changes, so no `make swagger`.

## Assumptions
- Thresholds live in `internal/health` (not `callstats`) so the
calculator does not import the managed-client dependency tree;
`callstats` takes the window length as its own constant of the same
value.
- `HealthCalculatorInput` gets a third field, `DominantCallFailureKind`,
so the detail text can name the kind without health importing callstats.
- The failure kind is derived from the error text (mcp-go discards HTTP
status and JSON-RPC codes); 113-c's classifier is not merged, so the
local predicate is used as the spec allows. Untyped errors count as
failures of kind "other".
- A caller deadline that expires after dispatch counts as a timeout
failure (only cancellation is excluded).

Spec: #1495
Review: opencode github-copilot/gpt-6.1-sol, 5 rounds, clean; earlier
rounds fixed text-form cancellation, expiry-driven notification,
in-flight resurrection of a removed window and a late-drop wipe; the
latest full re-review (3 chunks) found 3 mediums (text-cancel exclusion
without a done caller ctx, call window not dropped on config-change
client replacement, stale notifier timer callbacks), all fixed,
incremental round CLEAN; follow-ups: none.
@Dumbris
Dumbris merged commit d1718c7 into main Oct 6, 2026
57 checks passed
Dumbris added a commit that referenced this pull request Oct 6, 2026
…s (Spec 113-f) (#1510)

## Problem
Spec 058 FR-019 requires `ttlMs` and `cacheScope` on list/read results.
mcp-go v1.0.0 emits them (SEP-2549) with a library default of ttl 0 /
private; mcpproxy never configured them explicitly, so scope and TTL
were an unpinned library default. Talk gap G9.

## What changed
- `cacheHintServerOptions()` in `internal/server/mcp.go`:
`WithCacheHints` plus per-method `WithMethodCacheHints` (tools/list,
prompts/list, resources/list, resources/templates/list, resources/read,
server/discover), all `private`, ttl 5000 ms.
- Ticks T063/T064 in specs/058-mcp-2026-upgrade/tasks.md.
- `internal/server/cache_hints_test.go`: drives a modern-era request and
asserts private scope and the short TTL on each list method.

## Tests
- New tests failed to compile before the change (helpers undefined),
pass after.
- `go test -race` internal/server/... passes, bare and `-tags server`,
with the CI skip regex.
- personal and server-edition builds OK. golangci-lint (both runs)
reports 15/18 issues, all in files this PR does not touch (e.g.
tests/oauthserver, SA1019 on Go 1.26); none in changed files.
- Not run: `scripts/test-api-e2e.sh` (the change only adds server
options; the shared machine was saturated).

## Assumptions
- All listings are caller-dependent (profile Spec 108, agent-token scope
Spec 105, quarantine), so none is provably caller-independent and none
is public.
- 5 s TTL is a conservative freshness window; list_changed still
notifies connected sessions.
- The client-facing transport is pinned to the legacy era (FR-028),
where mcp-go does not emit these fields, so the hints take effect only
when that pin is lifted. The test therefore uses an unpinned transport.

Spec: #1495
Review: opencode github-copilot/gpt-6.1-sol, 2 rounds, clean;
follow-ups: hints are inert on the production transport until the FR-028
legacy-era pin is lifted (test uses an unpinned transport)
Dumbris added a commit that referenced this pull request Oct 6, 2026
…rrors (Spec 113-a) (#1498)

## Problem

Gaps G1 and G2 of the Anthropic MCP-proxy talk audit ("How Anthropic
uses MCP for its own product",
https://www.youtube.com/watch?v=e8DMLtP5ibk), re-verified in the Spec
113 research:

- **G1**: OAuth token refresh was not serialized. mcp-go's
`OAuthHandler.getValidToken` refreshes with no lock whenever the token
store reports an expired token, and the RefreshManager's proactive path
(`RefreshOAuthTokenDirect`, two sub-paths) refreshes independently. With
an authorization server that rotates refresh tokens, N concurrent
callers at expiry sent N refresh requests with the same refresh token.
Providers with reuse detection revoke the whole grant, so the user has
to sign in again. `PersistentTokenStore.SaveToken` also read the record
and wrote it in two transactions, so a concurrent DCR write was lost.
The new test reproduces both bugs on `main`.
- **G1+**: the RefreshManager looked up token records by display name,
but records are keyed by `GenerateServerKey(name, url)`. The token-age
log and the `oauth.token_refreshed` event never fired.
- **G2**: refresh errors were classified by substring. `invalid_client`
fell through to `failed_other`, so a dead DCR client was retried up to
50 times. 5xx and 429 were not told apart from other errors.

## What changed

- **`oauth.RefreshCoordinator`** (new, process-wide): runs at most one
flight per server key. The flight runs on a detached context with a 30 s
timeout. Inside the flight it first re-reads the record, and if the
refresh token was already rotated it returns the stored token with no
network call. All waiters get the same token or the same error. After a
terminal failure it latches, and after a transient failure reactive
callers get a cooldown that mirrors the RefreshManager backoff. The
latch and cooldown are keyed by grant generation (refresh-token and
client-id fingerprint), so a login clears them. A flight whose
generation was superseded during the request (for example by a login)
has its result discarded and causes no side effects (FR-006a). Each
flight logs one Info line with server, trigger, outcome class and
whether the network call was skipped.
- **Reactive path:** core binds the live handler's refresh function to
the token store handed to mcp-go (`c.bindOAuthRefresher(oauthConfig)`
after each OAuth `c.client =`). A bound `GetToken`:
  - refreshes through the coordinator inside the refresh margin;
- returns the token with `ExpiresAt` zeroed, so mcp-go's unlocked
`refreshToken` can never run;
- on terminal failure returns an error wrapping
`transport.ErrOAuthAuthorizationRequired`;
  - on transient failure returns `oauth.ErrTokenRefreshTransient`.

Unbound stores (read-only probes) behave as before. The CLI in-memory
store uses the same coordinator, keyed by server name.
- **Proactive path:** `RefreshOAuthTokenDirect` runs a coordinator
flight with the same refresh function. That function uses
`handler.RefreshToken` when the handler has a usable client id and the
stored DCR credentials otherwise, and never sends an empty `client_id`.
- **Storage:** `BoltDB.UpdateOAuthToken` (one read-modify-write
transaction) and `ClearOAuthClientCredentialsIf` (compare-and-clear).
`SaveToken` and the manual refresh persist through `UpdateOAuthToken`.
- **Classification:** `ClassifyRefreshError` reads the RFC 6749 §5.2
code from `transport.OAuthError` and the HTTP status from the typed
`RefreshHTTPError` or mcp-go's fixed non-JSON text. It also recognises
network error types. Substring matching is kept only as the last
fallback, and each pattern is tested.

  | Class | Handling |
  |---|---|
  | `invalid_grant` | Terminal |
| `invalid_client`, DCR client | Registration cleared once
(compare-and-clear on the client id and the refresh token the request
used); terminal with "sign in again to re-register". A second rejection
before any successful token response, from refresh or from the login
code exchange, is terminal with "rejects new client registrations". |
| `invalid_client`, static client | Terminal; credentials untouched;
message points at `oauth.client_id` / `oauth.client_secret` |
| `unauthorized_client`, `unsupported_grant_type`, `invalid_scope` |
Terminal |
| `server_error`, `temporarily_unavailable`, 5xx, 429, network |
Existing backoff |
  | Anything else | Existing backoff, then the existing give-up |

New metric labels: `failed_invalid_client`, `failed_server_error`.
Existing labels are unchanged.
- **RefreshManager:** looks up records by server key, treats every
terminal class as terminal and reports each one once, and learns about
terminal outcomes of reactive flights through the coordinator completion
hook.
- **Docs:** `docs/features/oauth-authentication.md` covers refresh
serialization, the error classes and the metric labels.

## Tests

- New: `refresh_errors_test.go` (table covering every class and fallback
pattern), `refresh_coordinator_test.go` (single flight with N=50, stale
observed token, shared failure, cancelled initiator, parallel keys,
terminal latch, transient cooldown, stale flight after login,
`invalid_client` DCR/static/connect-flow/compare-and-clear,
login-exchange rule), `persistent_token_store_refresh_test.go`,
`persistent_token_store_race_test.go`,
`memory_token_store_refresh_test.go`,
`refresh_manager_invalid_client_test.go`,
`storage/bbolt_oauth_update_test.go`.
- **SC-001:** `internal/upstream/core/oauth_refresh_race_test.go` uses a
rotating `httptest` AS that revokes the grant on reuse, a protected
Streamable HTTP MCP server, and a real mcp-go client. 50 concurrent
`CallTool`s plus one concurrent `RefreshOAuthTokenDirect` on an expired
token produce exactly 1 token request, all calls succeed, and the
request carries a non-empty `client_id`. It covers both sub-paths and
passes under `-race -count=20`. With the binding disabled, the same test
fails: the grant is revoked and the calls get "authorization required".
- `go test -race` passed for `internal/oauth/...`,
`internal/storage/...`, `internal/upstream/...`,
`internal/observability/...`, `internal/runtime/...` and
`internal/appctx/...`. The coordinator, store and storage tests also
pass with `-count=20`.
- `go build ./cmd/mcpproxy` and `go build -tags server -o /dev/null
./cmd/mcpproxy` both succeed.
- I ran golangci-lint v2 with `.github/.golangci.yml`, bare and with
`--build-tags server`. Neither run reports anything in changed or new
files. Both runs do report findings in files this PR does not touch (for
example `redactview.go`, `connection_stdio.go` and
`tests/oauthserver/jwks.go`).
- `./scripts/test-api-e2e.sh` against an isolated instance
(`LISTEN_PORT=18347`): passed. On the first run the launcher-lifecycle
reconnect check timed out once; the rerun passed fully, as did
`origin/main` on the same port.
- No REST, OAS or config change, so `make swagger` was not needed
(FR-013).

## Assumptions

- **Refresh margin for short tokens:** half the lifetime, capped at 30
s. The spec's "half their lifetime or 30 s" is read as the smaller of
the two, so a 30 s token is not refreshed on every request. Lifetime is
estimated from the record's `Updated` time.
- **Still-valid access token:** if a refresh fails while the current
access token is still valid (inside the 5 min grace), `GetToken` keeps
serving it. The terminal or transient error is returned only once the
token has actually expired, so an AS outage does not fail calls early.
The RefreshManager is told about terminal outcomes at once through the
hook.
- **Proactive fresh window:** a proactive refresh within
`MinRefreshInterval` (5 s, the RefreshManager's own scheduling floor) of
a token save, for a token with more than 5 s left, is answered from
storage (see the CI note below).
- **No login-flow exemption for the DCR clear (deviation from FR-009's
wording):** `tryOAuthAuth` holds a connection-wide OAuth flow while its
own reactive refresh runs, so the exemption kept a rejected registration
forever (every sign-in reloaded it and skipped DCR). The
compare-and-clear on client id plus refresh token already protects any
registration or grant a login saved. A code exchange that still uses the
cleared client is told to sign in again, not that the AS rejects new
registrations.
- **Cooldown scope:** the transient cooldown applies to reactive callers
only. The proactive path keeps its own unchanged backoff (10 s doubling
to a 5 min cap, `DefaultMaxRetries` 50).
- **DCR handler mismatch:** when the live handler's client id differs
from a newer stored DCR client id, the stored credentials are used. When
neither is available the refresh is terminal (`ErrNoClientCredentials`)
and never sends an empty `client_id`.
- **Binding sites:** the handler is bound at the five OAuth
client-creation sites in `connection_oauth.go` (one helper call each),
not at a single site as the plan suggested, because there is no shared
post-creation point. The three authorization-code exchange sites call a
one-line annotation helper, which implements the FR-009 login-flow rule.
- **RefreshManager lookup:** `RefreshManager` knows only the display
name, so FR-010 matches the record by `DisplayName` (most recently
updated wins), after trying a legacy record keyed by the bare name.
- **Reconnect fallback:** `upstream.Manager.RefreshOAuthToken` is
unchanged and keeps its reconnect fallback on failure.

## CI shuffle failure (run 37307361769)


`TestOAuthRefreshRace_OneTokenRequestPerExpiry/stored_DCR_credentials_sub-path`
saw 2 token requests, both valid (`rt-0` then `rt-1`, no replay). Root
cause was in production code: `RefreshOAuthTokenDirect` reads the record
when it runs, not when the RefreshManager timer fired. When the reactive
flight had already rotated the token, the proactive caller observed the
new refresh token, so the FR-003 "already rotated" check could not tell,
and a second flight rotated the just-minted grant. In production that is
the timer firing while a reactive refresh (or a login) completes, before
`OnTokenSaved` reschedules. Fix: the coordinator records the last token
save per key and answers a proactive request within the fresh window
from storage. `TestOAuthRefreshRace_LateProactiveAfterReactiveFlight`
reproduces the CI ordering deterministically for both sub-paths (it
failed before the fix). The race test's AS now holds the token request
for 50 ms so callers overlap the flight. Verified with the CI seed
(`-shuffle=1791202369572320301 -count=20`), `GOMAXPROCS=1 -count=50` and
`-count=50 -cpu 1,2,4` under `-race`.

## Review fixes (opencode gpt-6.1-sol)

Stale-flight side effects (latch re-read, DCR compare-and-clear now also
on the refresh token, schedule binding for terminal outcomes and
retries, logout during a flight or a grace refresh hands out no token,
DCR bookkeeping after a concurrent login); DCR recovery during a connect
attempt; secret redaction of refresh errors (pattern scrub plus the
exact sent credentials in raw, JSON- and URL-escaped spellings, decoded
JSON error fields, error code field); short-token handling (CLI memory
store save time, proactive window); `oauth.extra_params.client_id`
grants stay refreshable; long non-JSON bodies keep their RFC 6749 code,
while 5xx/429 stay transient.

Spec: #1495
Review: opencode github-copilot/gpt-6.1-sol, 8 rounds, clean (round 8
VERDICT: CLEAN); follow-ups: proactive refreshes are not held by the
reactive transient cooldown (by design, RefreshManager has its own
backoff); the still-valid access token is served during the refresh
margin even after a terminal refresh error (the hook applies the
terminal outcome to the schedule at once).
Dumbris added a commit that referenced this pull request Oct 6, 2026
…Spec 113-b) (#1499)

## Problem

Slice 113-b of Spec 113 (gaps G3 and G5 from the Anthropic MCP-proxy
talk audit):

- G3: mcpproxy never asked for `offline_access`, never warned when the
authorization server cannot issue refresh tokens, and had no way to
point a server at an authorization server whose metadata is non-standard
or advertises an unusable endpoint.
- G5: every `createOAuthConfigInternal` call re-fetched the same PRM and
authorization server metadata, and `extra_params` injection used a
`/token` / `/authorize` path heuristic.

## What changed

- New optional per-server `oauth.authorization_endpoint`,
`oauth.token_endpoint`, `oauth.registration_endpoint`,
`oauth.auth_server_metadata_url` (https, or http on a loopback host; no
query, fragment, userinfo or whitespace). Validated at write time only
(`OAuthConfig.Validate`, `Config.ValidateDetailed`); an invalid value
already on disk is dropped at load with a field-name-only warning, so
boot never fails.
- Endpoint overrides are applied by rewriting the authorization server
metadata inside `OAuthTransportWrapper` (mcp-go has no endpoint config).
With any endpoint override mcpproxy always passes mcp-go a metadata URL
(override, else discovered, else RFC 8414 URL from the override origin),
and when that metadata is unreachable and authorization + token
overrides are set the wrapper serves a synthesized document, so mcp-go
never falls back to `/authorize`, `/token`, `/register`.
- `extra_params` injection and 201 to 200 normalization match on the
effective (overridden or discovered) token endpoint URL; the path
heuristic remains only when no endpoint is known.
- `offline_access` is appended only when the scopes were taken from PRM
`scopes_supported` and the AS metadata `scopes_supported` contains it.
Explicit `oauth.scopes` and AS-derived scopes are untouched.
- One Warn per server per process when AS `grant_types_supported` is
present (including an empty list) and lacks `refresh_token`.
- Discovery cache (`internal/oauth/discovery_cache.go`): key =
hash(server URL, four overrides, kind); 1 h success TTL, 30 s failure
TTL, single-flight, LRU 256. Covers the HEAD/POST preflights, PRM
fetches, the scope fallbacks and the AS metadata document (shared with
the transport wrapper so mcp-go's own metadata GET is served from the
cache). A 401 advertising a different `resource_metadata` URL
invalidates the server's entries, including in-flight fetches.
- OAuth HTTP client redirect policy: refuses https to http on a
non-loopback host and any cross-origin redirect of a POST (compared with
effective ports).
- Config-field checklist: `OAuthConfigChanged`,
`MergeOAuthConfig`/`copyOAuthConfig`, `ServerFieldMaskDecisions` rows,
contracts OAuth projection + runtime/management plumbing, `make swagger`
(oas/ committed; `cmd/generate-types` produced no TS diff),
`docs/configuration.md`. Per-server fields, so no `MCPPROXY_` env
override. Echoed masks of the four fields are reverted on write
(`UnmaskLiveOAuth`), because the generic read view's name rule masks
leaves containing "auth"/"token".
- `refresh_manager.go` and `persistent_token_store.go` are untouched
(owned by slice a).

## Tests run

- Written failing first, then implemented: config (round trip, merge,
change detection, validation table, load-time sanitize, whitespace),
storage round trip, discovery cache (TTLs, keys, single-flight, LRU,
invalidation, atomic seeds), transport wrapper (rewrite, synthesis,
learned token endpoint, heuristic fallback, redirects, log redaction),
end-to-end with a real mcp-go `OAuthHandler` (authorization URL host
equals override, exchange + refresh hit the override, advertised
`/std/*` endpoints never hit), `createOAuthConfigInternal` integration
(5 calls = 1 request per discovery URL, separate keys per override set,
failure cached briefly, offline_access table, refresh-grant warning,
PRM-URL invalidation, unmask of echoed masks).
- `go test -race` on `internal/config`, `internal/storage`,
`internal/contracts`, `internal/management`, `cmd/generate-types`,
`internal/oauth`: pass.
- `go test -race -tags server -skip <CI regex>` on runtime, httpapi,
upstream, config, oauth, server/tokens: pass. `internal/server` (same
flags, 45m timeout) also passes; a first run with the 20m CI timeout hit
the timeout on the loaded shared machine (load average about 9),
unrelated to this change.
- `go build ./cmd/mcpproxy` and `go build -tags server -o /dev/null
./cmd/mcpproxy`: pass.
- golangci-lint v2 with `.github/.golangci.yml`, bare and `--build-tags
server`: no findings in touched files (the remaining repo-wide findings
are pre-existing Go 1.26 deprecation notices, e.g. `ecdsa.PublicKey.X`).
- `./scripts/test-api-e2e.sh` on isolated ports (18177/18179/18180): the
only consistent failure is `Audit log: server-edition binary present`,
which fails identically on `origin/main` (reproduced on a clean checkout
at 18178). Two of the three runs also hit a launcher-test reconnect
flake under machine load; the third run was green apart from the audit
item.
- Review: opencode `github-copilot/gpt-6.1-sol`, 4 rounds (round 1 in
three chunks, then three fix-and-re-review rounds). Round 1 found 9
issues, all fixed with tests (in-flight fetch repopulating after
invalidation, negative cache bypass, redirected metadata, default-port
origin match, POST preflight not recording the PRM URL,
transient-PRM-failure fallback pinned for 1 h, empty
`grant_types_supported`, whitespace validation bypass, masked echo on
write); rounds 2 and 3 found two more cache-seeding issues, fixed by
storing seeds atomically with the primary result; round 4 verdict CLEAN.

## Assumptions

- Overrides are plain per-server config fields with no env var; PATCH
merge keeps the base value when the patch value is empty (same as the
other scalar OAuth fields).
- The four override fields are labelled `NotSecret` in
`ServerFieldMaskDecisions` (typed projection shows them to the UI); the
write door still reverts echoed masks from the generic view.
- `internal/upstream/core/connection.go` is not edited: the PRM-URL
invalidation hook lives in `oauth.handleUnauthorizedResponse` (resource
auto-detection already sees every live 401 `resource_metadata`), which
keeps the slice inside `internal/oauth`.
- An authorization-server fallback result found on the MCP origin after
a failed PRM step is cached only for the failure TTL, not an hour, so a
transient PRM failure cannot pin a wrong AS.
- Known limitation: after a same-origin 307/308 redirect of a token
POST, the learned-endpoint match no longer applies the 201 to 200
normalization to the redirect target.
- The task text placed the end-to-end override test under
`internal/upstream/core`; it lives in `internal/oauth`
(`oauth_overrides_e2e_test.go`) because it only needs mcp-go's handler
and the config this package builds.

Spec: #1495
Review: opencode github-copilot/gpt-6.1-sol, 4 rounds, clean;
follow-ups: after a same-origin 307/308 redirect of a token POST the
learned-endpoint match no longer normalizes the 201 response
Dumbris added a commit that referenced this pull request Oct 7, 2026
…t_domain (Spec 113-c) (#1502)

## Problem

A tool call that fails shows only `status=error` plus prose in the
Activity log. Nobody can tell whether the network dropped, the upstream
returned HTTP 502, the upstream answered with a JSON-RPC error, the tool
reported a business error (`isError:true`), the session expired, auth
failed, or mcpproxy itself refused the call. Gap G6 of the Anthropic
MCP-proxy talk audit (Spec 113, slice 113-c).

## What changed

- New `internal/callerr`: a pure classifier `Classify(result, err,
facts)` returning `error_class` (network, timeout, http, jsonrpc,
tool_error, session_terminated, auth, proxy_policy, proxy_internal,
cancelled), `fault_domain` (upstream, proxy, client) and
`upstream_http_status`. Rules follow FR-041 in order.
`Outcome.AuditClass()` is a total mapping onto the frozen Spec 107 audit
vocabulary; `auditToolCall` uses it, so the audit line and the activity
record agree.
- `internal/transport`: a per-call `CallRecorder` round tripper,
installed as the outermost layer in `upstreamRoundTripper` (covers every
`CreateHTTPClient` branch and SSE). mcp-go flattens non-2xx responses
into an untyped string, so the status is recorded at the transport.
Requests without a recorder in their context are untouched.
- `core.Client.CallTool` marks the call dispatched at the exact point it
hands it to mcp-go (every transport) and returns a typed timeout error
with an unchanged message (it was a bare `fmt.Errorf` that dropped the
chain).
- `storage.ActivityRecord` gains `error_class`, `fault_domain`,
`upstream_http_status`, all `omitempty`. A pre-113 JSON fixture
round-trips byte-identical. `isError` results keep `status=error` and
are told apart by `tool_error`.
- Stamped at every tool-call completion path through the one funnel
(`call_tool_*`, legacy `call_tool`, direct surface, `code_execution`
sub-calls, REST `/api/v1/tools/call`); limiter sheds are stamped
`proxy_policy/proxy`.
- REST: `contracts.ActivityRecord`, SSE completion payload (forwarded
verbatim by `/events`), `error_class` / `fault_domain` query filters on
list and export. `make swagger` run, `oas/` committed. CLI: `activity
list|export --error-class --fault-domain`, `activity show` prints an
`Error class` line, `-o json|yaml` carry the fields. Docs updated.

## Tests run

- `go test -race` (no skips needed): callerr, transport, storage,
contracts, httpapi, audit, upstream/core, cmd/... all green.
- `go test -race -tags server` with the CI skip regex: runtime,
upstream/..., serveredition/... green; `internal/server` green when run
alone (1150 s). A combined run with runtime hit the 20 m timeout from
CPU contention on the shared machine.
- New: classifier table tests (every FR-041 rule, `isError`, 502 with
untyped error, `-32001` after a recorded 200, narrow fallback list),
`AuditClass` mapping, recorder installed in every client branch and
concurrency isolation, record JSON compatibility, runtime emit->persist,
and an `httptest` Streamable HTTP upstream integration test (SC-004)
asserting persisted record and `GET /api/v1/activity` JSON for isError,
502 HTML, JSON-RPC -32001, connection reset, timeout, 404 session, 401,
pre-dispatch validation and success, plus direct / code_execution / REST
write paths.
- Both golangci-lint runs (bare and `--build-tags server`): no findings
in touched files (remaining findings are pre-existing, in untouched
files).
- `./scripts/test-api-e2e.sh` on an isolated port with unique
results/log paths (another session was running the same script
concurrently; shared `/tmp` files and fixture port 39933 made the stock
run flaky on both this branch and `origin/main`): 65/66; the one
failure, `Audit log: server-edition binary present`, also fails on
`origin/main` (no server-edition binary built).

## Assumptions

- A call to a server that is not connected, or has no client, is
`network/upstream`; an error completion that no path classified is
`proxy_internal/proxy`; a `blocked` completion with no noted outcome is
`proxy_policy/proxy`.
- The fallback text list (FR-049) is `connection refused|reset`, `no
such host`, `broken pipe`, `unexpected eof`, `i/o timeout`,
`Client.Timeout exceeded`, `context deadline exceeded`, consulted only
when the call never left the proxy or HTTP requests went out with no
response, so an upstream's own message is never reclassified.
- The mcp-go `*transport.Error` wrapper counts as `network` only after
the recorded non-2xx rule, because mcp-go wraps HTTP errors in it (found
by the integration test).
- `cmd/generate-types` does not generate `ActivityRecord`, so the
hand-written `frontend/src/types/api.ts` interface got the three
optional fields instead. `call_tool_*` wrapper records
(`internal_tool_call`) and activity replay are not stamped; the
canonical `tool_call` record is.
- No config fields added (config-field checklist not applicable).
`internal/health/**` and `internal/upstream/managed/client.go`
untouched.

Spec: #1495
Review: opencode github-copilot/gpt-6.1-sol, 2 rounds, clean;
follow-ups: none (round 1 rejected: *transport.Error-before-http
ordering is an intentional documented deviation)
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.

2 participants