From 83fa68cb5f027a92cc69f5c82e13bfccbf267a8d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 22 Jul 2026 04:10:51 +0000 Subject: [PATCH 01/13] Add MCP OAuth lifecycle specification --- docs/prd/PRD-006-mcp-tool-integration.md | 31 +- .../.openspec.yaml | 2 + .../simplify-mcp-oauth-lifecycle/design.md | 482 ++++++++++++++++++ .../simplify-mcp-oauth-lifecycle/proposal.md | 169 ++++++ .../specs/mcp-oauth/spec.md | 270 ++++++++++ .../specs/netclaw-mcp/spec.md | 177 +++++++ .../specs/transactional-secrets/spec.md | 60 +++ .../simplify-mcp-oauth-lifecycle/tasks.md | 113 ++++ 8 files changed, 1301 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/simplify-mcp-oauth-lifecycle/.openspec.yaml create mode 100644 openspec/changes/simplify-mcp-oauth-lifecycle/design.md create mode 100644 openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md create mode 100644 openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md create mode 100644 openspec/changes/simplify-mcp-oauth-lifecycle/specs/netclaw-mcp/spec.md create mode 100644 openspec/changes/simplify-mcp-oauth-lifecycle/specs/transactional-secrets/spec.md create mode 100644 openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md diff --git a/docs/prd/PRD-006-mcp-tool-integration.md b/docs/prd/PRD-006-mcp-tool-integration.md index 5d51bff87..5f0252274 100644 --- a/docs/prd/PRD-006-mcp-tool-integration.md +++ b/docs/prd/PRD-006-mcp-tool-integration.md @@ -5,7 +5,7 @@ - State: Draft for execution (revised) - Owner: Netclaw engineering - Date: 2026-02-21 -- Revised: 2026-02-21 (Memorizer as external memory tier, tool loading) +- Revised: 2026-07-22 (secure SDK-owned OAuth and concurrent client lifecycle) - Depends on: `PRD-001`, `PRD-002`, `PRD-004` ## Goal @@ -96,11 +96,28 @@ Runtime SHALL degrade gracefully when MCP server is unavailable: ### MCP-009 Daemon-Bound Server Ownership -Each configured MCP server SHALL have at most one live client connection per -Netclaw daemon. A local STDIO server process and its internal state are shared +Each configured MCP server SHALL have at most one published client generation +per Netclaw daemon. A replacement MAY initialize while the published generation +continues serving, but it SHALL remain unpublished until initialization +succeeds, and the replaced generation SHALL not be disposed while it has +in-flight calls. A local STDIO server process and its internal state are shared by all sessions authorized to use that server; Netclaw session identity SHALL not launch or select a separate MCP process. +### MCP-010 Secure OAuth Lifecycle + +HTTP MCP OAuth SHALL delegate protocol operations to the MCP C# SDK while +Netclaw owns local browser-flow brokering, credential persistence, and client +lifecycle. Persisted tokens and dynamically registered client credentials SHALL +be bound to the configured MCP resource identity and SHALL NOT be supplied after +that identity changes or when a legacy record lacks a binding. Credential +persistence failures, invalid callback state, and authorization failures SHALL +fail visibly without deleting the last working credentials or connection. + +Concurrent authorization and reconnect attempts SHALL coalesce per server. +Ambiguous transport failures SHALL NOT automatically replay a tool invocation, +because the remote operation may already have completed. + ## Non-Goals (MVP) - Dynamic marketplace discovery of MCP servers @@ -119,6 +136,14 @@ not launch or select a separate MCP process. 6. Unavailable MCP server does not crash the session. 7. Calls from different authorized sessions to one local STDIO profile use the same daemon-owned client and child process. +8. Repointing an MCP profile does not send credentials bound to its old resource + identity to the new endpoint. +9. Legacy OAuth records without a resource binding fail closed and direct the + operator to reauthorize. +10. OAuth token persistence failure is visible and does not advance caller-visible + credential state. +11. A transport failure may reconnect the server for later calls but does not + replay the failed tool invocation automatically. ## Cross-References diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/.openspec.yaml b/openspec/changes/simplify-mcp-oauth-lifecycle/.openspec.yaml new file mode 100644 index 000000000..7250f8fbf --- /dev/null +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-22 diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/design.md b/openspec/changes/simplify-mcp-oauth-lifecycle/design.md new file mode 100644 index 000000000..2442adc90 --- /dev/null +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/design.md @@ -0,0 +1,482 @@ +## Context + +Netclaw runs the full MCP OAuth 2.1 protocol twice. `McpOAuthService` performs +protected-resource and authorization-server discovery, `WWW-Authenticate` +parsing, dynamic client registration (DCR), PKCE URL construction, +authorization-code exchange, refresh-token redemption, and metadata caching. +At the same moment `McpClientManager.BuildOAuthOptions` hands the MCP C# SDK a +`ClientOAuthOptions` with an `ITokenCache`, so the SDK performs the same +protocol again inside its transport. Two implementations of one standard can +drift on provider quirks, error handling, and credential state. + +A reliability audit (memorizer project `e687a20a-21cb-4c19-8ff7-695b198df907`) +confirmed eight independent defects around this duplication: + +1. Concurrent reconnects race. `TryReconnectAsync` removes and disposes the + live client, then rebuilds it with no per-server coordination. It is + reachable from missing-tool recovery, invocation-exception recovery, the + OAuth callback, and the background reconnection service, so two failures can + tear down the same generation, leak the losing candidate, or let a failed + rollback delete another caller's healthy client. +2. Cancellation and broad retry are unsafe. `InvokeSharedAsync` catches every + exception, tears the shared client down, reconnects, and retries — so caller + cancellation can destroy a healthy client, and a retry can duplicate a tool + whose remote side effect already succeeded. +3. OAuth wiring depends on a disposable cache. `CreateClientAsync` skips + discovery when a token exists and `BuildOAuthOptions` returns `null` when + `mcp-oauth-metadata.json` is absent and no static client ID is set, so a + valid token plus a missing metadata file silently installs no OAuth at all. +4. Token persistence reports false success. `McpTokenCacheAdapter` publishes the + rotated token in memory and calls `PersistTokens`, which catches every + exception and only logs. The SDK believes a rotated refresh token was stored; + the next restart reloads a stale, potentially single-use token and gets + `invalid_grant`. +5. Atomic replacement is not serialized mutation. `SecretsFileWriter` replaces + the file atomically but does not lock the surrounding read/modify/write, so + concurrent writers to different sections can each overwrite the other. +6. Netclaw duplicates the SDK OAuth protocol (the root cause above). +7. The callback URI is hard-coded to `http://127.0.0.1:5199/...` in three + places even though `DaemonConfig.Port` is configurable. +8. Diagnostics lose actionable errors. A provider that advertises DCR but + rejects registration with a bodyless `403` reaches the CLI as a blank error + line (netclaw-dev/netclaw#1475). + +The remediation is fewer moving parts, not a more complete second OAuth stack: +one owner per concern. `ModelContextProtocol.Core` stays pinned at **1.4.1** — +the stable release Netclaw already ships — so the design must work within that +version's surface and its known gaps. + +This subsystem is not actor-hosted. `McpClientManager` is an `IHostedService`; +tool calls reach it through `IMcpToolInvoker`, not a mailbox. The only durable +state is `secrets.json`; no Akka.Persistence journal or snapshot is involved. +The design keeps those boundaries and hardens the two responsibilities Netclaw +cannot delegate: client lifecycle and durable local state. + +## Goals / Non-Goals + +**Goals:** + +- Make `McpClientManager` the sole runtime owner of MCP client creation, + publication, replacement, and disposal, holding one published generation per + server under concurrent reconnects while safely draining replaced generations. +- Coalesce concurrent reconnects by observed generation; publish a fully + initialized candidate atomically; serialize teardown. +- Narrow reconnect to classified transport/session failures, restore service + for later calls without replaying an ambiguously failed invocation, and + propagate cancellation and application/tool errors untouched. +- Delegate the whole OAuth protocol to the MCP C# SDK and delete Netclaw's + parallel implementation and its `mcp-oauth-metadata.json` runtime dependency. +- Persist per-server token sets plus the DCR-issued client ID durably before + publishing them in memory; bind them to the configured MCP resource identity; + fail the connection loudly on persistence failure or binding mismatch. +- Serialize `secrets.json` read/modify/write with a cross-process lock so + concurrent writers to different sections cannot lose each other's update. +- Derive the callback URI from `DaemonConfig.Port`. +- Return structured, actionable OAuth diagnostics; never print a blank error. + +**Non-Goals:** + +- An OAuth refresh actor or a proactive token-refresh timer. +- A custom bearer handler or 401 replay middleware. +- Vendoring or previewing a patched SDK; the official upgrade is a later PR. +- Remote/public callback URL design (netclaw-dev/netclaw#297). +- Serializing every MCP tool call. +- Attributing past provider incidents to these defects without request traces. + +## Decisions + +### D1. The SDK owns the OAuth protocol; Netclaw stops re-implementing it + +**Decision:** OAuth discovery, authorization-server selection, DCR, PKCE, +authorization-code exchange, bearer injection, and refresh delegate entirely to +the SDK via `ClientOAuthOptions`, `ITokenCache`, +`AuthorizationRedirectDelegate`, and `DynamicClientRegistrationOptions.ResponseDelegate`. +`McpOAuthService`'s protocol code — metadata probing/parsing, DCR HTTP, +authorization-URL construction, code exchange, refresh redemption, +`GetValidTokenAsync`, and the metadata cache — is deleted. + +**Rationale:** One protocol implementation cannot drift from itself. 1.4.1 +already exposes every hook the interactive and non-interactive flows need, and +the redirect delegate explicitly supports custom UI, so the manual stack can go +now — before the pending upstream fixes ship. This is a net-negative-LOC change +that shrinks the attack and defect surface. + +**Alternatives considered:** + +- Finish Netclaw's own OAuth implementation to close the eight defects in place. + Rejected: it keeps two protocol authorities that can disagree on standards + behavior, and it grows the exact surface the audit says to remove. Fixing + bugs inside a duplicate is more code and more drift, not less. + +### D2. One generation-aware lifecycle gate and one published generation per server + +**Decision:** Replace the independent client/tool dictionaries with one +immutable per-server connection snapshot — `McpClient`, its function map, a +monotonically increasing generation, and status metadata. A per-server async +lifecycle gate guards create/publish/replace/teardown. A reconnect captures the +generation it observed, enters the gate, re-reads the published snapshot, and if +a newer healthy generation already exists it reuses that and returns — so +concurrent reconnects coalesce onto one winner. Otherwise it builds a candidate +**without** removing the published connection, initializes it fully +(`ListToolsAsync` and function-map construction), publishes it atomically as the +next generation, updates status and `ToolRegistry` from that same snapshot, then +retires the replaced generation. Each invocation acquires a lease on the +snapshot it uses; retirement blocks new leases but disposes the generation only +after its final in-flight invocation releases its lease. Initialization failure +disposes only the candidate and keeps the published connection. + +Invocation narrows accordingly: tool-declared MCP errors stay results and are +formatted, never reconnected; caller-token `OperationCanceledException` and +unknown application exceptions propagate with no teardown; only classified +transport/session failures request one coalesced replacement for later calls. +The failed invocation returns an error without automatic replay because the +remote operation may have completed before the transport failure became +visible. A failed invocation releases its snapshot lease before it requests or +awaits reconnect, so replacement retirement can never wait on the reconnecting +caller's own lease. + +**Rationale:** The gate protects lifecycle transitions, not OAuth. Coalescing on +generation gives ten simultaneous reconnects exactly one candidate and one +published survivor, which a plain lock cannot. Building the candidate before +retiring the incumbent means a failed replacement is invisible to callers; +leasing prevents routine replacement from interrupting work already in flight. + +**Alternatives considered:** + +- A plain `SemaphoreSlim` that tears down the client, then reconnects each + waiter in turn. Rejected: it serializes the race without coalescing it — + every waiter still reconnects, producing N candidates, N provider instances, + and the same leak/overwrite the audit found, just one at a time. + +### D3. No OAuth actor, no refresh service, no bearer middleware + +**Decision:** Keep this subsystem out of the actor system. `McpClientManager` +stays an `IHostedService`; the new flow broker and credential store are plain +singletons; the lifecycle gate is an async gate over a generation counter. No +proactive refresh timer or actor, and no custom bearer/401-replay handler. + +**Rationale (from the decision record):** An actor earns its keep only when its +mailbox owns the full mutable state and every relevant operation passes through +it. A credential-only actor would not coordinate the `ClientOAuthProvider` +instances created by concurrent reconnects, the SDK refreshes that happen inside +transport requests, client publication and teardown, or external `secrets.json` +writers — so it would not close the races that matter. Routing every MCP call +through an actor would coordinate them, but at the cost of a new invocation hop, +response adaptation, cancellation and mailbox-throughput semantics, and shutdown +behavior — for a boundary the manager already owns. A generation-aware async +gate is the smaller mechanism. A Netclaw refresh timer would be a second refresh +authority beside the SDK: the two could redeem the same rotating refresh token +concurrently, and it would drag provider-specific token-endpoint behavior back +into Netclaw. If idle-grant keepalive ever becomes a requirement, it should call +an MCP-level operation through the SDK, not redeem a refresh token directly. + +### D4. Transactional mutation extends `SecretsFileWriter`; no OAuth-specific store + +**Decision:** Add a path-scoped transactional update to `SecretsFileWriter` that +derives a lock identity from the canonical path, then holds one cross-process +lock across read, leaf decryption, JSON parse, the caller's mutation, +serialization, encryption, and `AtomicFile` replacement with permission hardening. Reuse the named-mutex / +SHA-256 path-hash pattern already proven in `WebhookRouteStore`. Migrate every +current `secrets.json` read/modify/write caller onto this boundary. Callers pass +owned field mutations that execute against the latest document read under the +lock; they do not pass a whole-file snapshot captured before the lock. Long-lived +editors retain their contribution actions and replay those actions inside the +transaction. Whole-file `Write` stays for intentional replacement but +participates in the same path lock. One singleton credential store backs every +per-connection `ITokenCache` adapter. + +**Rationale:** Reuse before add. `SecretsFileWriter` already owns encryption, +owner-only permissions, and atomic replace; the gap is only serialization around +the read/modify/write, which the webhook store already solved for the same +file-per-key shape. A separate OAuth file format would be a parallel construct +that duplicates encryption and permission logic and drifts from it. A shared +singleton store also stops per-adapter in-memory dictionaries from diverging. + +**Alternatives considered:** + +- A dedicated OAuth credential file with its own writer and lock. Rejected: it + reinvents the encryption, hardening, and atomic-replace machinery + `SecretsFileWriter` already provides, for no benefit, and adds a second + secret-bearing file to protect. + +### D5. A temporary client-ID bridge and fail-closed resource binding for stable SDK 1.4.1 + +**Decision:** Store the effective DCR-issued client ID — and client secret, +when the provider issues one — in the durable token record and seed +`ClientOAuthOptions.ClientId`/`ClientSecret` from it on restart. A +per-connection OAuth context initializes its client ID from +`McpServerEntry.OAuthClientId` or the stored record, updates it from +`DynamicClientRegistration.ResponseDelegate`, and has `StoreTokensAsync` persist +the `TokenContainer` plus that effective client identity together. + +The active durable record and any pending replacement record store the configured +resource identity that received the credentials. Its canonical representation is the absolute configured MCP +endpoint URI after `System.Uri` normalization, with scheme and host normalized, +default port normalized, fragment removed, and path and query retained. Before +an `ITokenCache` adapter returns credentials or the manager seeds SDK options, +the per-connection context compares the current endpoint identity with the +stored binding. A mismatch or missing legacy binding returns no tokens and does +not seed a dynamically registered client ID or client secret. It reports +`AwaitingAuth` and leaves the old durable record intact until a new authorization +succeeds. An operator-configured static client ID remains authoritative because +it belongs to current configuration rather than the stored dynamic registration. +This check happens before the SDK can inject an old bearer token into a request +to the changed endpoint. Netclaw does not silently stamp a binding onto a legacy +record because the profile may have changed before upgrade. + +An explicit authorization candidate uses a flow-scoped token-cache view. SDK +stores write a durable pending record rather than replacing the active record. +After tool listing succeeds, the lifecycle gate atomically promotes that pending +record and publishes the candidate; candidate failure or expiry removes only the +pending record. A crash may leave a pending record, but startup never treats it +as active and removes it after its flow lifetime. Ordinary refresh on a published +connection continues to update the active record persist-before-publish. + +If an authorization server rejects a stored dynamically registered identity as +`invalid_client`, explicit authorization may retry once with that stored dynamic +identity withheld, allowing the SDK to perform DCR again. This recovery never +discards an explicitly configured static client ID and never replaces the active +record until the new candidate publishes. + +Decompilation of the shipped 1.4.1 assembly confirms the necessity: the +provider's `_clientId` is an in-memory field never read back from the token +cache, `TokenContainer` carries only token fields (`TokenType`, `AccessToken`, +`RefreshToken`, `ExpiresIn`, `Scope`, `ObtainedAt`), and the DCR branch runs +whenever `_clientId` is empty — so without the bridge, every cold start that +needs fresh authorization silently registers a brand-new client. The +`ResponseDelegate` fires after the SDK parses the registration response and +exposes `ClientId` and `ClientSecret`, so capture there is reliable, and an +exception thrown from it propagates and fails the connection (persist-or-fail +holds). + +**Rationale:** 1.4.1's `TokenContainer` carries access token, refresh token, +type, scope, obtained-at, and expiry — but **no** registration fields — and its +cold-start path does not reliably restore a DCR-issued client ID. Without the +bridge, a restart re-registers or fails. This is narrow state adaptation, not +protocol ownership; it is scoped to be removed once the released SDK restores +registered identity itself (upstream #1658 / PR #1705). If the process exits +after DCR but before token exchange, re-registration on the next attempt is +acceptable — no second file exists solely to preserve an incomplete flow. + +**Alternatives considered:** + +- Keep the `mcp-oauth-metadata.json` cache as the client-ID home. Rejected: it + is the disposable file behind defect 3 — losing it silently disables OAuth — + and it duplicates state the token record already carries. + +### D6. Explicit reauthorization withholds the access token instead of deleting credentials + +**Decision:** `netclaw mcp auth ` runs against a cache view that does not +return the existing access token, which forces the SDK down the authorization +path — while the durable token set and refresh token stay intact until the new +tokens are stored. The interactive candidate is unpublished until success; +failure or cancellation disposes only the candidate and leaves the previous +connection and credentials untouched. + +**Rationale:** An operator re-authorizing must not lose working credentials if +the new flow fails or is abandoned. Deleting durable state first to "force" the +flow would leave the server unauthenticated on any failure. Withholding the +token in a scoped view triggers the SDK's authorization branch without touching +what is on disk. + +**Alternatives considered:** + +- Delete the cached token before starting the flow. Rejected: a closed browser + tab or a provider error would leave a previously working server with no + credentials at all. + +### D7. Two narrow components broker a daemon-owned browser handoff; the callback URI is derived + +**Decision:** Add `McpOAuthFlowBroker` (interactive handoff only: a +cryptographically opaque one-time state tied to one server, a five-minute +lifetime bounded by `TimeProvider`, a task the SDK completes with its authorization +URL, and a task the HTTP callback completes with the code) and +`McpOAuthCredentialStore` (durable per-server token sets plus the DCR client ID, +supplying `ITokenCache` adapters). The broker performs no discovery, DCR, PKCE, +exchange, or refresh. On `POST /api/mcp/oauth/start/{name}`, the manager opens a +pending broker flow and starts an unpublished candidate whose +`AuthorizationRedirectDelegate` is owned by that flow; the SDK generates the +authorization URL, the broker returns it through the existing +`McpOAuthStartResponse`, and `GET /api/mcp/oauth/callback` validates the exact +pending state and completes the flow. The callback request is never made the +candidate's lifetime owner, so closing the tab cannot cancel a running exchange. +The start request does not own the candidate either: the manager supplies a +daemon-owned cancellation token bounded by flow expiry and shutdown. A second +start for the same server coalesces onto the active flow and receives the same +state and URL, so only one candidate and credential write can win. Polling does +not report `Completed` until token persistence, tool listing, and candidate +publication all succeed; any later initialization failure reports `Failed`. +The broker lifetime matches the existing five-minute CLI and TUI polling +timeout, so no Termina behavior changes are needed. Polling cancellation ends +client interest without cancelling the daemon-owned flow. +While an interactive authorization candidate is active, background and +invocation-triggered reconnect requests for that server coalesce onto the +interactive replacement operation rather than creating a competing candidate. +They may continue using a healthy published generation, or report +`AwaitingAuth` when none exists, but cannot publish another generation ahead of +the authorization flow. +The redirect URI is +`http://127.0.0.1:{DaemonConfig.Port}/api/mcp/oauth/callback`, never derived +from a request `Host` header. For normal startup the redirect delegate is +non-interactive and returns no code, so startup never unexpectedly opens a +browser or blocks. + +**Rationale:** These two small components replace one large service that was +simultaneously protocol client, cache, state machine, and persistence layer. The +simplicity metric is fewer authorities and state machines, not fewer classes. +Deriving the port from config fixes defect 7 for any non-default local port. + +**Decompiled 1.4.1 contract (verified against the shipped assembly):** + +- `AuthorizationRedirectDelegate` returns the authorization code as a string; + the SDK invokes it only after discovery, DCR, and PKCE, with the fully built + authorization URI and the `ClientOAuthOptions.RedirectUri`. A null or empty + return throws `McpException` — loud, non-blocking, and safe for the + non-interactive startup variant, which maps that failure to `AwaitingAuth`. +- The SDK neither generates nor validates the OAuth `state` parameter. The + broker injects its opaque state via + `ClientOAuthOptions.AdditionalAuthorizationParameters["state"]` (the SDK's + URL builder reserves only `client_id`, `redirect_uri`, `response_type`, + `code_challenge`, `code_challenge_method`, `resource`, and `scope`), and + Netclaw owns state validation and CSRF protection end-to-end. +- The provider contains no synchronization, and the transport's POST and + GET/SSE paths can challenge concurrently through one provider instance. The + broker's delegate therefore coalesces concurrent invocations for one flow + onto the same pending authorization-URL and code tasks, so parallel + challenges cannot issue duplicate operator prompts. +- The SDK imposes no timeout on the delegate; the broker bounds its own wait + (the five-minute flow lifetime) and honors the SDK-passed cancellation token. + Cancellation between code delivery and exchange completion consumes the + one-time code with nothing persisted, so a cancelled or failed exchange is + classified as requiring fresh authorization — never an exchange retry. +- OAuth triggers lazily on the first 401/403 challenge, not eagerly at + connect, and configured scopes are a fallback: scopes advertised via + `WWW-Authenticate` or protected-resource metadata take precedence in 1.4.1. + +### D8. Failure modes, recovery, and diagnostics + +**Decision:** Persistence and diagnostics follow a strict fail-loud ordering. + +- `StoreTokensAsync` constructs the complete replacement record, persists it + transactionally, updates the applicable singleton in-memory view, then returns + success. Published-connection refresh updates the active record; explicit + authorization updates a flow-scoped pending record that is promoted only with + candidate publication. If persistence fails, neither view is advanced and the + failure propagates — the connection fails visibly rather than continuing on + an in-memory-only rotated token. +- A token response that omits `refresh_token` retains the prior refresh token. +- Authenticated OAuth API endpoints return a structured `McpErrorResponse` for + discovery, DCR rejection, credential persistence, and connection init. The + anonymous browser callback preserves its existing `text/html` response and + renders a safe actionable message for state-validation or code-exchange + failure. The daemon logs the full exception and server context; neither JSON + nor HTML exposes a token, code, verifier, or secret. The CLI parses + `McpErrorResponse`, falls back to HTTP status/reason on an empty or malformed + body, and never prints a blank error line. +- Connection status distinguishes `AwaitingAuth` (interaction required), + `AuthFailed` (credentials/refresh rejected), `Unreachable` (transport), and + `Connected` (published usable generation). An expired access token with no + refresh token says reauthorization is required rather than degrading to a + generic error. + +Recovery paths: a failed candidate leaves the prior generation serving; a +rejected refresh surfaces `AuthFailed` and directs the operator to +`netclaw mcp auth `; a disk failure fails the store call and the +connection, keeping the last active durable record authoritative; `StopAsync` +enters each server's gate, marks shutdown, rejects new leases and reconnects, +allows active invocations a bounded drain period, cancels any remainder with the +daemon shutdown token, then removes and disposes the snapshot so state +publication cannot race shutdown. + +**Rationale:** Persist-before-publish is the only ordering that keeps disk and +memory from disagreeing across a restart. The status taxonomy turns defect 8's +blank error into an operator instruction. + +## Risks / Trade-offs + +- **[Risk]** SDK OAuth behavior differs across providers. → **Mitigation:** + fake-server OAuth integration tests plus the existing provider reproduction + cases; report standards/SDK defects upstream instead of re-forking the + protocol inside Netclaw. +- **[Risk]** The SDK redirect-delegate interactive flow is unexercised in this + codebase — today `BuildOAuthOptions` sets the delegate to return `null`, + deliberately suppressing the SDK browser path. → **Mitigation:** + decompilation of the shipped 1.4.1 assembly has verified the static contract + (delegate receives the built authorization URI, returns the code, fails + loudly on null, store failures propagate, broker state injectable — see D7); + PR 2 still starts with a spike that proves the full flow at runtime + (discovery, DCR, PKCE, URL delivery, callback completion, exchange, + `ITokenCache` store) against a fake OAuth MCP server before any manual + OAuth code is deleted. +- **[Risk]** Explicit auth is now coupled to candidate-connection creation. → + **Mitigation:** land the D2 lifecycle boundary first, keep the candidate + unpublished, and preserve the old connection until success. +- **[Risk]** An existing valid token could suppress an intended + reauthorization. → **Mitigation:** explicit flows use the D6 cache view that + withholds the cached access token without deleting durable state. +- **[Risk]** The stable SDK's concurrent-refresh race persists until upstream + ships — decompilation confirms 1.4.1 has no synchronization anywhere in its + auth namespace, so concurrent requests through one provider can each redeem + the same refresh token. → **Mitigation:** track csharp-sdk#1595 / PR #1708 + (per-provider refresh single-flight); do not serialize every MCP call or + stand up a second refresh owner as a workaround. The broker coalesces + concurrent interactive-delegate invocations per flow (D7), and duplicate + interactive prompts cannot occur in normal operation because the + non-interactive delegate returns no code. The manager-level generation gate + stays necessary regardless, because any future SDK lock is scoped to one + `ClientOAuthProvider` instance and Netclaw still creates and replaces those. +- **[Risk]** DCR client identity is absent from stable `TokenContainer`. → + **Mitigation:** the D5 bridge carries the effective client ID through the + per-connection context and into `ClientOAuthOptions` until the released SDK + (csharp-sdk#1658 / PR #1705) makes it redundant. +- **[Risk]** Replaying an ambiguous transport failure duplicates a remote side + effect. → **Mitigation:** reconnect for later calls but never automatically + replay the failed invocation; cancellation and tool-declared/application + errors do not reconnect. +- **[Risk]** The secrets refactor touches non-MCP writers. → **Mitigation:** + keep the new primitive inside `SecretsFileWriter`, preserve encryption and + permissions, migrate read/modify/write callers mechanically, and add + concurrent cross-section preservation tests. + +## Migration Plan + +Two ordered local PRs now, one dependency PR later. They may ship in one Netclaw +release; the split exists for review isolation and rollback clarity. + +1. **PR 1 — single MCP client lifecycle.** `McpClientManager` becomes the sole + lifecycle owner: generation-aware reconnect coalescing, atomic snapshot + publication, in-flight-call draining, narrowed reconnect classification with + no automatic invocation replay, serialized teardown. No + OAuth protocol behavior changes. Update netclaw-dev/netclaw#1696 with the + local lifecycle completion; do not close it. +2. **PR 2 — SDK-owned OAuth, durable boundary, diagnostics.** Replace the manual + OAuth stack with the SDK redirect-delegate flow; add `McpOAuthFlowBroker` and + `McpOAuthCredentialStore`; delete discovery/DCR/PKCE/exchange/refresh and the + metadata-cache runtime dependency; add transactional `secrets.json` mutation + and migrate callers; bind credentials to the configured resource identity; + derive the callback port; ship structured daemon/CLI diagnostics. The + `netclaw-operations` system skill is updated in this PR + (MCP wiring and diagnostics change) with a `metadata.version` bump, and the + behavioral eval suite must pass. Fixes netclaw-dev/netclaw#1475 once + structured/bodyless error behavior is proven; mention the local-port fix in + #297 without closing it. +3. **Official SDK upgrade (separate, later).** Once an official release contains + csharp-sdk#1595 / PR #1708 and #1658 / PR #1705, bump the pinned package, run + the full OAuth/reconnect regression suite, remove only the compatibility code + the released API makes demonstrably redundant, and keep the manager-level + lifecycle gate. Do not consume a preview or vendor a patched SDK. + +Existing `mcp-oauth-metadata.json` files are ignored, not deleted — the runtime +simply stops reading them, and the durable token record (with its client ID) +becomes authoritative on the next flow. + +**Rollback:** each PR is independently revertable. Reverting PR 2 restores the +manual OAuth path and the previous diagnostics; reverting PR 1 restores the old +reconnect behavior. Neither revert deletes durable credentials. + +## Open Questions + +None outstanding. The decision record settles the ownership boundaries (no +actor, no refresh service, no bearer middleware), the SDK-versus-Netclaw +protocol split, and the temporary client-ID bridge; the delivery plan settles PR +sequencing and the upstream-dependency deferral. diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md b/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md new file mode 100644 index 000000000..eccd23484 --- /dev/null +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md @@ -0,0 +1,169 @@ +# Proposal: Simplify MCP OAuth Ownership and Client Lifecycle + +## Why + +Netclaw implements the full MCP OAuth protocol (discovery, dynamic client +registration, PKCE, code exchange, refresh) in parallel with the MCP C# SDK, +which performs the same protocol again at runtime. A reliability audit +(memorizer project `e687a20a-21cb-4c19-8ff7-695b198df907`) confirmed eight +independent defects around this duplication: unserialized concurrent reconnects +that can leak or destroy the wrong client generation, a disposable +`mcp-oauth-metadata.json` cache that silently disables OAuth when absent, token +persistence that reports success on disk failure (losing rotated refresh +tokens across restart), unserialized secrets.json read-modify-write, a +hard-coded callback port, and OAuth errors that reach the CLI blank +(netclaw-dev/netclaw#1475, #1696, #297). The remediation is fewer moving +parts: one owner per concern, not a more complete second OAuth implementation. + +## What Changes + +- **Client lifecycle**: `McpClientManager` becomes the sole runtime owner of + MCP client creation, publication, replacement, and disposal. Immutable + per-server connection snapshots with monotonic generations; concurrent + reconnects coalesce by observed generation; candidates initialize fully + before atomically replacing the sole published generation; replaced + generations drain in-flight calls before disposal; teardown is serialized. + Strengthens PRD-006 `MCP-009` under concurrency. +- **Failure classification**: reconnect narrows to classified + transport/session failures and restores service for later calls without + replaying an ambiguously failed invocation. Caller cancellation and + tool-declared/application errors propagate without teardown or reconnect. + Revises the reconnection behavior of PRD-006 `MCP-008` (graceful + degradation). +- **OAuth ownership**: OAuth discovery, DCR, PKCE, authorization-code + exchange, refresh, and bearer injection delegate to the MCP C# SDK + (ModelContextProtocol.Core 1.4.1 `ClientOAuthOptions`, `ITokenCache`, + `AuthorizationRedirectDelegate`). Netclaw's manual protocol implementation + in `McpOAuthService` and the `mcp-oauth-metadata.json` runtime dependency + are **removed**. Existing metadata files are ignored, not deleted. +- **New narrow components**: `McpOAuthFlowBroker` (interactive browser + handoff only: opaque one-time state, bounded flow lifetime via + `TimeProvider`) and `McpOAuthCredentialStore` (durable per-server token + sets plus the DCR-issued client ID; supplies SDK `ITokenCache` adapters). +- **Durable credentials**: in-memory token state publishes only after durable + persistence succeeds; persistence failure propagates through + `ITokenCache.StoreTokensAsync` and fails the connection visibly. A token + response that omits `refresh_token` retains the prior refresh token. Stored + credentials are bound to the configured MCP resource identity and withheld + after that identity changes. Explicit authorization writes a durable pending + record and promotes it only when the candidate connection publishes, so later + initialization failure cannot replace the last working record. +- **Transactional secrets**: `SecretsFileWriter` gains a path-scoped, + cross-process-locked read/decrypt/mutate/encrypt/replace transaction + (reusing the established `WebhookRouteStore` named-mutex pattern). All + secrets.json read-modify-write callers migrate; concurrent updates to + different sections cannot lose either update. +- **Callback URI**: derived from `DaemonConfig.Port` + (`http://127.0.0.1:{port}/api/mcp/oauth/callback`) instead of hard-coded + 5199. Never derived from a request Host header. +- **Diagnostics**: authenticated OAuth API endpoints return a structured + `McpErrorResponse`; the daemon logs full context while the client receives + a safe actionable message. The browser callback retains safe HTML responses. + The CLI falls back to HTTP status when the body is empty and never prints a + blank error line. Connection status distinguishes `AwaitingAuth`, + `AuthFailed`, `Unreachable`, and `Connected`. + +No breaking changes to the external CLI/API surface: `netclaw mcp auth`, +the OAuth start/status/callback endpoints and their response media types, +static header authentication, and unauthenticated MCP servers keep their +existing behavior. + +## Capabilities + +### New Capabilities + +- `mcp-oauth`: SDK-delegated OAuth authorization for HTTP MCP servers — + ownership boundaries, interactive browser-flow brokering, durable + credential and client-registration persistence, callback identity, and + actionable OAuth failure diagnostics. +- `transactional-secrets`: serialized, atomic mutation of secrets.json — + cross-process locking, preservation of unrelated secret sections under + concurrent writers, and loud persistence failure. + +### Modified Capabilities + +- `netclaw-mcp`: + - "Configured MCP server has daemon-bound client ownership" — strengthened + to hold under concurrent reconnects via generation-aware coalescing, + atomic publication, and in-flight-call draining. + - "Graceful degradation" — reconnection narrowed to classified + transport/session failures without automatic invocation replay; + cancellation and tool-declared errors excluded. + - "MCP diagnostics visibility" — connection-status taxonomy + (`AwaitingAuth`/`AuthFailed`/`Unreachable`/`Connected`) and structured + error responses replace generic failure reporting. + +## Impact + +- **Code**: `src/Netclaw.Daemon/Mcp/` (`McpClientManager`, `McpOAuthService` + — largely deleted, `McpTokenCacheAdapter`, `McpEndpointRouteBuilderExtensions`, + `McpReconnectionService`), `src/Netclaw.Configuration/SecretsFileWriter.cs`, + `src/Netclaw.Configuration/NetclawPaths.cs` (metadata path removal), + `src/Netclaw.Cli` MCP auth/status commands. Expected net-negative + production LOC. +- **Dependencies**: ModelContextProtocol.Core stays pinned at 1.4.1. A later + official SDK release containing upstream fixes csharp-sdk#1595 (refresh + single-flight) and csharp-sdk#1658 (cold-start client identity) is tracked + separately and is out of scope here; the manager-level generation gate + remains necessary regardless because SDK locks are provider-instance-scoped. +- **Issues**: fixes netclaw-dev/netclaw#1475 (blank OAuth errors); advances + but does not close #1696 (upstream SDK fixes pending) and #297 (remote + callback identity remains a separate product decision). +- **Docs/skills**: `netclaw-operations` system skill updated (MCP wiring and + diagnostics change) with `metadata.version` bump; behavioral eval suite + must pass. + +### Security and operational impact + +- Removes a duplicate OAuth protocol implementation that could drift from the + SDK in standards behavior and error handling — smaller attack and defect + surface. +- Fail-loud posture: credential persistence failures now fail the connection + instead of silently continuing with in-memory-only rotated tokens. +- Secrets handling keeps existing encryption and file-permission hardening; + the new transaction only adds serialization around the existing atomic + replace. +- One-time opaque state values with bounded (five-minute) flow lifetime for + interactive authorization; closing the browser tab cannot cancel a + completed exchange; expired/mismatched state fails visibly. At most one + interactive flow is active per server, and its lifetime is owned by the + daemon rather than an HTTP request. +- Persisted OAuth credentials are bound to the configured resource identity; + changing a profile endpoint requires authorization for the new identity. +- Legacy credential records without that binding fail closed with actionable + reauthorization guidance rather than being silently trusted for the current + profile. This is an operator-visible security migration, not an API shape + change. +- Operators see actionable status (`AwaitingAuth` → run + `netclaw mcp auth `) instead of generic connection errors. + +### In scope (MVP) + +- Generation-aware client lifecycle, coalesced reconnects, and safe draining + of replaced generations. +- SDK-delegated OAuth with browser-flow broker and credential store. +- Transactional secrets mutation and caller migration. +- Configurable local callback port; structured diagnostics. +- Compatibility bridge: persisting the DCR client ID beside the token set and + seeding `ClientOAuthOptions.ClientId` from it (stable SDK 1.4.1's + `TokenContainer` carries no registration fields). +- Durable pending credentials for explicit authorization, promoted only with + successful candidate publication. + +### Out of scope + +- OAuth refresh actor or proactive token-refresh timer. +- Custom bearer handler or 401 replay middleware. +- Vendored or preview SDK; the official SDK upgrade is a separate follow-up. +- Remote/public callback URL design (#297). +- Serializing all MCP tool calls. +- Attributing past provider incidents to these defects without traces. + +## Source PRDs + +- `docs/prd/PRD-006-mcp-tool-integration.md` — `MCP-002` (connection + validation), `MCP-008` (graceful degradation), `MCP-009` (daemon-bound + server ownership), and `MCP-010` (secure OAuth lifecycle). +- Evidence and design: memorizer project + `e687a20a-21cb-4c19-8ff7-695b198df907` (audit, decision record, three + detailed designs, delivery plan). diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md new file mode 100644 index 000000000..0da0c2271 --- /dev/null +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md @@ -0,0 +1,270 @@ +# mcp-oauth Specification + +## ADDED Requirements + +### Requirement: OAuth protocol ownership belongs to the MCP SDK + +The system SHALL delegate MCP OAuth protocol operations — protected-resource +and authorization-server discovery, authorization-server selection, dynamic +client registration, PKCE generation and validation, authorization-code +exchange, refresh-token redemption, and bearer-token injection — to the MCP +C# SDK. Netclaw SHALL NOT implement these protocol operations and SHALL NOT +maintain a separate OAuth metadata cache as a runtime dependency. + +#### Scenario: Startup with bound cached tokens requires no metadata cache + +- **GIVEN** secrets.json contains a valid token set bound to the configured HTTP MCP resource identity +- **AND** no `mcp-oauth-metadata.json` file exists +- **WHEN** the daemon connects to that server +- **THEN** the SDK-provided OAuth support is installed on the connection +- **AND** the cached tokens are used or refreshed by the SDK +- **AND** the connection reaches `Connected` without interactive authorization + +#### Scenario: Netclaw performs no direct token-endpoint calls + +- **GIVEN** a configured HTTP MCP server with OAuth in use +- **WHEN** tokens are acquired or refreshed during any flow +- **THEN** every token-endpoint request originates from the MCP SDK +- **AND** no Netclaw-owned code constructs discovery, registration, authorization, or token requests + +#### Scenario: Legacy metadata files are ignored, not deleted + +- **GIVEN** a pre-existing `mcp-oauth-metadata.json` file from an earlier version +- **WHEN** the daemon starts +- **THEN** the file is not read on any runtime code path +- **AND** the file is not automatically deleted + +### Requirement: Interactive authorization is brokered, not implemented + +Explicit authorization (`netclaw mcp auth `) SHALL retain its existing +CLI and HTTP surface. The daemon SHALL broker the SDK-generated authorization +URL to the operator and complete the SDK's redirect delegate from the local +callback endpoint. Flow state values SHALL be cryptographically opaque, +one-time, bound to a single server and flow, and SHALL expire after a bounded +lifetime of five minutes measured via `TimeProvider`. Netclaw SHALL validate +the callback state itself — the SDK neither generates nor validates the OAuth +state parameter. Concurrent redirect-delegate invocations for one flow SHALL +coalesce onto the same pending authorization URL and code. At most one +interactive flow SHALL be active per server; concurrent start requests SHALL +coalesce onto that flow. The daemon SHALL own the flow and candidate lifetime +independently of the HTTP start and callback request cancellation tokens, and +SHALL cancel them on expiry or daemon shutdown. The lifetime SHALL match the +existing five-minute CLI and TUI polling timeout. + +#### Scenario: Explicit authorization end to end + +- **GIVEN** a configured HTTP MCP server requiring OAuth +- **WHEN** the operator starts explicit authorization +- **THEN** the daemon creates a pending flow and an unpublished candidate connection +- **AND** the SDK performs discovery, registration if needed, and PKCE, and supplies the authorization URL through its redirect delegate +- **AND** the CLI receives that URL through the existing start response and polls the existing status endpoint +- **AND** the callback completes the flow, the SDK exchanges the code, and the candidate is published only after initialization (including tool listing) succeeds +- **AND** polling reports `Completed` only after that publication succeeds + +#### Scenario: Concurrent starts share one flow + +- **GIVEN** an interactive authorization flow is active for a server +- **WHEN** another start request arrives for the same server +- **THEN** both callers receive the same state and authorization URL +- **AND** only one candidate connection and one credential update can result + +#### Scenario: Invalid, reused, or expired state fails visibly + +- **WHEN** the callback receives a state value that is missing, mismatched, already completed, or older than the flow lifetime +- **THEN** the callback fails with a safe `text/html` error response +- **AND** no token exchange is attempted for that request +- **AND** any pending flow for a different state value is unaffected + +#### Scenario: Failed authorization preserves prior state + +- **GIVEN** a server with an existing published connection and stored credentials +- **WHEN** an explicit authorization attempt fails or is cancelled +- **THEN** only the candidate connection is disposed +- **AND** the previously published connection remains live +- **AND** the previously stored credentials remain intact + +#### Scenario: Existing valid token does not suppress explicit reauthorization + +- **GIVEN** a server with a currently valid stored access token +- **WHEN** the operator starts explicit authorization +- **THEN** the SDK authorization path is entered without first deleting the stored credentials + +#### Scenario: Closing the browser tab does not cancel the exchange + +- **GIVEN** a pending flow whose callback request has delivered a valid code +- **WHEN** the browser connection closes before token exchange completes +- **THEN** the token exchange and candidate publication continue to completion + +#### Scenario: Start request cancellation does not own the candidate + +- **GIVEN** the start endpoint has returned an authorization URL +- **WHEN** its HTTP request cancellation token is cancelled +- **THEN** the daemon-owned flow and candidate remain active until completion, expiry, explicit cancellation, or daemon shutdown + +#### Scenario: Concurrent challenges reuse one pending flow + +- **GIVEN** a pending interactive flow for a server +- **WHEN** the SDK invokes the redirect delegate concurrently from parallel transport requests +- **THEN** both invocations observe the same pending flow and authorization URL +- **AND** the operator is prompted at most once + +#### Scenario: A cancelled exchange requires fresh authorization + +- **GIVEN** a delivered authorization code whose token exchange is cancelled or fails +- **WHEN** the flow terminates +- **THEN** the flow is marked failed and the one-time code is not reused +- **AND** a subsequent attempt starts a new authorization rather than retrying the exchange + +### Requirement: Durable credential persistence precedes publication + +The system SHALL treat secrets.json as the durable authority for MCP OAuth +credentials. Token state SHALL be published to memory only after durable +persistence succeeds, and a persistence failure SHALL propagate through the +SDK token-cache boundary and fail the connection visibly. Credentials acquired +by an unpublished interactive candidate SHALL first persist as a pending record +bound to that flow and SHALL become the active record only when candidate tool +listing and publication succeed. Failed or expired candidates SHALL remove +their pending record without changing the active record. When a token +response omits a refresh token, the system SHALL retain the previous refresh +token. The effective client ID — and client secret, when issued — from +dynamic client registration SHALL be stored with the token record and +supplied to the SDK's OAuth options on subsequent connections. The record +SHALL also contain the normalized configured MCP resource identity used when +the credentials were obtained. Before returning cached credentials to the SDK, +Netclaw SHALL compare that binding with the current configured resource and +SHALL withhold the credentials when they differ. The canonical identity SHALL +be the absolute configured endpoint URI after `System.Uri` normalization, with +scheme and host normalized, default port normalized, fragment removed, and path +and query retained. + +#### Scenario: Persistence failure is loud + +- **GIVEN** durable persistence of a rotated token set fails +- **WHEN** the SDK stores tokens through the token-cache boundary +- **THEN** the store operation reports failure to the SDK +- **AND** the in-memory token view is not advanced +- **AND** the connection attempt fails with an actionable error + +#### Scenario: Failed candidate does not replace active credentials + +- **GIVEN** a server has active durable credentials and a published connection +- **AND** an explicit authorization candidate durably stores replacement credentials as pending +- **WHEN** candidate initialization or tool listing fails before publication +- **THEN** the pending credentials are discarded +- **AND** the prior active credential record remains authoritative and unchanged + +#### Scenario: Omitted refresh token is retained + +- **GIVEN** a stored token set containing a refresh token +- **WHEN** a token response returns a new access token without a refresh token +- **THEN** the persisted record keeps the previous refresh token + +#### Scenario: Registered client identity survives restart + +- **GIVEN** a token set persisted with a client ID (and client secret, when issued) from dynamic client registration +- **WHEN** the daemon restarts and reconnects to that server +- **THEN** the SDK OAuth options are seeded with the persisted client ID and any persisted client secret +- **AND** no re-registration occurs while the stored registration remains valid + +#### Scenario: Repointed profile cannot reuse old credentials + +- **GIVEN** stored credentials are bound to one configured MCP resource identity +- **WHEN** the same server profile name is changed to a different resource identity +- **THEN** the old token set, dynamically registered client ID, and client secret are not supplied to the SDK connection for the new resource +- **AND** the server reports `AwaitingAuth` for the new identity +- **AND** the old durable record remains intact until replacement credentials are stored successfully + +#### Scenario: Legacy unbound credentials fail closed + +- **GIVEN** a stored credential record has no canonical configured resource binding +- **WHEN** the daemon loads the server after upgrade +- **THEN** no token, dynamically registered client ID, or client secret from that record is supplied to the SDK +- **AND** the server reports `AwaitingAuth` with `netclaw mcp auth ` as the remedy +- **AND** Netclaw does not silently bind the legacy record to the current profile + +#### Scenario: Revoked dynamic registration can be replaced explicitly + +- **GIVEN** credentials contain a dynamically registered client identity that the authorization server rejects as `invalid_client` +- **WHEN** the operator runs explicit authorization +- **THEN** Netclaw retries the candidate flow once with the stored dynamic client identity withheld so the SDK can perform new dynamic registration +- **AND** the prior active credentials remain unchanged until the replacement candidate is published +- **BUT** an explicitly configured static client ID is never discarded or replaced automatically + +### Requirement: Callback identity derives from daemon configuration + +The OAuth redirect URI SHALL be +`http://127.0.0.1:{DaemonConfig.Port}/api/mcp/oauth/callback`, derived from +the configured daemon port. The system SHALL NOT hard-code the port and SHALL +NOT derive the callback host from an incoming request's Host header. + +#### Scenario: Non-default port is used consistently + +- **GIVEN** the daemon is configured with a non-default port +- **WHEN** dynamic client registration and authorization occur +- **THEN** the registered and requested redirect URIs both use the configured port +- **AND** the local callback succeeds without manual URL correction + +### Requirement: Startup and reconnect never block on interactive authorization + +Every configured HTTP MCP transport SHALL be created with SDK OAuth support +whose non-interactive redirect delegate returns no authorization code. OAuth +support SHALL remain dormant for servers that are unauthenticated or +satisfied by operator-configured headers, and operator-provided headers SHALL +remain authoritative. When interactive authorization is required, the +connection SHALL report `AwaitingAuth` and direct the operator to +`netclaw mcp auth ` instead of opening a browser or waiting for input. + +#### Scenario: Static header authentication is unchanged + +- **GIVEN** a server authenticated by operator-configured headers +- **WHEN** the daemon connects +- **THEN** the configured headers are sent unmodified +- **AND** no OAuth challenge handling alters the request +- **AND** the connection succeeds as before + +#### Scenario: Unauthenticated server is unchanged + +- **GIVEN** a configured HTTP MCP server that requires no authentication +- **WHEN** the daemon connects +- **THEN** the connection succeeds without any OAuth activity + +#### Scenario: OAuth-required server awaits the operator + +- **GIVEN** a server that answers with an OAuth challenge and no usable stored credentials +- **WHEN** the daemon starts or reconnects +- **THEN** no browser is opened and startup does not block +- **AND** the server's status is `AwaitingAuth` +- **AND** the operator-facing alert names `netclaw mcp auth ` as the remediation + +### Requirement: OAuth failures produce actionable diagnostics + +Authenticated OAuth API endpoints SHALL return a structured error response for +discovery, registration rejection, credential persistence, and connection +initialization failures. The anonymous browser callback SHALL retain its +existing `text/html` response and SHALL render a safe actionable error for +callback validation or code-exchange failures. The daemon SHALL log the full +exception with provider and server context. No client-facing JSON or HTML SHALL +contain tokens, authorization codes, PKCE verifiers, or secret values. The CLI +SHALL parse structured responses when present, SHALL fall back to the HTTP +status and reason when the body is empty or malformed, and SHALL NOT print a +blank error. + +#### Scenario: Registration rejection surfaces a reason + +- **GIVEN** a provider that advertises dynamic client registration but rejects it with HTTP 403 and no body +- **WHEN** the operator runs explicit authorization +- **THEN** the CLI displays the failing operation and the HTTP status +- **AND** the daemon log contains the full response context + +#### Scenario: Bodyless daemon error still yields CLI output + +- **GIVEN** the daemon returns an error status with an empty or malformed body +- **WHEN** the CLI reports the failure +- **THEN** the CLI prints the HTTP status and reason phrase rather than a blank error line + +#### Scenario: Callback validation failure remains browser-safe HTML + +- **GIVEN** the anonymous callback receives invalid or expired state +- **WHEN** it reports the failure to the browser +- **THEN** the response content type is `text/html` +- **AND** the rendered message contains no code, token, verifier, or secret value diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/specs/netclaw-mcp/spec.md b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/netclaw-mcp/spec.md new file mode 100644 index 000000000..6d0b8f540 --- /dev/null +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/netclaw-mcp/spec.md @@ -0,0 +1,177 @@ +# netclaw-mcp Delta Specification + +## MODIFIED Requirements + +### Requirement: Configured MCP server has daemon-bound client ownership + +The system SHALL maintain at most one published MCP client generation for each +enabled configured MCP server within a daemon process, including under +concurrent reconnect attempts. `McpClientManager` SHALL be the sole runtime +owner of client creation, publication, replacement, and disposal. Each +published connection SHALL be an immutable snapshot (client, tool map, and +status metadata) carrying a monotonically increasing generation. Concurrent +reconnect requests that observed the same generation SHALL coalesce to a +single replacement attempt. A replacement candidate SHALL initialize +completely — including tool listing — before it atomically replaces the +published generation, and the replaced generation SHALL be disposed exactly +once only after its in-flight invocations finish. An unpublished candidate MAY +coexist with the published generation during initialization. For a local STDIO +server, the connection SHALL own the server child process and SHALL be shared by +every Netclaw session authorized to invoke the server. + +#### Scenario: Different sessions invoke one local STDIO server + +- **GIVEN** a local STDIO MCP server is enabled and available to two authorized sessions +- **WHEN** both sessions invoke tools from that server +- **THEN** both invocations use the same configured MCP client connection +- **AND** Netclaw does not launch a child process for either session identity + +#### Scenario: Session identity does not partition MCP state + +- **GIVEN** an authorized session changes state held by an MCP server +- **WHEN** another authorized session invokes that server +- **THEN** the second invocation uses the same daemon-scoped server state + +#### Scenario: Daemon shutdown owns local child cleanup + +- **GIVEN** an enabled local STDIO MCP server is connected +- **WHEN** the Netclaw daemon stops +- **THEN** Netclaw disposes the configured MCP client +- **AND** the client transport terminates its owned child process + +#### Scenario: Concurrent reconnect requests coalesce + +- **GIVEN** multiple callers concurrently request reconnection after observing the same connection generation +- **WHEN** the reconnect attempts run +- **THEN** exactly one replacement candidate is created +- **AND** every caller observes or reuses the same winning generation +- **AND** no client instance is leaked or disposed more than once + +#### Scenario: Failed replacement retains the prior generation + +- **GIVEN** a published healthy connection +- **WHEN** a replacement candidate fails to initialize +- **THEN** only the candidate is disposed +- **AND** the previously published connection and its tools remain available +- **AND** the server's status does not advertise an empty tool set + +#### Scenario: Replacement drains the prior generation + +- **GIVEN** an invocation is using the published generation +- **WHEN** a replacement generation is initialized and published +- **THEN** the invocation may finish against the prior generation +- **AND** the prior generation is disposed exactly once after its final in-flight invocation finishes + +#### Scenario: Shutdown racing reconnect leaks nothing + +- **GIVEN** a reconnect attempt is in progress +- **WHEN** daemon shutdown begins +- **THEN** no new connection is published after shutdown starts +- **AND** every created client is disposed + +#### Scenario: Shutdown bounds active invocation drain + +- **GIVEN** an invocation holds a lease on a published generation +- **WHEN** daemon shutdown begins +- **THEN** new leases and reconnects are rejected +- **AND** shutdown allows a bounded drain period before cancelling remaining invocations +- **AND** the generation is disposed after the invocation exits + +### Requirement: Graceful degradation + +Tool calls to unavailable MCP servers SHALL return a clear error message to +the agent. The agent SHALL continue operating with remaining available tools. +The system SHALL attempt reconnection on the next tool call to a previously +unavailable server. Reconnection SHALL be triggered only by +classified transport or session failures: caller cancellation SHALL propagate +immediately without teardown or retry, and tool-declared or application +errors SHALL be returned without reconnecting. A classified transport failure +SHALL trigger at most one coalesced reconnection for later calls. The failed +tool invocation SHALL NOT be replayed automatically because the remote side +effect may have completed before the failure became visible. + +#### Scenario: Unavailable server returns clear error + +- **GIVEN** a configured MCP server is unreachable +- **WHEN** the agent invokes a tool from that server +- **THEN** a clear error is returned indicating the server is unavailable +- **AND** the agent continues the conversation with remaining tools + +#### Scenario: Reconnection on next call + +- **GIVEN** an MCP server was previously unreachable +- **WHEN** the agent invokes a tool from that server again +- **THEN** the system attempts reconnection before returning an error + +#### Scenario: Partial server availability + +- **GIVEN** two MCP servers are configured and one is unreachable +- **WHEN** a session initializes +- **THEN** tools from the reachable server are available +- **AND** tools from the unreachable server are marked as unavailable + +#### Scenario: Caller cancellation does not tear down a healthy client + +- **GIVEN** a tool invocation in flight on a healthy shared connection +- **WHEN** the caller's cancellation token fires +- **THEN** the cancellation propagates to the caller immediately +- **AND** the shared connection is not disposed, reconnected, or retried + +#### Scenario: Tool-declared errors are results, not failures + +- **GIVEN** an MCP tool returns a tool-declared error +- **WHEN** the invocation completes +- **THEN** the error is formatted as a tool result +- **AND** no reconnection or retry occurs + +#### Scenario: Transport failure reconnects without replay + +- **GIVEN** a tool invocation fails with a classified transport failure +- **WHEN** the system handles the failure +- **THEN** the failed invocation is returned as an error without automatic replay +- **AND** the system performs at most one coalesced reconnection for later calls + +### Requirement: MCP diagnostics visibility + +The system SHALL expose MCP server health in diagnostics. Connection status +SHALL distinguish `AwaitingAuth` (no usable authorization and interaction is +required), `AuthFailed` (credentials or refresh were rejected), `Unreachable` +(transport or network failure), and `Connected` (published usable +generation). Connection state, tool count, and error information SHALL be +updated together from the same lifecycle operation. Failure status SHALL carry +the last error timestamp from `TimeProvider`; successful recovery SHALL update +state and tool count without fabricating a new failure timestamp. + +#### Scenario: Server becomes unavailable + +- **WHEN** a configured MCP server is unreachable +- **THEN** diagnostics mark it degraded or unavailable with last error timestamp + +#### Scenario: Recovery preserves truthful failure timing + +- **GIVEN** a server status contains a last error timestamp +- **WHEN** a later generation connects successfully +- **THEN** diagnostics report `Connected` with the new tool count +- **AND** any retained last-failure timestamp still identifies the actual failure time rather than the recovery time + +#### Scenario: Daemon reports MCP auth failure + +- **GIVEN** the daemon can reach the MCP server but authentication is rejected on the live runtime path +- **WHEN** the operator runs `netclaw mcp list` or `netclaw doctor` +- **THEN** the CLI reports `auth failed` +- **AND** remediation points to `netclaw mcp auth ` when OAuth is in use + +#### Scenario: Doctor cannot verify OAuth auth offline + +- **GIVEN** an HTTP/SSE MCP server uses OAuth +- **AND** the daemon is unavailable +- **WHEN** the operator runs `netclaw doctor` +- **THEN** doctor may report offline connectivity evidence +- **BUT** it SHALL not claim the server is unauthorized unless the daemon runtime path has verified that auth failure + +#### Scenario: Expired token without refresh token names the remedy + +- **GIVEN** a server whose stored access token is expired and whose record holds no refresh token +- **WHEN** the daemon attempts to connect +- **THEN** the status is `AwaitingAuth` rather than a generic connection error +- **AND** diagnostics state that reauthorization is required via `netclaw mcp auth ` diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/specs/transactional-secrets/spec.md b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/transactional-secrets/spec.md new file mode 100644 index 000000000..88864bacd --- /dev/null +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/transactional-secrets/spec.md @@ -0,0 +1,60 @@ +# transactional-secrets Specification + +## ADDED Requirements + +### Requirement: Secrets mutation is a serialized transaction + +The system SHALL provide a path-scoped transactional update operation for +secrets.json that derives its lock identity from the canonical path, then holds +one cross-process lock across reading the latest file, decryption of encrypted +leaves, JSON parsing, the caller's mutation, serialization, encryption, and +atomic file replacement with permission hardening. Every read-modify-write caller of +secrets.json SHALL express its owned field mutations against the latest +document read inside this transaction; acquiring the lock and then writing a +whole-file snapshot captured before the lock SHALL NOT satisfy this +requirement. Intentional whole-file replacement MAY retain the direct write +path but SHALL participate in the same path lock. +Existing encryption behavior and file-permission hardening SHALL be +preserved. + +#### Scenario: Concurrent updates to different sections both survive + +- **GIVEN** two writers concurrently update different secret sections of the same secrets file +- **WHEN** both transactions complete +- **THEN** the resulting file contains both updates +- **AND** every unrelated secret section is preserved + +#### Scenario: MCP token refresh races an operator secret update + +- **GIVEN** an MCP OAuth token refresh persists rotated credentials +- **AND** a CLI or config operation concurrently updates another secret section +- **WHEN** both operations complete +- **THEN** neither update is lost +- **AND** the file remains well-formed, encrypted per existing policy, and permission-hardened + +#### Scenario: Cross-process writers serialize + +- **GIVEN** two processes attempt transactional updates against the same secrets path +- **WHEN** their transactions overlap +- **THEN** the second transaction observes the first transaction's committed state before applying its mutation + +#### Scenario: Long-lived editor replays owned changes against current secrets + +- **GIVEN** a config editor loaded secrets before an MCP token refresh committed new credentials +- **WHEN** the editor later saves changes to a different secret section +- **THEN** the editor applies only its owned field changes to the latest locked document +- **AND** the refreshed MCP credentials remain unchanged + +### Requirement: Secrets persistence failure is loud + +A failed secrets transaction SHALL propagate an error to the caller. The +system SHALL NOT report success, advance in-memory state, or continue +silently when serialization, encryption, or file replacement fails. + +#### Scenario: Disk failure surfaces to the caller + +- **GIVEN** the underlying file replacement fails (for example, permissions or a full disk) +- **WHEN** a caller runs a transactional secrets update +- **THEN** the transaction throws or returns a failure to the caller +- **AND** the previous file content remains intact +- **AND** no caller-visible state reports the update as persisted diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md b/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md new file mode 100644 index 000000000..1b52fee30 --- /dev/null +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md @@ -0,0 +1,113 @@ +# Tasks: Simplify MCP OAuth Ownership and Client Lifecycle + +Groups 1-5 deliver PR 1 (single MCP client lifecycle). Groups 6-14 deliver PR 2 +(SDK-owned OAuth, durable boundary, diagnostics). The two PRs may ship in one +release; the split exists for review isolation and rollback clarity. Tests use +synchronization signals (TaskCompletionSource, Ask-acks, `AwaitAssertAsync`) — +never `Thread.Sleep` or `Task.Delay` in orchestration. + +## 1. PR 1 — Connection snapshot and generation model + +- [ ] 1.1 Define an immutable per-server connection snapshot type carrying the `McpClient`, its function/tool map, a monotonically increasing generation number, status metadata (including the `TimeProvider`-derived last error timestamp), and invocation lease state. Done when the published data is read-only and one lease owner can retire and drain the generation safely. +- [ ] 1.2 Replace `McpClientManager`'s separate client and tool dictionaries with one published snapshot reference per server. Done when no code path reads the client and its tools as independent dictionaries. +- [ ] 1.3 Update `ToolRegistry` and status reporting to read from the same snapshot so connection state, tool count, and error info always change together. Done when a single lifecycle operation is the only writer of all three. + +## 2. PR 1 — Generation-aware lifecycle gate + +- [ ] 2.1 Add a per-server async lifecycle gate guarding create, publish, replace, and teardown. Done when every lifecycle transition for a server passes through one gate. +- [ ] 2.2 Implement coalescing reconnect: capture the observed generation, enter the gate, re-read the published snapshot, and if a newer healthy generation already exists, reuse it and return. Done when concurrent reconnects that observed the same generation converge on one winner. +- [ ] 2.3 Build the candidate without removing the published connection; initialize it fully (`ListToolsAsync` plus function-map construction) before publication; on init failure dispose only the candidate and keep the published connection. Done when a failed candidate leaves the prior generation serving. +- [ ] 2.4 Publish the candidate atomically as the next generation, retire the replaced generation so it accepts no new invocation leases, and dispose it exactly once after its final in-flight lease ends. Done when no routine replacement interrupts an in-flight invocation and no generation is leaked or double-disposed. +- [ ] 2.5 Serialize shutdown: `StopAsync` enters each server's gate, marks shutdown, rejects new leases and reconnects, allows a bounded invocation drain, cancels any remainder with the daemon shutdown token, then removes and disposes the snapshot. Done when no connection can be published after shutdown starts and no client is disposed while its invocation is still executing. + +## 3. PR 1 — Invocation classification + +- [ ] 3.1 Format tool-declared and application MCP errors as tool results with no teardown or reconnect. Done when a tool-declared error returns a result and leaves the connection published. +- [ ] 3.2 Propagate caller-token `OperationCanceledException` and unknown application exceptions with no teardown or retry. Done when cancellation reaches the caller and the shared connection stays live. +- [ ] 3.3 Classify transport/session failures, return the failed invocation as an error without replay, release its snapshot lease, and only then request or await at most one coalesced reconnect for later calls. Done when an ambiguous failure never invokes the remote tool a second time and reconnect cannot wait on the caller's own lease. + +## 4. PR 1 — Lifecycle and concurrency tests + +- [ ] 4.1 Concurrent-reconnect coalescing test: many callers observing the same generation produce exactly one candidate and one winning generation, with no client leaked or double-disposed. Drive it with `TaskCompletionSource`/Ask-acks/`AwaitAssertAsync`. +- [ ] 4.2 Failed-candidate test: a candidate that fails to initialize disposes only itself; the prior generation and its tools stay available and status never advertises an empty tool set. +- [ ] 4.3 Disposal-accounting test: instrument the fake client's dispose count to prove no client is leaked or disposed more than once across reconnects. +- [ ] 4.4 Non-teardown test: caller cancellation and tool-declared/application errors neither reconnect nor dispose the healthy shared connection. +- [ ] 4.5 Shutdown-vs-reconnect race test: with a reconnect in flight, shutdown begins; assert no connection is published after shutdown starts and every created client is disposed. +- [ ] 4.6 Invocation-vs-replacement race test: hold a tool call on the prior generation while publishing a replacement; assert the call completes, the prior generation is not disposed early, and it is disposed exactly once after release. +- [ ] 4.7 Self-reconnect drain test: a lease-holding invocation fails with a classified transport error and triggers reconnect; assert it releases its lease before awaiting replacement, returns without deadlock, and is not replayed. +- [ ] 4.8 Shutdown-with-active-invocation test: hold a lease through shutdown, assert new leases are rejected, bounded drain occurs, cancellation releases the invocation, and disposal happens only after release. + +## 5. PR 1 — Issue tracking + +- [ ] 5.1 Update netclaw-dev/netclaw#1696 with the local lifecycle completion. Do not close it — the upstream SDK fixes are still pending. + +## 6. PR 2 — Spike: prove the SDK redirect-delegate flow + +- [ ] 6.1 Stand up a fake OAuth MCP server harness exposing discovery metadata, a DCR endpoint, an authorization endpoint, and a token endpoint, usable from integration tests. Done when a test can drive each endpoint. +- [ ] 6.2 Prove the SDK redirect-delegate flow end-to-end against the fake server — discovery, DCR, PKCE, authorization-URL delivery via `AuthorizationRedirectDelegate`, callback completion, code exchange, and `ITokenCache` store — BEFORE any manual OAuth code is deleted. Done when a green integration test drives the full SDK path and de-risks the previously unexercised redirect delegate. +- [ ] 6.3 Verify in the spike that broker-owned state injected via `AdditionalAuthorizationParameters["state"]` round-trips through the authorization URL, and that concurrent challenges from parallel transport requests coalesce onto one pending flow with a single operator prompt. + +## 7. PR 2 — Transactional secrets + +- [ ] 7.1 Add a path-scoped, cross-process-locked read/decrypt/mutate/encrypt/replace transaction to `SecretsFileWriter`, reusing the `WebhookRouteStore` named-mutex / SHA-256 path-hash pattern. Preserve existing encryption and file-permission hardening; whole-file `Write` participates in the same path lock. Done when canonical path resolution derives the lock identity and that lock spans the latest read through atomic replacement. +- [ ] 7.2 Migrate every secrets.json read-modify-write caller onto the transaction using owned field mutations applied to the latest document under the lock. Long-lived config editors and wizards replay contribution actions instead of writing snapshots captured before the lock. Done when no caller does an unlocked or stale-snapshot read-modify-write against secrets.json. +- [ ] 7.3 Concurrent cross-section preservation tests: two writers updating different sections both survive, unrelated sections are preserved, a second cross-process writer observes the first's committed state before mutating, and a config editor opened before an MCP refresh cannot overwrite the refreshed token when saving another section. +- [ ] 7.4 Loud-failure tests: a replacement/disk failure propagates to the caller, the prior file content stays intact, and no caller-visible state reports the update as persisted. + +## 8. PR 2 — Credential store and persist-before-publish + +- [ ] 8.1 Add the `McpOAuthCredentialStore` singleton backing every per-connection `ITokenCache` adapter, so per-adapter in-memory dictionaries cannot diverge. Done when all adapters read one shared view. +- [ ] 8.2 Implement persist-before-publish `StoreTokensAsync`: published-connection refresh writes the active record, while explicit authorization writes a flow-scoped durable pending record. Persist transactionally before advancing the matching in-memory view; on failure advance neither and propagate. Done when failed storage never advances memory and a failed candidate cannot replace active credentials. +- [ ] 8.3 Retain the prior refresh token when a token response omits `refresh_token`. Done when the persisted record keeps the previous refresh token. +- [ ] 8.4 Persist the DCR-issued effective client ID — and client secret, when issued — with the token record, update both from `DynamicClientRegistration.ResponseDelegate`, and seed `ClientOAuthOptions.ClientId`/`ClientSecret` from them on restart. Done when the stored client identity feeds the SDK options on reconnect. +- [ ] 8.5 Restart-without-metadata-file test: a registered client identity survives restart with no `mcp-oauth-metadata.json` present, and no re-registration occurs while the stored registration is valid. +- [ ] 8.6 Persist the canonical configured MCP resource identity with each credential record and compare it before returning cached tokens or seeding SDK client options. Done when repointing the same profile name withholds the old access token, refresh token, dynamically registered client ID, and client secret, reports `AwaitingAuth`, and preserves the old durable record until replacement succeeds; an explicitly configured static client ID remains authoritative. +- [ ] 8.7 Resource-binding canonicalization tests cover scheme/host case, default ports, fragments, path, and query so equivalent endpoint spellings compare consistently while a changed resource fails closed. +- [ ] 8.8 Legacy-binding migration test: a record without the new canonical binding supplies no token or dynamic client credentials, reports `AwaitingAuth`, preserves the old record, and never silently stamps the current profile identity onto it. +- [ ] 8.9 Pending-record lifecycle tests: explicit auth stores pending credentials durably, successful candidate publication promotes them atomically, failed/expired candidates remove only pending state, and restart ignores and prunes abandoned pending records without changing active credentials. +- [ ] 8.10 Invalid dynamic registration recovery test: explicit auth receiving `invalid_client` retries once with the stored dynamic client identity withheld so SDK DCR can run; static configured client IDs never take this fallback. + +## 9. PR 2 — Flow broker and endpoint wiring + +- [ ] 9.1 Add `McpOAuthFlowBroker`: a cryptographically opaque one-time state bound to a single server and flow, a five-minute `TimeProvider`-bounded lifetime matching the existing CLI/TUI timeout, a task the SDK completes with the authorization URL, and a task the callback completes with the code. The broker performs no discovery, DCR, PKCE, exchange, or refresh. Its delegate coalesces concurrent invocations for one flow onto the same pending tasks, honors the SDK-passed cancellation token to bound its wait, and validates callback state itself (the SDK neither generates nor validates `state` — inject it via `AdditionalAuthorizationParameters`). At most one flow is active per server; concurrent start requests coalesce and receive the same state and URL. +- [ ] 9.2 Wire the start, status, and callback endpoints keeping their existing surface: `POST /api/mcp/oauth/start/{name}` opens a pending flow and an unpublished candidate whose `AuthorizationRedirectDelegate` is owned by the flow; the status endpoint is polled unchanged; `GET /api/mcp/oauth/callback` validates the exact pending state and completes the flow. +- [ ] 9.3 Give each flow and candidate a manager-owned cancellation token bounded by flow expiry and daemon shutdown, independent of start and callback HTTP request cancellation, so a returned start response or closed browser tab cannot cancel a running exchange. Make missing/mismatched/reused/expired state fail with safe callback HTML without affecting other flows. Classify a cancelled or failed code exchange as requiring fresh authorization (the one-time code is burned) — never retry the exchange. +- [ ] 9.4 Make explicit reauthorization use a cache view that withholds the existing access token — forcing the SDK authorization path — while the durable token set and refresh token stay intact until the new tokens are stored. +- [ ] 9.5 Derive the callback URI from `DaemonConfig.Port` (`http://127.0.0.1:{port}/api/mcp/oauth/callback`), replacing all three hard-coded 5199 sites; never derive the host from a request `Host` header. Done when a non-default port is used consistently for registration and callback. +- [ ] 9.6 Tie flow status to the full candidate lifecycle: `Completed` only after durable token storage, tool listing, and publication; any subsequent initialization failure reports `Failed`. Keep the broker aligned with the existing five-minute CLI/TUI polling timeout and test that client cancellation does not cancel the daemon-owned flow. +- [ ] 9.7 Concurrent-start test: two start requests for one server receive the same flow identity and URL, create one candidate, and can produce only one credential write and published generation. +- [ ] 9.8 Authorization-vs-reconnect race test: while one interactive candidate is pending, background and invocation-triggered reconnects coalesce onto it, do not create or publish a competing candidate, and either keep using the healthy published generation or report `AwaitingAuth` when none exists. + +## 10. PR 2 — Delete the manual OAuth stack + +- [ ] 10.1 Delete `McpOAuthService`'s protocol code: discovery, DCR HTTP, PKCE URL construction, code exchange, refresh redemption, and `GetValidTokenAsync`. Done when no Netclaw-owned code constructs discovery, registration, authorization, or token requests. +- [ ] 10.2 Remove the `mcp-oauth-metadata.json` runtime dependency — `NetclawPaths.McpOAuthMetadataPath` and `McpOAuthServerMetadata` — and detach from `OAuthPkceService`. Legacy metadata files are ignored on every runtime path, not deleted. + +## 11. PR 2 — Compatibility tests + +- [ ] 11.1 Prove static-header authentication and unauthenticated servers behave exactly as before: configured headers are sent unmodified and no-auth servers connect with no OAuth activity. +- [ ] 11.2 Prove OAuth support stays dormant until a challenge and that operator-provided User-Agent and headers are never overwritten. +- [ ] 11.3 Prove non-interactive startup and reconnect never open a browser or block: an OAuth-required server with no usable credentials reports `AwaitingAuth`. Poll with `AwaitAssertAsync`. + +## 12. PR 2 — Diagnostics + +- [ ] 12.1 Return a structured `McpErrorResponse` from authenticated OAuth API failures (discovery, DCR rejection, credential persistence, connection init) while preserving safe `text/html` responses for anonymous callback validation and code-exchange failures. The daemon logs full provider/server context and neither response format carries a token, code, PKCE verifier, or secret. +- [ ] 12.2 Make the CLI parse `McpErrorResponse`, fall back to the HTTP status and reason phrase on an empty or malformed body, and never print a blank error line. +- [ ] 12.3 Surface the connection-status taxonomy `AwaitingAuth`/`AuthFailed`/`Unreachable`/`Connected` through diagnostics; an expired access token with no refresh token reports `AwaitingAuth` and names `netclaw mcp auth ` as the remedy. Include a `TimeProvider`-derived last error timestamp and test that failure and recovery update state, tool count, error, and timestamp consistently without stamping recovery as a failure. +- [ ] 12.4 Add the DCR-bodyless-403 regression test (netclaw-dev/netclaw#1475): a provider advertising DCR that rejects registration with HTTP 403 and no body yields a CLI message naming the failing operation and HTTP status, with full context in the daemon log. + +## 13. PR 2 — Docs, skills, and evals + +- [ ] 13.1 Update `feeds/skills/.system/files/netclaw-operations/SKILL.md` with the new MCP OAuth wiring and diagnostics, and bump `metadata.version` in its frontmatter. +- [ ] 13.2 Run `./evals/run-evals.sh` and confirm the behavioral eval suite passes. + +## 14. Final verification + +- [ ] 14.1 Run `dotnet build` and the full test suite; both pass. +- [ ] 14.2 Run `dotnet slopwatch analyze`; no new violations. +- [ ] 14.3 Run `./scripts/Add-FileHeaders.ps1 -Verify`; all `.cs` files have copyright headers. +- [ ] 14.4 Run `git diff --check`; no whitespace or conflict-marker errors. No TUI smoke harness is needed because no Termina surface changes. +- [ ] 14.5 Run `openspec validate simplify-mcp-oauth-lifecycle --type change` and `/opsx-verify simplify-mcp-oauth-lifecycle`; resolve every critical or warning finding. +- [ ] 14.6 After both implementation PRs merge, sync the delta specs with `/opsx-sync simplify-mcp-oauth-lifecycle` and archive the completed change with `/opsx-archive simplify-mcp-oauth-lifecycle`. + +> Note: PR 3 (the official MCP C# SDK upgrade carrying csharp-sdk#1595/#1708 and #1658/#1705) is tracked as a separate later change; issues netclaw-dev/netclaw#1696 and #297 stay open. From dd14c24cd47f2e4011fd63d935796b9ba6175c2b Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 24 Jul 2026 15:42:49 +0000 Subject: [PATCH 02/13] Harden MCP OAuth lifecycle --- Netclaw.slnx | 1 + .../.system/files/netclaw-operations/SKILL.md | 80 +- .../simplify-mcp-oauth-lifecycle/design.md | 24 +- .../simplify-mcp-oauth-lifecycle/proposal.md | 10 +- .../specs/mcp-oauth/spec.md | 38 +- .../simplify-mcp-oauth-lifecycle/tasks.md | 122 +- .../Tools/McpToolAdapterTests.cs | 17 + src/Netclaw.Actors/Tools/McpToolAdapter.cs | 8 + .../Tools/ToolRegistrationExtensions.cs | 49 +- src/Netclaw.Actors/Tools/ToolRegistry.cs | 79 +- src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs | 62 + .../Mcp/McpOAuthEndToEndTests.cs | 231 +++ .../Provider/ProviderRenamerTests.cs | 175 ++ .../Tui/Sections/ConfigEditorSessionTests.cs | 50 + .../Tui/Wizard/WizardConfigBuilderTests.cs | 51 + src/Netclaw.Cli/Config/ConfigFileHelper.cs | 45 + .../Config/ProviderCredentialWriter.cs | 16 +- src/Netclaw.Cli/Daemon/PairCommand.cs | 8 +- src/Netclaw.Cli/Mcp/McpCommand.cs | 86 +- src/Netclaw.Cli/Provider/ProviderCommand.cs | 13 +- src/Netclaw.Cli/Provider/ProviderRenamer.cs | 95 +- src/Netclaw.Cli/Secrets/SecretsCommand.cs | 26 +- .../Tui/ProviderManagerViewModel.cs | 24 +- .../Tui/Sections/ConfigEditorSession.cs | 24 +- .../Wizard/Steps/ExposureModeStepViewModel.cs | 12 +- .../Tui/Wizard/WizardConfigBuilder.cs | 67 +- .../Netclaw.Configuration.Tests.csproj | 2 + .../SecretsFileWriterTests.cs | 223 +++ .../McpOAuthServerMetadata.cs | 35 - src/Netclaw.Configuration/McpOAuthTokenSet.cs | 56 +- src/Netclaw.Configuration/NetclawPaths.cs | 1 - .../SecretsFileWriter.cs | 163 ++ src/Netclaw.Configuration/SensitiveString.cs | 2 +- .../DaemonRuntimeStatusServiceTests.cs | 30 +- .../Mcp/McpClientManagerLifecycleTests.cs | 803 ++++++++ .../Mcp/McpClientManagerStatusTests.cs | 63 +- .../McpEndpointRouteBuilderExtensionsTests.cs | 238 +-- .../Mcp/McpOAuthCredentialStoreTests.cs | 685 +++++++ .../Mcp/McpOAuthFlowBrokerTests.cs | 201 ++ .../Mcp/McpOAuthHeaderConflictTests.cs | 277 --- .../Mcp/McpOAuthServiceTests.cs | 174 -- .../Mcp/McpReconnectionServiceTests.cs | 3 +- .../Mcp/McpSdkOAuthFlowIntegrationTests.cs | 1663 +++++++++++++++++ .../Mcp/McpSmokeHarness.cs | 38 +- .../Mcp/McpTokenCacheAdapterTests.cs | 126 -- .../Netclaw.Daemon.Tests.csproj | 1 + .../Security/BootstrapDeviceSeederTests.cs | 75 + src/Netclaw.Daemon/Mcp/McpClientManager.cs | 1469 ++++++++++++--- .../Mcp/McpEndpointRouteBuilderExtensions.cs | 112 +- .../Mcp/McpOAuthCredentialStore.cs | 737 ++++++++ src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs | 402 ++++ src/Netclaw.Daemon/Mcp/McpOAuthService.cs | 663 ------- .../Mcp/McpTokenCacheAdapter.cs | 89 - src/Netclaw.Daemon/Program.cs | 14 +- src/Netclaw.Daemon/Properties/AssemblyInfo.cs | 1 + .../Security/BootstrapDeviceSeeder.cs | 72 +- .../OAuth/OAuthTokenPersistence.cs | 60 +- .../Netclaw.SecretsLockProbe.csproj | 15 + tests/Netclaw.SecretsLockProbe/Program.cs | 38 + 59 files changed, 7857 insertions(+), 2087 deletions(-) create mode 100644 src/Netclaw.Cli.Tests/Mcp/McpOAuthEndToEndTests.cs delete mode 100644 src/Netclaw.Configuration/McpOAuthServerMetadata.cs create mode 100644 src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs create mode 100644 src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs create mode 100644 src/Netclaw.Daemon.Tests/Mcp/McpOAuthFlowBrokerTests.cs delete mode 100644 src/Netclaw.Daemon.Tests/Mcp/McpOAuthHeaderConflictTests.cs delete mode 100644 src/Netclaw.Daemon.Tests/Mcp/McpOAuthServiceTests.cs create mode 100644 src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs delete mode 100644 src/Netclaw.Daemon.Tests/Mcp/McpTokenCacheAdapterTests.cs create mode 100644 src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs create mode 100644 src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs delete mode 100644 src/Netclaw.Daemon/Mcp/McpOAuthService.cs delete mode 100644 src/Netclaw.Daemon/Mcp/McpTokenCacheAdapter.cs create mode 100644 tests/Netclaw.SecretsLockProbe/Netclaw.SecretsLockProbe.csproj create mode 100644 tests/Netclaw.SecretsLockProbe/Program.cs diff --git a/Netclaw.slnx b/Netclaw.slnx index a7cf41e75..6ff86e027 100644 --- a/Netclaw.slnx +++ b/Netclaw.slnx @@ -39,6 +39,7 @@ + diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index e4143a137..998c29ad5 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.35.0" + version: "2.36.0" --- # Netclaw Operations @@ -28,6 +28,7 @@ a reference file — load the one matching the user's intent with | Update identity / where facts go (identity vs memory) | [Identity](#identity) | | Work on a project, switch projects | `skill_read_resource('netclaw-operations', 'references/projects.md')` | | Discover MCP / available tools | `skill_read_resource('netclaw-operations', 'references/tools.md')` | +| Authorize or diagnose an HTTP/SSE MCP server | [MCP OAuth](#mcp-oauth) | | Manage skills and sources | `skill_read_resource('netclaw-operations', 'references/skills.md')` | | Manage inbound webhooks / attachments | `skill_read_resource('netclaw-operations', 'references/webhooks.md')` | | Add/switch LLM or search provider, OAuth login | `skill_read_resource('netclaw-operations', 'references/providers.md')` | @@ -113,6 +114,83 @@ Only a core toolset is always loaded. Use `search_tools(query)` to find addition or MCP tools by capability before concluding a tool doesn't exist. Full guidance: `skill_read_resource('netclaw-operations', 'references/tools.md')`. +## MCP OAuth + +For HTTP/SSE MCP servers, the Model Context Protocol .NET SDK owns OAuth +discovery, dynamic client registration (DCR), PKCE, authorization-code exchange, +token refresh, and the related HTTP calls. Netclaw presents the SDK's +authorization URL, brokers the browser callback, and durably stores the SDK token +cache. Do not fetch metadata or token endpoints by hand, build PKCE requests, or +create or repair `mcp-oauth-metadata.json`; legacy metadata files are ignored. + +### Authorize a server + +Run this with the daemon active: + +```bash +netclaw mcp auth +``` + +The command starts an unpublished client candidate, opens the authorization URL +when possible, always prints it, and waits up to five minutes. Complete the +browser flow normally. If the callback cannot reach this machine, paste the full +redirect URL into the command. Netclaw publishes the candidate only after the SDK +exchanges the code, credentials persist, the client connects, and tool discovery +succeeds. A failed replacement does not displace an existing healthy connection. + +The SDK redirect URI is +`http://127.0.0.1:{Daemon.Port}/api/mcp/oauth/callback`. If the provider requires +a pre-registered redirect URI, use the configured `Daemon.Port`, not a fixed +default port. + +A configured `Authorization` header takes precedence over SDK OAuth. Netclaw +sends that header unchanged, does not start SDK OAuth after a challenge, and +rejects `netclaw mcp auth ` until the header is removed. Check or rotate the +configured header instead of trying to layer OAuth on top of it. + +OAuth credentials are bound to the server's canonical configured resource +identity. If the same profile name is pointed at another resource, Netclaw +withholds its old tokens and dynamically registered client credentials, reports +`AwaitingAuth`, and preserves the old durable record until replacement succeeds. +A legacy token record without a resource binding is also preserved but never +silently bound; reauthorize it. An explicitly configured static OAuth client ID +remains authoritative. + +### Read connection states + +| State | Meaning and action | +|-------|--------------------| +| `Connected` | A usable client generation is published. The status includes its discovered tool count. | +| `AwaitingAuth` | No usable OAuth credential is bound to this resource, or an access token expired without a refresh token. Run `netclaw mcp auth `. Startup and background reconnects never open a browser or block. | +| `AuthFailed` | The server rejected credentials that were supplied. Reauthorize SDK-managed OAuth, or check the configured `Authorization` header if it owns auth. | +| `Unreachable` | A non-auth transport, network, timeout, or initialization failure prevented connection. Check the endpoint and daemon logs. | + +### Diagnose failures + +```bash +netclaw mcp list # configured servers plus live daemon connection states +netclaw doctor # MCP config and health checks +netclaw status # daemon connector health, including MCP +``` + +`netclaw doctor` uses live daemon state when available. If the daemon is down, it +can probe connectivity but cannot verify SDK-managed OAuth; start the daemon for +an authoritative auth result. + +OAuth failures return safe structured errors with an `error`, an `operation`, +and, when known, an HTTP `status`. The CLI prints the useful message rather than +raw JSON. A blank provider body still produces a structured daemon error from its +HTTP status. If the daemon response body is blank or malformed, the CLI falls +back to `HTTP ` instead of showing an empty error. Check daemon +logs for full server context; operator-facing errors omit authorization codes, +tokens, PKCE data, and client secrets. + +Credential persistence fails loudly. If the durable secrets write fails, +authorization fails, the shared cache does not advance, and the candidate is not +published. Fix the filesystem or secrets-store error shown in daemon logs, then +run `netclaw mcp auth ` again; browser success alone does not mean the MCP +connection is ready. + ## Approval Prompts MCP approval prompts show a bounded, redacted preview of the call arguments. diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/design.md b/openspec/changes/simplify-mcp-oauth-lifecycle/design.md index 2442adc90..d55d24b09 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/design.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/design.md @@ -233,10 +233,11 @@ as active and removes it after its flow lifetime. Ordinary refresh on a publishe connection continues to update the active record persist-before-publish. If an authorization server rejects a stored dynamically registered identity as -`invalid_client`, explicit authorization may retry once with that stored dynamic -identity withheld, allowing the SDK to perform DCR again. This recovery never -discards an explicitly configured static client ID and never replaces the active -record until the new candidate publishes. +`invalid_client`, the current flow fails visibly and records that dynamic +identity as rejected. The next explicit authorization attempt withholds it, +allowing the SDK to perform DCR with a new URL and PKCE verifier. This recovery +never discards an explicitly configured static client ID and never replaces the +active record until the new candidate publishes. Decompilation of the shipped 1.4.1 assembly confirms the necessity: the provider's `_clientId` is an in-memory field never read back from the token @@ -341,9 +342,12 @@ Deriving the port from config fixes defect 7 for any non-default local port. Netclaw owns state validation and CSRF protection end-to-end. - The provider contains no synchronization, and the transport's POST and GET/SSE paths can challenge concurrently through one provider instance. The - broker's delegate therefore coalesces concurrent invocations for one flow - onto the same pending authorization-URL and code tasks, so parallel - challenges cannot issue duplicate operator prompts. + broker therefore elects the first redirect-delegate invocation as the flow + owner. It alone publishes the authorization URL, waits for the callback code, + and returns that code to its SDK invocation. Concurrent delegates observe the + same flow as authorization-in-progress and do not prompt, but they fail their + request with a classified in-progress result rather than reusing the owner's + code with a different PKCE verifier. - The SDK imposes no timeout on the delegate; the broker bounds its own wait (the five-minute flow lifetime) and honors the SDK-passed cancellation token. Cancellation between code delivery and exchange completion consumes the @@ -352,6 +356,9 @@ Deriving the port from config fixes defect 7 for any non-default local port. - OAuth triggers lazily on the first 401/403 challenge, not eagerly at connect, and configured scopes are a fallback: scopes advertised via `WWW-Authenticate` or protected-resource metadata take precedence in 1.4.1. + When an operator configures an `Authorization` header, Netclaw does not attach + SDK OAuth to that transport because SDK 1.4.1 would replace the header after a + challenge; the explicit operator credential remains authoritative. ### D8. Failure modes, recovery, and diagnostics @@ -373,6 +380,9 @@ Deriving the port from config fixes defect 7 for any non-default local port. nor HTML exposes a token, code, verifier, or secret. The CLI parses `McpErrorResponse`, falls back to HTTP status/reason on an empty or malformed body, and never prints a blank error line. +- The authenticated status response carries an optional `McpErrorResponse` for + terminal credential-storage, code-exchange, or candidate-initialization + failures that occur after the start response has already returned. - Connection status distinguishes `AwaitingAuth` (interaction required), `AuthFailed` (credentials/refresh rejected), `Unreachable` (transport), and `Connected` (published usable generation). An expired access token with no diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md b/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md index eccd23484..2f1200f92 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md @@ -40,6 +40,8 @@ parts: one owner per concern, not a more complete second OAuth implementation. handoff only: opaque one-time state, bounded flow lifetime via `TimeProvider`) and `McpOAuthCredentialStore` (durable per-server token sets plus the DCR-issued client ID; supplies SDK `ITokenCache` adapters). + One SDK redirect delegate owns each flow's PKCE/code exchange; concurrent + delegates observe authorization in progress but never reuse its code. - **Durable credentials**: in-memory token state publishes only after durable persistence succeeds; persistence failure propagates through `ITokenCache.StoreTokensAsync` and fails the connection visibly. A token @@ -60,7 +62,8 @@ parts: one owner per concern, not a more complete second OAuth implementation. `McpErrorResponse`; the daemon logs full context while the client receives a safe actionable message. The browser callback retains safe HTML responses. The CLI falls back to HTTP status when the body is empty and never prints a - blank error line. Connection status distinguishes `AwaitingAuth`, + blank error line. Terminal failures that occur after start are carried in the + authenticated status response. Connection status distinguishes `AwaitingAuth`, `AuthFailed`, `Unreachable`, and `Connected`. No breaking changes to the external CLI/API surface: `netclaw mcp auth`, @@ -101,6 +104,11 @@ existing behavior. `src/Netclaw.Configuration/NetclawPaths.cs` (metadata path removal), `src/Netclaw.Cli` MCP auth/status commands. Expected net-negative production LOC. +- **.NET source compatibility**: removes the public + `McpOAuthServerMetadata` cache type and `NetclawPaths.McpOAuthMetadataPath` + property. These represented a runtime cache that no longer exists; external + source consumers must stop reading or constructing MCP OAuth metadata. The + CLI and HTTP contracts remain unchanged. - **Dependencies**: ModelContextProtocol.Core stays pinned at 1.4.1. A later official SDK release containing upstream fixes csharp-sdk#1595 (refresh single-flight) and csharp-sdk#1658 (cold-start client identity) is tracked diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md index 0da0c2271..d3ebbfcdc 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md @@ -43,8 +43,10 @@ callback endpoint. Flow state values SHALL be cryptographically opaque, one-time, bound to a single server and flow, and SHALL expire after a bounded lifetime of five minutes measured via `TimeProvider`. Netclaw SHALL validate the callback state itself — the SDK neither generates nor validates the OAuth -state parameter. Concurrent redirect-delegate invocations for one flow SHALL -coalesce onto the same pending authorization URL and code. At most one +state parameter. The first redirect-delegate invocation for a flow SHALL own +the authorization URL, callback code, and PKCE exchange. Concurrent delegate +invocations SHALL observe that authorization is already in progress and SHALL +NOT receive or reuse the owner's authorization code. At most one interactive flow SHALL be active per server; concurrent start requests SHALL coalesce onto that flow. The daemon SHALL own the flow and candidate lifetime independently of the HTTP start and callback request cancellation tokens, and @@ -105,8 +107,9 @@ existing five-minute CLI and TUI polling timeout. - **GIVEN** a pending interactive flow for a server - **WHEN** the SDK invokes the redirect delegate concurrently from parallel transport requests -- **THEN** both invocations observe the same pending flow and authorization URL -- **AND** the operator is prompted at most once +- **THEN** one invocation owns the authorization URL and callback code +- **AND** every other invocation observes authorization in progress without prompting +- **AND** no authorization code is returned to more than one SDK invocation #### Scenario: A cancelled exchange requires fresh authorization @@ -186,7 +189,8 @@ and query retained. - **GIVEN** credentials contain a dynamically registered client identity that the authorization server rejects as `invalid_client` - **WHEN** the operator runs explicit authorization -- **THEN** Netclaw retries the candidate flow once with the stored dynamic client identity withheld so the SDK can perform new dynamic registration +- **THEN** that flow fails visibly and records the stored dynamic identity as rejected +- **AND** the next explicit authorization attempt withholds the rejected identity so the SDK can perform new dynamic registration with a new authorization URL - **AND** the prior active credentials remain unchanged until the replacement candidate is published - **BUT** an explicitly configured static client ID is never discarded or replaced automatically @@ -206,11 +210,12 @@ NOT derive the callback host from an incoming request's Host header. ### Requirement: Startup and reconnect never block on interactive authorization -Every configured HTTP MCP transport SHALL be created with SDK OAuth support -whose non-interactive redirect delegate returns no authorization code. OAuth -support SHALL remain dormant for servers that are unauthenticated or -satisfied by operator-configured headers, and operator-provided headers SHALL -remain authoritative. When interactive authorization is required, the +The system SHALL create every configured HTTP MCP transport without an +operator-configured `Authorization` header with SDK OAuth support whose +non-interactive redirect delegate returns no authorization code. A configured +`Authorization` header SHALL suppress SDK OAuth so the SDK cannot replace it +after a challenge. OAuth support SHALL remain dormant for unauthenticated +servers, and all operator-provided headers SHALL remain authoritative. When interactive authorization is required, the connection SHALL report `AwaitingAuth` and direct the operator to `netclaw mcp auth ` instead of opening a browser or waiting for input. @@ -219,7 +224,7 @@ connection SHALL report `AwaitingAuth` and direct the operator to - **GIVEN** a server authenticated by operator-configured headers - **WHEN** the daemon connects - **THEN** the configured headers are sent unmodified -- **AND** no OAuth challenge handling alters the request +- **AND** a configured `Authorization` header disables SDK OAuth challenge handling for that transport - **AND** the connection succeeds as before #### Scenario: Unauthenticated server is unchanged @@ -247,7 +252,9 @@ exception with provider and server context. No client-facing JSON or HTML SHALL contain tokens, authorization codes, PKCE verifiers, or secret values. The CLI SHALL parse structured responses when present, SHALL fall back to the HTTP status and reason when the body is empty or malformed, and SHALL NOT print a -blank error. +blank error. The authenticated OAuth status response SHALL carry an optional +structured error for terminal failures that occur after the start response has +already returned. #### Scenario: Registration rejection surfaces a reason @@ -262,6 +269,13 @@ blank error. - **WHEN** the CLI reports the failure - **THEN** the CLI prints the HTTP status and reason phrase rather than a blank error line +#### Scenario: Late candidate failure is available through status + +- **GIVEN** an authorization start response has already returned successfully +- **WHEN** credential persistence, code exchange, or candidate initialization later fails +- **THEN** the authenticated status response reports `Failed` +- **AND** it includes a safe structured error naming the failed operation + #### Scenario: Callback validation failure remains browser-safe HTML - **GIVEN** the anonymous callback receives invalid or expired state diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md b/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md index 1b52fee30..b1b664c26 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md @@ -8,106 +8,106 @@ never `Thread.Sleep` or `Task.Delay` in orchestration. ## 1. PR 1 — Connection snapshot and generation model -- [ ] 1.1 Define an immutable per-server connection snapshot type carrying the `McpClient`, its function/tool map, a monotonically increasing generation number, status metadata (including the `TimeProvider`-derived last error timestamp), and invocation lease state. Done when the published data is read-only and one lease owner can retire and drain the generation safely. -- [ ] 1.2 Replace `McpClientManager`'s separate client and tool dictionaries with one published snapshot reference per server. Done when no code path reads the client and its tools as independent dictionaries. -- [ ] 1.3 Update `ToolRegistry` and status reporting to read from the same snapshot so connection state, tool count, and error info always change together. Done when a single lifecycle operation is the only writer of all three. +- [x] 1.1 Define an immutable per-server connection snapshot type carrying the `McpClient`, its function/tool map, a monotonically increasing generation number, status metadata (including the `TimeProvider`-derived last error timestamp), and invocation lease state. Done when the published data is read-only and one lease owner can retire and drain the generation safely. +- [x] 1.2 Replace `McpClientManager`'s separate client and tool dictionaries with one published snapshot reference per server. Done when no code path reads the client and its tools as independent dictionaries. +- [x] 1.3 Update `ToolRegistry` and status reporting to read from the same snapshot so connection state, tool count, and error info always change together. Done when a single lifecycle operation is the only writer of all three. ## 2. PR 1 — Generation-aware lifecycle gate -- [ ] 2.1 Add a per-server async lifecycle gate guarding create, publish, replace, and teardown. Done when every lifecycle transition for a server passes through one gate. -- [ ] 2.2 Implement coalescing reconnect: capture the observed generation, enter the gate, re-read the published snapshot, and if a newer healthy generation already exists, reuse it and return. Done when concurrent reconnects that observed the same generation converge on one winner. -- [ ] 2.3 Build the candidate without removing the published connection; initialize it fully (`ListToolsAsync` plus function-map construction) before publication; on init failure dispose only the candidate and keep the published connection. Done when a failed candidate leaves the prior generation serving. -- [ ] 2.4 Publish the candidate atomically as the next generation, retire the replaced generation so it accepts no new invocation leases, and dispose it exactly once after its final in-flight lease ends. Done when no routine replacement interrupts an in-flight invocation and no generation is leaked or double-disposed. -- [ ] 2.5 Serialize shutdown: `StopAsync` enters each server's gate, marks shutdown, rejects new leases and reconnects, allows a bounded invocation drain, cancels any remainder with the daemon shutdown token, then removes and disposes the snapshot. Done when no connection can be published after shutdown starts and no client is disposed while its invocation is still executing. +- [x] 2.1 Add a per-server async lifecycle gate guarding create, publish, replace, and teardown. Done when every lifecycle transition for a server passes through one gate. +- [x] 2.2 Implement coalescing reconnect: capture the observed generation, enter the gate, re-read the published snapshot, and if a newer healthy generation already exists, reuse it and return. Done when concurrent reconnects that observed the same generation converge on one winner. +- [x] 2.3 Build the candidate without removing the published connection; initialize it fully (`ListToolsAsync` plus function-map construction) before publication; on init failure dispose only the candidate and keep the published connection. Done when a failed candidate leaves the prior generation serving. +- [x] 2.4 Publish the candidate atomically as the next generation, retire the replaced generation so it accepts no new invocation leases, and dispose it exactly once after its final in-flight lease ends. Done when no routine replacement interrupts an in-flight invocation and no generation is leaked or double-disposed. +- [x] 2.5 Serialize shutdown: `StopAsync` enters each server's gate, marks shutdown, rejects new leases and reconnects, allows a bounded invocation drain, cancels any remainder with the daemon shutdown token, then removes and disposes the snapshot. Done when no connection can be published after shutdown starts and no client is disposed while its invocation is still executing. ## 3. PR 1 — Invocation classification -- [ ] 3.1 Format tool-declared and application MCP errors as tool results with no teardown or reconnect. Done when a tool-declared error returns a result and leaves the connection published. -- [ ] 3.2 Propagate caller-token `OperationCanceledException` and unknown application exceptions with no teardown or retry. Done when cancellation reaches the caller and the shared connection stays live. -- [ ] 3.3 Classify transport/session failures, return the failed invocation as an error without replay, release its snapshot lease, and only then request or await at most one coalesced reconnect for later calls. Done when an ambiguous failure never invokes the remote tool a second time and reconnect cannot wait on the caller's own lease. +- [x] 3.1 Format tool-declared and application MCP errors as tool results with no teardown or reconnect. Done when a tool-declared error returns a result and leaves the connection published. +- [x] 3.2 Propagate caller-token `OperationCanceledException` and unknown application exceptions with no teardown or retry. Done when cancellation reaches the caller and the shared connection stays live. +- [x] 3.3 Classify transport/session failures, return the failed invocation as an error without replay, release its snapshot lease, and only then request or await at most one coalesced reconnect for later calls. Done when an ambiguous failure never invokes the remote tool a second time and reconnect cannot wait on the caller's own lease. ## 4. PR 1 — Lifecycle and concurrency tests -- [ ] 4.1 Concurrent-reconnect coalescing test: many callers observing the same generation produce exactly one candidate and one winning generation, with no client leaked or double-disposed. Drive it with `TaskCompletionSource`/Ask-acks/`AwaitAssertAsync`. -- [ ] 4.2 Failed-candidate test: a candidate that fails to initialize disposes only itself; the prior generation and its tools stay available and status never advertises an empty tool set. -- [ ] 4.3 Disposal-accounting test: instrument the fake client's dispose count to prove no client is leaked or disposed more than once across reconnects. -- [ ] 4.4 Non-teardown test: caller cancellation and tool-declared/application errors neither reconnect nor dispose the healthy shared connection. -- [ ] 4.5 Shutdown-vs-reconnect race test: with a reconnect in flight, shutdown begins; assert no connection is published after shutdown starts and every created client is disposed. -- [ ] 4.6 Invocation-vs-replacement race test: hold a tool call on the prior generation while publishing a replacement; assert the call completes, the prior generation is not disposed early, and it is disposed exactly once after release. -- [ ] 4.7 Self-reconnect drain test: a lease-holding invocation fails with a classified transport error and triggers reconnect; assert it releases its lease before awaiting replacement, returns without deadlock, and is not replayed. -- [ ] 4.8 Shutdown-with-active-invocation test: hold a lease through shutdown, assert new leases are rejected, bounded drain occurs, cancellation releases the invocation, and disposal happens only after release. +- [x] 4.1 Concurrent-reconnect coalescing test: many callers observing the same generation produce exactly one candidate and one winning generation, with no client leaked or double-disposed. Drive it with `TaskCompletionSource`/Ask-acks/`AwaitAssertAsync`. +- [x] 4.2 Failed-candidate test: a candidate that fails to initialize disposes only itself; the prior generation and its tools stay available and status never advertises an empty tool set. +- [x] 4.3 Disposal-accounting test: instrument the fake client's dispose count to prove no client is leaked or disposed more than once across reconnects. +- [x] 4.4 Non-teardown test: caller cancellation and tool-declared/application errors neither reconnect nor dispose the healthy shared connection. +- [x] 4.5 Shutdown-vs-reconnect race test: with a reconnect in flight, shutdown begins; assert no connection is published after shutdown starts and every created client is disposed. +- [x] 4.6 Invocation-vs-replacement race test: hold a tool call on the prior generation while publishing a replacement; assert the call completes, the prior generation is not disposed early, and it is disposed exactly once after release. +- [x] 4.7 Self-reconnect drain test: a lease-holding invocation fails with a classified transport error and triggers reconnect; assert it releases its lease before awaiting replacement, returns without deadlock, and is not replayed. +- [x] 4.8 Shutdown-with-active-invocation test: hold a lease through shutdown, assert new leases are rejected, bounded drain occurs, cancellation releases the invocation, and disposal happens only after release. ## 5. PR 1 — Issue tracking -- [ ] 5.1 Update netclaw-dev/netclaw#1696 with the local lifecycle completion. Do not close it — the upstream SDK fixes are still pending. +- [x] 5.1 Update netclaw-dev/netclaw#1696 with the local lifecycle completion. Do not close it — the upstream SDK fixes are still pending. ## 6. PR 2 — Spike: prove the SDK redirect-delegate flow -- [ ] 6.1 Stand up a fake OAuth MCP server harness exposing discovery metadata, a DCR endpoint, an authorization endpoint, and a token endpoint, usable from integration tests. Done when a test can drive each endpoint. -- [ ] 6.2 Prove the SDK redirect-delegate flow end-to-end against the fake server — discovery, DCR, PKCE, authorization-URL delivery via `AuthorizationRedirectDelegate`, callback completion, code exchange, and `ITokenCache` store — BEFORE any manual OAuth code is deleted. Done when a green integration test drives the full SDK path and de-risks the previously unexercised redirect delegate. -- [ ] 6.3 Verify in the spike that broker-owned state injected via `AdditionalAuthorizationParameters["state"]` round-trips through the authorization URL, and that concurrent challenges from parallel transport requests coalesce onto one pending flow with a single operator prompt. +- [x] 6.1 Stand up a fake OAuth MCP server harness exposing discovery metadata, a DCR endpoint, an authorization endpoint, and a token endpoint, usable from integration tests. Done when a test can drive each endpoint. +- [x] 6.2 Prove the SDK redirect-delegate flow end-to-end against the fake server — discovery, DCR, PKCE, authorization-URL delivery via `AuthorizationRedirectDelegate`, callback completion, code exchange, and `ITokenCache` store — BEFORE any manual OAuth code is deleted. Done when a green integration test drives the full SDK path and de-risks the previously unexercised redirect delegate. +- [x] 6.3 Verify in the spike that broker-owned state injected via `AdditionalAuthorizationParameters["state"]` round-trips through the authorization URL, and that concurrent challenges from parallel transport requests coalesce onto one pending flow with a single operator prompt. ## 7. PR 2 — Transactional secrets -- [ ] 7.1 Add a path-scoped, cross-process-locked read/decrypt/mutate/encrypt/replace transaction to `SecretsFileWriter`, reusing the `WebhookRouteStore` named-mutex / SHA-256 path-hash pattern. Preserve existing encryption and file-permission hardening; whole-file `Write` participates in the same path lock. Done when canonical path resolution derives the lock identity and that lock spans the latest read through atomic replacement. -- [ ] 7.2 Migrate every secrets.json read-modify-write caller onto the transaction using owned field mutations applied to the latest document under the lock. Long-lived config editors and wizards replay contribution actions instead of writing snapshots captured before the lock. Done when no caller does an unlocked or stale-snapshot read-modify-write against secrets.json. -- [ ] 7.3 Concurrent cross-section preservation tests: two writers updating different sections both survive, unrelated sections are preserved, a second cross-process writer observes the first's committed state before mutating, and a config editor opened before an MCP refresh cannot overwrite the refreshed token when saving another section. -- [ ] 7.4 Loud-failure tests: a replacement/disk failure propagates to the caller, the prior file content stays intact, and no caller-visible state reports the update as persisted. +- [x] 7.1 Add a path-scoped, cross-process-locked read/decrypt/mutate/encrypt/replace transaction to `SecretsFileWriter`, reusing the `WebhookRouteStore` named-mutex / SHA-256 path-hash pattern. Preserve existing encryption and file-permission hardening; whole-file `Write` participates in the same path lock. Done when canonical path resolution derives the lock identity and that lock spans the latest read through atomic replacement. +- [x] 7.2 Migrate every secrets.json read-modify-write caller onto the transaction using owned field mutations applied to the latest document under the lock. Long-lived config editors and wizards replay contribution actions instead of writing snapshots captured before the lock. Done when no caller does an unlocked or stale-snapshot read-modify-write against secrets.json. +- [x] 7.3 Concurrent cross-section preservation tests: two writers updating different sections both survive, unrelated sections are preserved, a second cross-process writer observes the first's committed state before mutating, and a config editor opened before an MCP refresh cannot overwrite the refreshed token when saving another section. +- [x] 7.4 Loud-failure tests: a replacement/disk failure propagates to the caller, the prior file content stays intact, and no caller-visible state reports the update as persisted. ## 8. PR 2 — Credential store and persist-before-publish -- [ ] 8.1 Add the `McpOAuthCredentialStore` singleton backing every per-connection `ITokenCache` adapter, so per-adapter in-memory dictionaries cannot diverge. Done when all adapters read one shared view. -- [ ] 8.2 Implement persist-before-publish `StoreTokensAsync`: published-connection refresh writes the active record, while explicit authorization writes a flow-scoped durable pending record. Persist transactionally before advancing the matching in-memory view; on failure advance neither and propagate. Done when failed storage never advances memory and a failed candidate cannot replace active credentials. -- [ ] 8.3 Retain the prior refresh token when a token response omits `refresh_token`. Done when the persisted record keeps the previous refresh token. -- [ ] 8.4 Persist the DCR-issued effective client ID — and client secret, when issued — with the token record, update both from `DynamicClientRegistration.ResponseDelegate`, and seed `ClientOAuthOptions.ClientId`/`ClientSecret` from them on restart. Done when the stored client identity feeds the SDK options on reconnect. -- [ ] 8.5 Restart-without-metadata-file test: a registered client identity survives restart with no `mcp-oauth-metadata.json` present, and no re-registration occurs while the stored registration is valid. -- [ ] 8.6 Persist the canonical configured MCP resource identity with each credential record and compare it before returning cached tokens or seeding SDK client options. Done when repointing the same profile name withholds the old access token, refresh token, dynamically registered client ID, and client secret, reports `AwaitingAuth`, and preserves the old durable record until replacement succeeds; an explicitly configured static client ID remains authoritative. -- [ ] 8.7 Resource-binding canonicalization tests cover scheme/host case, default ports, fragments, path, and query so equivalent endpoint spellings compare consistently while a changed resource fails closed. -- [ ] 8.8 Legacy-binding migration test: a record without the new canonical binding supplies no token or dynamic client credentials, reports `AwaitingAuth`, preserves the old record, and never silently stamps the current profile identity onto it. -- [ ] 8.9 Pending-record lifecycle tests: explicit auth stores pending credentials durably, successful candidate publication promotes them atomically, failed/expired candidates remove only pending state, and restart ignores and prunes abandoned pending records without changing active credentials. -- [ ] 8.10 Invalid dynamic registration recovery test: explicit auth receiving `invalid_client` retries once with the stored dynamic client identity withheld so SDK DCR can run; static configured client IDs never take this fallback. +- [x] 8.1 Add the `McpOAuthCredentialStore` singleton backing every per-connection `ITokenCache` adapter, so per-adapter in-memory dictionaries cannot diverge. Done when all adapters read one shared view. +- [x] 8.2 Implement persist-before-publish `StoreTokensAsync`: published-connection refresh writes the active record, while explicit authorization writes a flow-scoped durable pending record. Persist transactionally before advancing the matching in-memory view; on failure advance neither and propagate. Done when failed storage never advances memory and a failed candidate cannot replace active credentials. +- [x] 8.3 Retain the prior refresh token when a token response omits `refresh_token`. Done when the persisted record keeps the previous refresh token. +- [x] 8.4 Persist the DCR-issued effective client ID — and client secret, when issued — with the token record, update both from `DynamicClientRegistration.ResponseDelegate`, and seed `ClientOAuthOptions.ClientId`/`ClientSecret` from them on restart. Done when the stored client identity feeds the SDK options on reconnect. +- [x] 8.5 Restart-without-metadata-file test: a registered client identity survives restart with no `mcp-oauth-metadata.json` present, and no re-registration occurs while the stored registration is valid. +- [x] 8.6 Persist the canonical configured MCP resource identity with each credential record and compare it before returning cached tokens or seeding SDK client options. Done when repointing the same profile name withholds the old access token, refresh token, dynamically registered client ID, and client secret, reports `AwaitingAuth`, and preserves the old durable record until replacement succeeds; an explicitly configured static client ID remains authoritative. +- [x] 8.7 Resource-binding canonicalization tests cover scheme/host case, default ports, fragments, path, and query so equivalent endpoint spellings compare consistently while a changed resource fails closed. +- [x] 8.8 Legacy-binding migration test: a record without the new canonical binding supplies no token or dynamic client credentials, reports `AwaitingAuth`, preserves the old record, and never silently stamps the current profile identity onto it. +- [x] 8.9 Pending-record lifecycle tests: explicit auth stores pending credentials durably, successful candidate publication promotes them atomically, failed/expired candidates remove only pending state, and restart ignores and prunes abandoned pending records without changing active credentials. +- [x] 8.10 Invalid dynamic registration recovery test: explicit auth receiving `invalid_client` fails the current flow and records the dynamic identity as rejected; the next explicit attempt withholds that identity so SDK DCR can run with a new URL, while static configured client IDs never take this fallback. ## 9. PR 2 — Flow broker and endpoint wiring -- [ ] 9.1 Add `McpOAuthFlowBroker`: a cryptographically opaque one-time state bound to a single server and flow, a five-minute `TimeProvider`-bounded lifetime matching the existing CLI/TUI timeout, a task the SDK completes with the authorization URL, and a task the callback completes with the code. The broker performs no discovery, DCR, PKCE, exchange, or refresh. Its delegate coalesces concurrent invocations for one flow onto the same pending tasks, honors the SDK-passed cancellation token to bound its wait, and validates callback state itself (the SDK neither generates nor validates `state` — inject it via `AdditionalAuthorizationParameters`). At most one flow is active per server; concurrent start requests coalesce and receive the same state and URL. -- [ ] 9.2 Wire the start, status, and callback endpoints keeping their existing surface: `POST /api/mcp/oauth/start/{name}` opens a pending flow and an unpublished candidate whose `AuthorizationRedirectDelegate` is owned by the flow; the status endpoint is polled unchanged; `GET /api/mcp/oauth/callback` validates the exact pending state and completes the flow. -- [ ] 9.3 Give each flow and candidate a manager-owned cancellation token bounded by flow expiry and daemon shutdown, independent of start and callback HTTP request cancellation, so a returned start response or closed browser tab cannot cancel a running exchange. Make missing/mismatched/reused/expired state fail with safe callback HTML without affecting other flows. Classify a cancelled or failed code exchange as requiring fresh authorization (the one-time code is burned) — never retry the exchange. -- [ ] 9.4 Make explicit reauthorization use a cache view that withholds the existing access token — forcing the SDK authorization path — while the durable token set and refresh token stay intact until the new tokens are stored. -- [ ] 9.5 Derive the callback URI from `DaemonConfig.Port` (`http://127.0.0.1:{port}/api/mcp/oauth/callback`), replacing all three hard-coded 5199 sites; never derive the host from a request `Host` header. Done when a non-default port is used consistently for registration and callback. -- [ ] 9.6 Tie flow status to the full candidate lifecycle: `Completed` only after durable token storage, tool listing, and publication; any subsequent initialization failure reports `Failed`. Keep the broker aligned with the existing five-minute CLI/TUI polling timeout and test that client cancellation does not cancel the daemon-owned flow. -- [ ] 9.7 Concurrent-start test: two start requests for one server receive the same flow identity and URL, create one candidate, and can produce only one credential write and published generation. -- [ ] 9.8 Authorization-vs-reconnect race test: while one interactive candidate is pending, background and invocation-triggered reconnects coalesce onto it, do not create or publish a competing candidate, and either keep using the healthy published generation or report `AwaitingAuth` when none exists. +- [x] 9.1 Add `McpOAuthFlowBroker`: a cryptographically opaque one-time state bound to a single server and flow, a five-minute `TimeProvider`-bounded lifetime matching the existing CLI/TUI timeout, a task the owner SDK delegate completes with the authorization URL, and a task the callback completes with the code. The broker performs no discovery, DCR, PKCE, exchange, or refresh. The first delegate invocation owns the URL/code exchange; concurrent delegates observe authorization in progress without another prompt and never receive the owner's code. The owner honors SDK cancellation, and the broker validates callback state itself (the SDK neither generates nor validates `state` — inject it via `AdditionalAuthorizationParameters`). At most one flow is active per server; concurrent start requests coalesce and receive the same state and URL. +- [x] 9.2 Wire the start, status, and callback endpoints keeping their existing surface: `POST /api/mcp/oauth/start/{name}` opens a pending flow and an unpublished candidate whose `AuthorizationRedirectDelegate` is owned by the flow; the status endpoint is polled unchanged; `GET /api/mcp/oauth/callback` validates the exact pending state and completes the flow. +- [x] 9.3 Give each flow and candidate a manager-owned cancellation token bounded by flow expiry and daemon shutdown, independent of start and callback HTTP request cancellation, so a returned start response or closed browser tab cannot cancel a running exchange. Make missing/mismatched/reused/expired state fail with safe callback HTML without affecting other flows. Classify a cancelled or failed code exchange as requiring fresh authorization (the one-time code is burned) — never retry the exchange. +- [x] 9.4 Make explicit reauthorization use a cache view that withholds the existing access token — forcing the SDK authorization path — while the durable token set and refresh token stay intact until the new tokens are stored. +- [x] 9.5 Derive the callback URI from `DaemonConfig.Port` (`http://127.0.0.1:{port}/api/mcp/oauth/callback`), replacing all three hard-coded 5199 sites; never derive the host from a request `Host` header. Done when a non-default port is used consistently for registration and callback. +- [x] 9.6 Tie flow status to the full candidate lifecycle: `Completed` only after durable token storage, tool listing, and publication; any subsequent initialization failure reports `Failed`. Keep the broker aligned with the existing five-minute CLI/TUI polling timeout and test that client cancellation does not cancel the daemon-owned flow. +- [x] 9.7 Concurrent-start test: two start requests for one server receive the same flow identity and URL, create one candidate, and can produce only one credential write and published generation. +- [x] 9.8 Authorization-vs-reconnect race test: while one interactive candidate is pending, background and invocation-triggered reconnects coalesce onto it, do not create or publish a competing candidate, and either keep using the healthy published generation or report `AwaitingAuth` when none exists. ## 10. PR 2 — Delete the manual OAuth stack -- [ ] 10.1 Delete `McpOAuthService`'s protocol code: discovery, DCR HTTP, PKCE URL construction, code exchange, refresh redemption, and `GetValidTokenAsync`. Done when no Netclaw-owned code constructs discovery, registration, authorization, or token requests. -- [ ] 10.2 Remove the `mcp-oauth-metadata.json` runtime dependency — `NetclawPaths.McpOAuthMetadataPath` and `McpOAuthServerMetadata` — and detach from `OAuthPkceService`. Legacy metadata files are ignored on every runtime path, not deleted. +- [x] 10.1 Delete `McpOAuthService`'s protocol code: discovery, DCR HTTP, PKCE URL construction, code exchange, refresh redemption, and `GetValidTokenAsync`. Done when no Netclaw-owned code constructs discovery, registration, authorization, or token requests. +- [x] 10.2 Remove the `mcp-oauth-metadata.json` runtime dependency — `NetclawPaths.McpOAuthMetadataPath` and `McpOAuthServerMetadata` — and detach from `OAuthPkceService`. Legacy metadata files are ignored on every runtime path, not deleted. ## 11. PR 2 — Compatibility tests -- [ ] 11.1 Prove static-header authentication and unauthenticated servers behave exactly as before: configured headers are sent unmodified and no-auth servers connect with no OAuth activity. -- [ ] 11.2 Prove OAuth support stays dormant until a challenge and that operator-provided User-Agent and headers are never overwritten. -- [ ] 11.3 Prove non-interactive startup and reconnect never open a browser or block: an OAuth-required server with no usable credentials reports `AwaitingAuth`. Poll with `AwaitAssertAsync`. +- [x] 11.1 Prove static-header authentication and unauthenticated servers behave exactly as before: configured headers are sent unmodified, a configured `Authorization` header suppresses SDK OAuth even after a challenge, and no-auth servers connect with no OAuth activity. +- [x] 11.2 Prove OAuth support stays dormant until a challenge and that operator-provided User-Agent and non-authorization headers are never overwritten. +- [x] 11.3 Prove non-interactive startup and reconnect never open a browser or block: an OAuth-required server with no usable credentials reports `AwaitingAuth`. Poll with `AwaitAssertAsync`. ## 12. PR 2 — Diagnostics -- [ ] 12.1 Return a structured `McpErrorResponse` from authenticated OAuth API failures (discovery, DCR rejection, credential persistence, connection init) while preserving safe `text/html` responses for anonymous callback validation and code-exchange failures. The daemon logs full provider/server context and neither response format carries a token, code, PKCE verifier, or secret. -- [ ] 12.2 Make the CLI parse `McpErrorResponse`, fall back to the HTTP status and reason phrase on an empty or malformed body, and never print a blank error line. -- [ ] 12.3 Surface the connection-status taxonomy `AwaitingAuth`/`AuthFailed`/`Unreachable`/`Connected` through diagnostics; an expired access token with no refresh token reports `AwaitingAuth` and names `netclaw mcp auth ` as the remedy. Include a `TimeProvider`-derived last error timestamp and test that failure and recovery update state, tool count, error, and timestamp consistently without stamping recovery as a failure. -- [ ] 12.4 Add the DCR-bodyless-403 regression test (netclaw-dev/netclaw#1475): a provider advertising DCR that rejects registration with HTTP 403 and no body yields a CLI message naming the failing operation and HTTP status, with full context in the daemon log. +- [x] 12.1 Return a structured `McpErrorResponse` from authenticated OAuth API failures (discovery, DCR rejection, credential persistence, connection init), include optional structured errors in authenticated terminal status responses for failures after start, and preserve safe `text/html` responses for anonymous callback validation and code-exchange failures. The daemon logs full provider/server context and no response format carries a token, code, PKCE verifier, or secret. +- [x] 12.2 Make the CLI parse `McpErrorResponse`, fall back to the HTTP status and reason phrase on an empty or malformed body, and never print a blank error line. +- [x] 12.3 Surface the connection-status taxonomy `AwaitingAuth`/`AuthFailed`/`Unreachable`/`Connected` through diagnostics; an expired access token with no refresh token reports `AwaitingAuth` and names `netclaw mcp auth ` as the remedy. Include a `TimeProvider`-derived last error timestamp and test that failure and recovery update state, tool count, error, and timestamp consistently without stamping recovery as a failure. +- [x] 12.4 Add the DCR-bodyless-403 regression test (netclaw-dev/netclaw#1475): a provider advertising DCR that rejects registration with HTTP 403 and no body yields a CLI message naming the failing operation and HTTP status, with full context in the daemon log. ## 13. PR 2 — Docs, skills, and evals -- [ ] 13.1 Update `feeds/skills/.system/files/netclaw-operations/SKILL.md` with the new MCP OAuth wiring and diagnostics, and bump `metadata.version` in its frontmatter. -- [ ] 13.2 Run `./evals/run-evals.sh` and confirm the behavioral eval suite passes. +- [x] 13.1 Update `feeds/skills/.system/files/netclaw-operations/SKILL.md` with the new MCP OAuth wiring and diagnostics, and bump `metadata.version` in its frontmatter. +- [x] 13.2 Local behavioral eval execution was waived by the operator on 2026-07-23. A partial run against `nvidia/Qwen3.6-27B-NVFP4` stopped at the unrelated `identity_version` case (3/5, "tool call detected"); no MCP OAuth case failed before the waiver. ## 14. Final verification -- [ ] 14.1 Run `dotnet build` and the full test suite; both pass. -- [ ] 14.2 Run `dotnet slopwatch analyze`; no new violations. -- [ ] 14.3 Run `./scripts/Add-FileHeaders.ps1 -Verify`; all `.cs` files have copyright headers. -- [ ] 14.4 Run `git diff --check`; no whitespace or conflict-marker errors. No TUI smoke harness is needed because no Termina surface changes. -- [ ] 14.5 Run `openspec validate simplify-mcp-oauth-lifecycle --type change` and `/opsx-verify simplify-mcp-oauth-lifecycle`; resolve every critical or warning finding. +- [x] 14.1 Run `dotnet build` and the full test suite; both pass. The final build used `-m:1` after a parallel MSBuild worker exited with `MSB4166`; the serial build passed with 0 warnings/errors. +- [x] 14.2 Run `dotnet slopwatch analyze`; no new violations. +- [x] 14.3 Run `./scripts/Add-FileHeaders.ps1 -Verify`; all `.cs` files have copyright headers. +- [x] 14.4 Run `git diff --check`; no whitespace or conflict-marker errors. No TUI smoke harness is needed because no Termina surface changes. +- [x] 14.5 Run `openspec validate simplify-mcp-oauth-lifecycle --type change` and `/opsx-verify simplify-mcp-oauth-lifecycle`; resolve every critical or warning finding. - [ ] 14.6 After both implementation PRs merge, sync the delta specs with `/opsx-sync simplify-mcp-oauth-lifecycle` and archive the completed change with `/opsx-archive simplify-mcp-oauth-lifecycle`. > Note: PR 3 (the official MCP C# SDK upgrade carrying csharp-sdk#1595/#1708 and #1658/#1705) is tracked as a separate later change; issues netclaw-dev/netclaw#1696 and #297 stay open. diff --git a/src/Netclaw.Actors.Tests/Tools/McpToolAdapterTests.cs b/src/Netclaw.Actors.Tests/Tools/McpToolAdapterTests.cs index 6cbf45141..0b26efdb0 100644 --- a/src/Netclaw.Actors.Tests/Tools/McpToolAdapterTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/McpToolAdapterTests.cs @@ -168,6 +168,23 @@ public async Task ExecuteAsync_WithContext_InvokerFailure_ReturnsError() Assert.Contains("session transport failed", result); } + [Fact] + public async Task ExecuteAsync_WithContext_CallerCancellationPropagates() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var fakeTool = AIFunctionFactory.Create(() => "unused", "navigate_page"); + var invoker = new RecordingMcpToolInvoker("ignored") + { + Failure = new OperationCanceledException(cancellation.Token) + }; + var adapter = new McpToolAdapter(fakeTool, "browser_playwright", "navigate_page", invoker: invoker); + var context = TestToolExecutionContext.CreateBound("chan/thread", null, TrustAudience.Personal); + + await Assert.ThrowsAnyAsync( + () => adapter.ExecuteAsync(ToolInput.Empty(), context, cancellation.Token)); + } + [Fact] public void ClampDescription_ExactlyAtLimit_PreservedAsIs() { diff --git a/src/Netclaw.Actors/Tools/McpToolAdapter.cs b/src/Netclaw.Actors/Tools/McpToolAdapter.cs index e90e3ac8c..136ddb49c 100644 --- a/src/Netclaw.Actors/Tools/McpToolAdapter.cs +++ b/src/Netclaw.Actors/Tools/McpToolAdapter.cs @@ -122,6 +122,10 @@ public async Task ExecuteAsync( var coerced = McpSchemaSanitizer.CoerceArguments(normalized, _rawSchema); return await _invoker.InvokeAsync(ServerName, _toolName, coerced, context, ct); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } catch (Exception ex) { return $"Error: MCP tool '{Name}' failed: {ex.Message}"; @@ -144,6 +148,10 @@ private async Task ExecuteViaBoundToolAsync(IDictionary var result = await func.InvokeAsync(aiArgs, ct); return McpToolResultFormatter.Format(result, Name); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } catch (Exception ex) { return $"Error: MCP tool '{Name}' failed: {ex.Message}"; diff --git a/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs b/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs index 902c8ef7c..553f7b486 100644 --- a/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs +++ b/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs @@ -136,6 +136,30 @@ public static ToolRegistry WithMcpTools( int maxDescriptionChars = 0, int maxSchemaWarnChars = 0, ILogger? logger = null) + { + var adapters = PrepareMcpTools( + serverName, + tools.Cast().ToList(), + grantCategory, + invoker, + maxDescriptionChars, + maxSchemaWarnChars, + logger); + + foreach (var adapter in adapters) + registry.Register(adapter); + + return registry; + } + + public static IReadOnlyList PrepareMcpTools( + string serverName, + IReadOnlyList tools, + string? grantCategory, + IMcpToolInvoker? invoker, + int maxDescriptionChars, + int maxSchemaWarnChars, + ILogger? logger) { var adapters = new List(tools.Count); foreach (var tool in tools) @@ -150,22 +174,21 @@ public static ToolRegistry WithMcpTools( logger); adapters.Add(adapter); - if (maxSchemaWarnChars > 0 && adapter.ParameterSchema.ValueKind != System.Text.Json.JsonValueKind.Undefined) + if (maxSchemaWarnChars <= 0 + || adapter.ParameterSchema.ValueKind == System.Text.Json.JsonValueKind.Undefined) + continue; + + var schemaChars = adapter.ParameterSchema.GetRawText().Length; + if (schemaChars > maxSchemaWarnChars) { - var schemaChars = adapter.ParameterSchema.GetRawText().Length; - if (schemaChars > maxSchemaWarnChars) - { - logger?.LogWarning( - "MCP tool '{ServerName}/{ToolName}' has an oversized schema ({SchemaChars} chars, threshold {Threshold}). " + - "This wastes context window budget when the tool is loaded. Consider asking the MCP server author to reduce schema verbosity.", - serverName, tool.Name, schemaChars, maxSchemaWarnChars); - } + logger?.LogWarning( + "MCP tool '{ServerName}/{ToolName}' has an oversized schema ({SchemaChars} chars, threshold {Threshold}). " + + "This wastes context window budget when the tool is loaded. Consider asking the MCP server author to reduce schema verbosity.", + serverName, tool.Name, schemaChars, maxSchemaWarnChars); } } - foreach (var adapter in adapters) - registry.Register(adapter); - - return registry; + return adapters; } + } diff --git a/src/Netclaw.Actors/Tools/ToolRegistry.cs b/src/Netclaw.Actors/Tools/ToolRegistry.cs index 168ca92e5..df9961470 100644 --- a/src/Netclaw.Actors/Tools/ToolRegistry.cs +++ b/src/Netclaw.Actors/Tools/ToolRegistry.cs @@ -28,16 +28,42 @@ public sealed record McpServerSummary(string ServerName, string Description, int public sealed class ToolRegistry { private readonly List _tools = []; + private readonly object _sync = new(); public void Register(INetclawTool tool) { - _tools.Add(new ToolRegistration(tool, tool.GrantCategory)); + lock (_sync) + _tools.Add(new ToolRegistration(tool, tool.GrantCategory)); } public void Replace(INetclawTool tool) { - _tools.RemoveAll(t => string.Equals(t.Tool.Name, tool.Name, StringComparison.Ordinal)); - Register(tool); + lock (_sync) + { + _tools.RemoveAll(t => string.Equals(t.Tool.Name, tool.Name, StringComparison.Ordinal)); + _tools.Add(new ToolRegistration(tool, tool.GrantCategory)); + } + } + + public void PublishMcpServerTools( + string serverName, + IReadOnlyList tools, + Action publishSnapshot) + { + ArgumentNullException.ThrowIfNull(publishSnapshot); + + lock (_sync) + { + _tools.RemoveAll(t => t.Tool is McpToolAdapter mcp + && string.Equals(mcp.ServerName, serverName, StringComparison.OrdinalIgnoreCase)); + foreach (var tool in tools) + _tools.Add(new ToolRegistration(tool, tool.GrantCategory)); + + // Registry readers use this same lock. Publishing the manager + // snapshot here makes its volatile write the linearization point: + // readers can observe either the old pair or the new pair, not a mix. + publishSnapshot(); + } } /// @@ -45,16 +71,17 @@ public void Replace(INetclawTool tool) /// public void Register(AITool tool, string grantCategory) { - _tools.Add(new ToolRegistration(new AIToolAdapter(tool, grantCategory), grantCategory)); + lock (_sync) + _tools.Add(new ToolRegistration(new AIToolAdapter(tool, grantCategory), grantCategory)); } /// All registered tools as AITool for ChatOptions.Tools. public IReadOnlyList GetAllTools() => - _tools.Select(t => t.Tool.ToAITool()).ToList(); + GetRegistrationsSnapshot().Select(t => t.Tool.ToAITool()).ToList(); /// Only tools whose grant category is in the allowed set. public IReadOnlyList GetToolsForGrants(IReadOnlySet grantedCategories) => - _tools + GetRegistrationsSnapshot() .Where(t => grantedCategories.Contains(t.GrantCategory)) .Select(t => t.Tool.ToAITool()) .ToList(); @@ -93,12 +120,15 @@ public string ToCanonicalName(string name) => private ToolRegistration? FindRegistration(string name) { - var direct = _tools.FirstOrDefault(t => t.Tool.Name == name); - if (direct is not null) - return direct; - return _tools.FirstOrDefault(t => - t.Tool is McpToolAdapter mcp - && string.Equals(mcp.LlmFacingName.Value, name, StringComparison.Ordinal)); + lock (_sync) + { + var direct = _tools.FirstOrDefault(t => t.Tool.Name == name); + if (direct is not null) + return direct; + return _tools.FirstOrDefault(t => + t.Tool is McpToolAdapter mcp + && string.Equals(mcp.LlmFacingName.Value, name, StringComparison.Ordinal)); + } } /// @@ -106,7 +136,7 @@ t.Tool is McpToolAdapter mcp /// All non-MCP tools are always loaded; MCP tools use dynamic discovery via search_tools. /// public IReadOnlyList GetAlwaysLoadedTools() => - _tools + GetRegistrationsSnapshot() .Where(t => t.Tool is not McpToolAdapter) .Select(t => t.Tool.ToAITool()) .ToList(); @@ -114,7 +144,7 @@ public IReadOnlyList GetAlwaysLoadedTools() => /// /// Returns all registered tool registrations (for search and dynamic loading). /// - public IReadOnlyList GetAllRegistrations() => _tools; + public IReadOnlyList GetAllRegistrations() => GetRegistrationsSnapshot(); /// /// Returns tools discovered from one MCP server. @@ -124,7 +154,7 @@ public IReadOnlyList GetToolsForServer(McpServerName serverName, i if (string.IsNullOrWhiteSpace(serverName.Value) || maxResults <= 0) return []; - return _tools + return GetRegistrationsSnapshot() .Where(t => t.Tool is McpToolAdapter mcp && string.Equals(mcp.ServerName, serverName.Value, StringComparison.OrdinalIgnoreCase)) .Select(t => t.Tool) @@ -138,7 +168,7 @@ public IReadOnlyList GetToolsForServer(McpServerName serverName, i /// public IReadOnlyList GetMcpServerSummaries() { - return _tools + return GetRegistrationsSnapshot() .Select(t => t.Tool) .OfType() .GroupBy(t => t.ServerName, StringComparer.OrdinalIgnoreCase) @@ -164,7 +194,7 @@ public IReadOnlyList SearchTools(string query, McpServerName? serv if (queryParts.Count == 0) return []; - return _tools + return GetRegistrationsSnapshot() .Where(t => { // Apply server filter if specified @@ -199,7 +229,7 @@ public IReadOnlyList SuggestTools(string query, McpServerName? ser if (string.IsNullOrWhiteSpace(normalizedQuery)) return []; - var candidates = _tools + var candidates = GetRegistrationsSnapshot() .Where(t => PassesServerFilter(t.Tool, serverFilter)) .Select(t => t.Tool) .ToList(); @@ -227,10 +257,11 @@ public IReadOnlyList SuggestTools(string query, McpServerName? ser /// public string GenerateCompressedIndex() { - if (_tools.Count == 0) + var registrations = GetRegistrationsSnapshot(); + if (registrations.Count == 0) return string.Empty; - return BuildCompressedIndex(_tools); + return BuildCompressedIndex(registrations); } /// @@ -239,7 +270,7 @@ public string GenerateCompressedIndex() /// public string GenerateCompressedIndex(TrustAudience audience, ToolAccessPolicy policy) { - var visible = _tools + var visible = GetRegistrationsSnapshot() .Where(t => policy.IsToolExposed(t.Tool, audience)) .ToList(); @@ -428,6 +459,12 @@ private static int LevenshteinDistance(string source, string target) return d[rows - 1, cols - 1]; } + private IReadOnlyList GetRegistrationsSnapshot() + { + lock (_sync) + return _tools.ToList(); + } + /// /// Adapter to wrap bare instances (e.g. test fakes) as . /// diff --git a/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs index 35433c2c8..fc698557e 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs @@ -540,6 +540,68 @@ public void TryEmitOsc52Copy_StringWriter_ReturnsFalse() Assert.Equal(string.Empty, output.ToString()); } + [Fact] + public async Task Auth_ParsesStructuredBodylessDcrFailure() + { + await McpCommand.RunAsync( + ["mcp", "add", "--transport", "http", "oauth", "https://mcp.example/mcp"], + _paths, + output: _output); + var daemonApi = CreateDaemonApi(request => request.RequestUri!.AbsolutePath switch + { + "/api/mcp/oauth/start/oauth" => new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent( + "{\"error\":\"MCP OAuth dynamic client registration failed: HTTP 403 Forbidden.\",\"operation\":\"dynamic client registration\",\"status\":403}", + Encoding.UTF8, + "application/json"), + }, + _ => new HttpResponseMessage(HttpStatusCode.NotFound), + }); + var output = new StringWriter(); + + var exitCode = await McpCommand.RunAsync(["mcp", "auth", "oauth"], _paths, daemonApi, output); + + Assert.Equal(1, exitCode); + Assert.Contains("dynamic client registration", output.ToString(), StringComparison.Ordinal); + Assert.Contains("HTTP 403 Forbidden", output.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("{\"error\"", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Auth_EmptyErrorBodyFallsBackToHttpStatusAndReason() + { + await McpCommand.RunAsync( + ["mcp", "add", "--transport", "http", "oauth", "https://mcp.example/mcp"], + _paths, + output: _output); + var daemonApi = CreateDaemonApi(request => request.RequestUri!.AbsolutePath switch + { + "/api/mcp/oauth/start/oauth" => new HttpResponseMessage(HttpStatusCode.Forbidden), + _ => new HttpResponseMessage(HttpStatusCode.NotFound), + }); + var output = new StringWriter(); + + var exitCode = await McpCommand.RunAsync(["mcp", "auth", "oauth"], _paths, daemonApi, output); + + Assert.Equal(1, exitCode); + Assert.Contains("HTTP 403 Forbidden", output.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("Error: \n", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ReadMcpError_MalformedBodyFallsBackToHttpStatusAndReason() + { + using var response = new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("not-json", Encoding.UTF8, "text/plain"), + }; + + var message = await McpCommand.ReadMcpErrorAsync(response); + + Assert.Equal("HTTP 502 Bad Gateway", message); + } + private static JsonDocument ReadConfigFile(string path) { return JsonDocument.Parse(File.ReadAllText(path)); diff --git a/src/Netclaw.Cli.Tests/Mcp/McpOAuthEndToEndTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpOAuthEndToEndTests.cs new file mode 100644 index 000000000..568c57f1b --- /dev/null +++ b/src/Netclaw.Cli.Tests/Mcp/McpOAuthEndToEndTests.cs @@ -0,0 +1,231 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using Netclaw.Actors.Tools; +using Netclaw.Cli.Daemon; +using Netclaw.Cli.Mcp; +using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; +using Netclaw.Daemon.Mcp; +using Netclaw.Daemon.Security; +using Netclaw.Tests.Utilities; +using Netclaw.Tools; +using Xunit; + +namespace Netclaw.Cli.Tests.Mcp; + +public sealed class McpOAuthEndToEndTests : IDisposable +{ + private readonly DisposableTempDir _directory = new(); + + [Fact] + public async Task BodylessDcr403TraversesProviderDaemonEndpointSerializationAndCli() + { + var ct = TestContext.Current.CancellationToken; + var paths = new NetclawPaths(_directory.Path); + paths.EnsureDirectoriesExist(); + var configOutput = new StringWriter(); + Assert.Equal(0, await McpCommand.RunAsync( + ["mcp", "add", "--transport", "http", "oauth", "https://oauth.test/mcp"], + paths, + output: configOutput)); + var servers = McpCommand.LoadMcpServers(paths); + var logger = new RecordingLogger(); + var credentials = new McpOAuthCredentialStore( + paths, + TimeProvider.System, + new NullSecretsProtector(), + new RecordingLogger()); + using var broker = new McpOAuthFlowBroker(TimeProvider.System, CancellationToken.None); + var manager = new McpClientManager( + servers, + new ToolRegistry(), + new ToolConfig(), + credentials, + broker, + new DaemonConfig(), + NullNotificationSink.Instance, + TimeProvider.System, + new BodylessDcrRuntime(), + logger, + new SessionConfig()); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddNetclawAuthSchemes(new DaemonConfig()); + builder.Services.AddAuthorization(); + builder.Services.AddLogging(); + builder.Services.AddSingleton(servers); + builder.Services.AddSingleton(credentials); + builder.Services.AddSingleton(broker); + builder.Services.AddSingleton(manager); + builder.Services.AddSingleton>(logger); + await using var app = builder.Build(); + app.Use(async (context, next) => + { + context.Connection.RemoteIpAddress = IPAddress.Loopback; + await next(context); + }); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapMcpEndpoints(); + await app.StartAsync(ct); + + var daemonApi = new DaemonApi( + new TestServerHttpClientFactory(app.GetTestClient), + new ConfigurationBuilder().Build(), + paths); + var output = new StringWriter(); + + var exitCode = await McpCommand.RunAsync( + ["mcp", "auth", "oauth"], + paths, + daemonApi, + output); + + Assert.Equal(1, exitCode); + Assert.Contains("dynamic client registration", output.ToString(), StringComparison.Ordinal); + Assert.Contains("HTTP 403 Forbidden", output.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("Error: \n", output.ToString(), StringComparison.Ordinal); + Assert.Contains(logger.Messages, message => message.Contains("403", StringComparison.Ordinal)); + Assert.Contains(logger.Exceptions, exception => + exception.ToString().Contains("Forbidden", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain("access_token", output.ToString(), StringComparison.OrdinalIgnoreCase); + await manager.StopAsync(CancellationToken.None); + manager.Dispose(); + } + + public void Dispose() => _directory.Dispose(); + + private sealed class BodylessDcrRuntime : IMcpClientRuntime + { + public IClientTransport CreateHttpTransport(HttpClientTransportOptions options) + => new HttpClientTransport( + options, + new HttpClient(new BodylessDcrHandler()) { BaseAddress = new Uri("https://oauth.test") }, + ownsHttpClient: true); + + public Task CreateAsync( + IClientTransport transport, + McpClientOptions options, + CancellationToken cancellationToken) + => McpClient.CreateAsync(transport, options, cancellationToken: cancellationToken); + + public async ValueTask InitializeAsync( + McpClient client, + CancellationToken cancellationToken) + { + var tools = await client.ListToolsAsync(cancellationToken: cancellationToken); + return new McpClientInitialization(tools.Cast().ToList()); + } + + public ValueTask InvokeAsync( + AIFunction function, + AIFunctionArguments? arguments, + CancellationToken cancellationToken) + => function.InvokeAsync(arguments, cancellationToken); + + public ValueTask DisposeAsync(McpClient client) => client.DisposeAsync(); + } + + private sealed class BodylessDcrHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = request.RequestUri!.AbsolutePath; + HttpResponseMessage response = path switch + { + "/mcp" => Challenge(), + "/.well-known/oauth-protected-resource/mcp" => Json(new + { + resource = "https://oauth.test/mcp", + authorization_servers = new[] { "https://oauth.test" }, + }), + "/.well-known/oauth-authorization-server" => Json(new + { + issuer = "https://oauth.test", + authorization_endpoint = "https://oauth.test/authorize", + token_endpoint = "https://oauth.test/token", + registration_endpoint = "https://oauth.test/register", + response_types_supported = new[] { "code" }, + grant_types_supported = new[] { "authorization_code", "refresh_token" }, + token_endpoint_auth_methods_supported = new[] { "none" }, + code_challenge_methods_supported = new[] { "S256" }, + }), + "/register" => new HttpResponseMessage(HttpStatusCode.Forbidden), + _ => new HttpResponseMessage(HttpStatusCode.NotFound), + }; + return Task.FromResult(response); + } + + private static HttpResponseMessage Challenge() + { + var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + response.Headers.TryAddWithoutValidation( + "WWW-Authenticate", + "Bearer resource_metadata=\"https://oauth.test/.well-known/oauth-protected-resource/mcp\""); + return response; + } + + private static HttpResponseMessage Json(object value) + => new(HttpStatusCode.OK) + { + Content = new StringContent( + JsonSerializer.Serialize(value), + Encoding.UTF8, + "application/json"), + }; + } + + private sealed class TestServerHttpClientFactory(Func createClient) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => createClient(); + } + + private sealed class RecordingLogger : ILogger + { + public string? LastMessage { get; private set; } + + public Exception? LastException { get; private set; } + + public List Messages { get; } = []; + + public List Exceptions { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + LastMessage = formatter(state, exception); + Messages.Add(LastMessage); + if (exception is not null) + { + LastException = exception; + Exceptions.Add(exception); + } + } + } +} diff --git a/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs b/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs index a1bd61020..118b62958 100644 --- a/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs +++ b/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs @@ -4,8 +4,10 @@ // // ----------------------------------------------------------------------- using System.Text.Json; +using Netclaw.Cli.Config; using Netclaw.Cli.Provider; using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; using Netclaw.Tests.Utilities; using Xunit; @@ -147,6 +149,164 @@ public void Rename_CollidesWithExistingProvider_ReturnsError() Assert.Contains("my-vllm-b", result.ErrorMessage!); } + [Fact] + public void Rename_CollidesWithExistingSecretProvider_ReturnsErrorAndLeavesConfigUnchanged() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } + } + }); + WriteSecrets(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["ApiKey"] = "sk-old" }, + ["lab-a100"] = new Dictionary { ["ApiKey"] = "sk-new" } + } + }); + + var result = ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100"); + + Assert.False(result.Success); + Assert.Contains("lab-a100", result.ErrorMessage!); + + var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var providers = config.RootElement.GetProperty("Providers"); + Assert.True(providers.TryGetProperty("my-vllm", out _)); + Assert.False(providers.TryGetProperty("lab-a100", out _)); + } + + [Fact] + public void Rename_SecretsReadFailure_ThrowsAndLeavesConfigUnchanged() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } + } + }); + File.WriteAllText(_paths.SecretsPath, "{not-json"); + + Assert.ThrowsAny(() => ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100")); + + var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var providers = config.RootElement.GetProperty("Providers"); + Assert.True(providers.TryGetProperty("my-vllm", out _)); + Assert.False(providers.TryGetProperty("lab-a100", out _)); + } + + [Fact] + public void Rename_ConfigWriteFailure_ThrowsAndLeavesSecretsUnchanged() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } + } + }); + WriteSecrets(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["ApiKey"] = "sk-old" } + } + }); + var originalConfig = File.ReadAllText(_paths.NetclawConfigPath); + var originalSecrets = File.ReadAllText(_paths.SecretsPath); + + Assert.Throws(() => ProviderRenamer.Rename( + _paths, + "my-vllm", + "lab-a100", + static (_, _) => throw new InvalidOperationException("config write failed"), + static (path, text) => AtomicFile.WriteAllText(path, text), + secretsProtector: null)); + + Assert.Equal(originalConfig, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(originalSecrets, File.ReadAllText(_paths.SecretsPath)); + AssertProviderState(_paths.NetclawConfigPath, oldNamePresent: true, newNamePresent: false); + AssertProviderState(_paths.SecretsPath, oldNamePresent: true, newNamePresent: false); + } + + [Fact] + public void Rename_SecretsWriteFailure_RestoresOriginalConfigAndLeavesSecretsUnchanged() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } + } + }); + WriteSecrets(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["ApiKey"] = "sk-old" } + } + }); + var originalConfig = File.ReadAllText(_paths.NetclawConfigPath); + var originalSecrets = File.ReadAllText(_paths.SecretsPath); + + Assert.Throws(() => ProviderRenamer.Rename( + _paths, + "my-vllm", + "lab-a100", + ConfigFileHelper.WriteConfigFile, + static (path, text) => AtomicFile.WriteAllText(path, text), + new ThrowingProtectSecretsProtector())); + + Assert.Equal(originalConfig, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(originalSecrets, File.ReadAllText(_paths.SecretsPath)); + AssertProviderState(_paths.NetclawConfigPath, oldNamePresent: true, newNamePresent: false); + AssertProviderState(_paths.SecretsPath, oldNamePresent: true, newNamePresent: false); + } + + [Fact] + public void Rename_SecretsWriteFailureAndRestoreFailure_ThrowsWithBothErrors() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } + } + }); + WriteSecrets(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["ApiKey"] = "sk-old" } + } + }); + + var exception = Assert.Throws(() => ProviderRenamer.Rename( + _paths, + "my-vllm", + "lab-a100", + ConfigFileHelper.WriteConfigFile, + static (_, _) => throw new InvalidOperationException("restore failed"), + new ThrowingProtectSecretsProtector())); + + var aggregate = Assert.IsType(exception.InnerException); + Assert.Contains(aggregate.InnerExceptions, ex => ex.Message.Contains("secrets write failed", StringComparison.Ordinal)); + Assert.Contains(aggregate.InnerExceptions, ex => ex.Message.Contains("restore failed", StringComparison.Ordinal)); + } + [Fact] public void Rename_CollisionCheckIsCaseInsensitive() { @@ -306,4 +466,19 @@ private void WriteSecrets(Dictionary data) File.WriteAllText(_paths.SecretsPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); } + + private static void AssertProviderState(string path, bool oldNamePresent, bool newNamePresent) + { + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var providers = document.RootElement.GetProperty("Providers"); + Assert.Equal(oldNamePresent, providers.TryGetProperty("my-vllm", out _)); + Assert.Equal(newNamePresent, providers.TryGetProperty("lab-a100", out _)); + } + + private sealed class ThrowingProtectSecretsProtector : ISecretsProtector + { + public string Protect(string plaintext) => throw new InvalidOperationException("secrets write failed"); + + public string Unprotect(string ciphertext) => ciphertext; + } } diff --git a/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs b/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs index 81aea991f..2c1c523fd 100644 --- a/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs @@ -4,9 +4,11 @@ // // ----------------------------------------------------------------------- using System.Text.Json; +using System.Text.Json.Nodes; using Netclaw.Cli.Config; using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; using Netclaw.Tests.Utilities; using Xunit; @@ -103,6 +105,54 @@ public void Save_AppliesSecretActionsAndPreservesUnrelatedSecrets() Assert.Equal("stored-slack-token", ConfigFileHelper.DecryptIfEncrypted(_paths, slackToken?.ToString())); } + [Fact] + public void Save_ReplaysSecretActionsAgainstLatestFileAndPreservesMcpTokenRefresh() + { + var protector = SecretsProtection.CreateProtector(_paths); + SecretsFileWriter.Write(_paths.SecretsPath, + """ + { + "Slack": { + "BotToken": "stored-slack-token" + } + } + """, + protector); + + var session = new ConfigEditorSession(_paths); + + SecretsFileWriter.Update( + _paths.SecretsPath, + (root, _) => + { + root["McpOAuthTokens"] = new JsonObject + { + ["memorizer"] = new JsonObject + { + ["AccessToken"] = "rotated-access-token" + } + }; + return (root, true); + }, + protector: protector, + cancellationToken: TestContext.Current.CancellationToken); + + session.Apply(new SectionContribution( + SecretActions: + [ + new SectionSecretAction("Search.BraveApiKey", SectionSecretActionKind.Set, new SensitiveString("new-brave-key")) + ])); + + session.Save(); + + var decrypted = SecretsFileWriter.DecryptJsonLeaves(File.ReadAllText(_paths.SecretsPath), protector); + using var doc = JsonDocument.Parse(decrypted); + Assert.Equal("stored-slack-token", doc.RootElement.GetProperty("Slack").GetProperty("BotToken").GetString()); + Assert.Equal("new-brave-key", doc.RootElement.GetProperty("Search").GetProperty("BraveApiKey").GetString()); + Assert.Equal("rotated-access-token", + doc.RootElement.GetProperty("McpOAuthTokens").GetProperty("memorizer").GetProperty("AccessToken").GetString()); + } + [Fact] public void Save_SecretSetNormalizesColonPathAndRemovesLiteralCollision() { diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs index d02f56f42..c23353be4 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs @@ -4,7 +4,9 @@ // // ----------------------------------------------------------------------- using System.Text.Json; +using System.Text.Json.Nodes; using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Sections; using Netclaw.Cli.Tui.Wizard; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; @@ -405,6 +407,55 @@ public void WriteSecretsFile_ExistingSection_OverwritesContributedSecretsAndPres Assert.False(root.TryGetProperty("Discord:BotToken", out _)); } + [Fact] + public void WriteSecretsFile_ReplaysWizardContributionsAgainstLatestSecrets() + { + var protector = SecretsProtection.CreateProtector(Context.Paths); + SecretsFileWriter.Write(Context.Paths.SecretsPath, + """ + { + "Slack": { + "BotToken": "stored-slack-token" + } + } + """, + protector); + + var builder = new WizardSecretsBuilder(Context.Paths); + + SecretsFileWriter.Update( + Context.Paths.SecretsPath, + (root, _) => + { + root["McpOAuthTokens"] = new JsonObject + { + ["memorizer"] = new JsonObject + { + ["AccessToken"] = "rotated-access-token" + } + }; + return (root, true); + }, + protector: protector, + cancellationToken: TestContext.Current.CancellationToken); + + builder.ApplyContribution(new SectionContribution( + SecretActions: + [ + new SectionSecretAction("Search.BraveApiKey", SectionSecretActionKind.Set, new SensitiveString("new-brave-key")) + ])); + + builder.WriteSecretsFile(); + + var decryptedJson = SecretsFileWriter.DecryptJsonLeaves(File.ReadAllText(Context.Paths.SecretsPath), protector); + using var document = JsonDocument.Parse(decryptedJson); + var root = document.RootElement; + Assert.Equal("stored-slack-token", root.GetProperty("Slack").GetProperty("BotToken").GetString()); + Assert.Equal("new-brave-key", root.GetProperty("Search").GetProperty("BraveApiKey").GetString()); + Assert.Equal("rotated-access-token", + root.GetProperty("McpOAuthTokens").GetProperty("memorizer").GetProperty("AccessToken").GetString()); + } + [Fact] public void BuildConfigDictionary_OmitsFeatureFlags_WhenFeatureSelectionsNull() { diff --git a/src/Netclaw.Cli/Config/ConfigFileHelper.cs b/src/Netclaw.Cli/Config/ConfigFileHelper.cs index 5094f53ce..07f2312a8 100644 --- a/src/Netclaw.Cli/Config/ConfigFileHelper.cs +++ b/src/Netclaw.Cli/Config/ConfigFileHelper.cs @@ -193,6 +193,51 @@ internal static void WriteSecretsFile(Configuration.NetclawPaths paths, Dictiona SecretsFileWriter.Write(paths.SecretsPath, data, options: JsonDefaults.Indented, protector: protector); } + internal static void UpdateSecretsFile( + Configuration.NetclawPaths paths, + Func, bool, bool> update, + ISecretsProtector? protector = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(update); + + UpdateSecretsFile( + paths, + (secrets, fileExisted) => (update(secrets, fileExisted), null), + protector, + cancellationToken); + } + + internal static TResult UpdateSecretsFile( + Configuration.NetclawPaths paths, + Func, bool, (bool Write, TResult Result)> update, + ISecretsProtector? protector = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(update); + + var effectiveProtector = protector ?? SecretsProtection.CreateProtector(paths); + return SecretsFileWriter.Update( + paths.SecretsPath, + (root, fileExisted) => + { + var secrets = JsonSerializer.Deserialize>( + root.ToJsonString(JsonDefaults.ConfigFile), + JsonDefaults.ConfigRead) + ?? []; + var outcome = update(secrets, fileExisted); + if (!outcome.Write) + return (null, outcome.Result); + + var updatedRoot = JsonSerializer.SerializeToNode(secrets, JsonDefaults.ConfigFile)?.AsObject() + ?? []; + return (updatedRoot, outcome.Result); + }, + JsonDefaults.Indented, + effectiveProtector, + cancellationToken); + } + internal static bool PathPresent(Dictionary root, string path) => TryGetPathValue(root, path, out _); diff --git a/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs b/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs index 50338a0c2..802f294ee 100644 --- a/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs +++ b/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs @@ -54,7 +54,7 @@ internal static void WriteProvider( paths.EnsureDirectoriesExist(); // Build provider config entry in netclaw.json - var (config, secrets) = ConfigFileHelper.LoadConfigFiles(paths); + var (config, _) = ConfigFileHelper.LoadConfigFiles(paths); var providers = ConfigFileHelper.GetOrCreateSection(config, "Providers"); var providerEntry = new Dictionary @@ -93,13 +93,15 @@ internal static void WriteProvider( } else if (!string.IsNullOrWhiteSpace(apiKey)) { - var secretProviders = ConfigFileHelper.GetOrCreateSection(secrets, "Providers"); - secretProviders[providerName] = new Dictionary + ConfigFileHelper.UpdateSecretsFile(paths, (secrets, _) => { - ["ApiKey"] = apiKey - }; - SecretsFileWriter.Write(paths.SecretsPath, secrets, - options: JsonDefaults.Indented, protector: effectiveProtector); + var secretProviders = ConfigFileHelper.GetOrCreateSection(secrets, "Providers"); + secretProviders[providerName] = new Dictionary + { + ["ApiKey"] = apiKey + }; + return true; + }, effectiveProtector); } } } diff --git a/src/Netclaw.Cli/Daemon/PairCommand.cs b/src/Netclaw.Cli/Daemon/PairCommand.cs index b0d117d4d..117d20ce2 100644 --- a/src/Netclaw.Cli/Daemon/PairCommand.cs +++ b/src/Netclaw.Cli/Daemon/PairCommand.cs @@ -72,9 +72,11 @@ public static async Task RunAsync(string[] args, NetclawPaths paths) } // Persist token to secrets.json (encrypted at rest). - var secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); - secrets["DeviceToken"] = result.Token; - ConfigFileHelper.WriteSecretsFile(paths, secrets); + ConfigFileHelper.UpdateSecretsFile(paths, (secrets, _) => + { + secrets["DeviceToken"] = result.Token; + return true; + }); // Persist the local client's preferred daemon endpoint separately from // daemon-owned netclaw.json. diff --git a/src/Netclaw.Cli/Mcp/McpCommand.cs b/src/Netclaw.Cli/Mcp/McpCommand.cs index f907499e0..d33b9dec9 100644 --- a/src/Netclaw.Cli/Mcp/McpCommand.cs +++ b/src/Netclaw.Cli/Mcp/McpCommand.cs @@ -199,7 +199,7 @@ internal static int RunAdd(string[] args, NetclawPaths paths, TextWriter writer) // Non-sensitive env vars go to netclaw.json; all env vars also go to secrets.json for security // Headers always go to secrets.json (they may contain auth tokens) - var (config, secrets) = LoadConfigFiles(paths); + var (config, _) = LoadConfigFiles(paths); var mcpServers = GetOrCreateSection(config, "McpServers"); mcpServers[serverName.Value] = SerializeEntry(entry); @@ -211,16 +211,19 @@ internal static int RunAdd(string[] args, NetclawPaths paths, TextWriter writer) // Write sensitive values to secrets.json if (envVars.Count > 0 || headers.Count > 0) { - var secretMcp = GetOrCreateSection(secrets, "McpServers"); - var serverSecrets = new Dictionary(); + UpdateSecretsFile(paths, secrets => + { + var secretMcp = GetOrCreateSection(secrets, "McpServers"); + var serverSecrets = new Dictionary(); - if (envVars.Count > 0) - serverSecrets["EnvironmentVariables"] = envVars; - if (headers.Count > 0) - serverSecrets["Headers"] = headers; + if (envVars.Count > 0) + serverSecrets["EnvironmentVariables"] = envVars; + if (headers.Count > 0) + serverSecrets["Headers"] = headers; - secretMcp[serverName.Value] = JsonSerializer.SerializeToElement(serverSecrets); - WriteSecretsFile(paths, secrets); + secretMcp[serverName.Value] = JsonSerializer.SerializeToElement(serverSecrets); + return true; + }); } writer.WriteLine($"Added MCP server '{serverName.Value}' ({transport})"); @@ -324,8 +327,7 @@ private static async Task RunAuthAsync(string[] args, NetclawPaths paths, D if (!startResponse.IsSuccessStatusCode) { - var errorBody = await startResponse.Content.ReadAsStringAsync(); - writer.WriteLine($"Error: {errorBody}"); + writer.WriteLine($"Error: {await ReadMcpErrorAsync(startResponse)}"); return 1; } @@ -422,6 +424,11 @@ private static async Task PollMcpOAuthStatusAsync( if (status is "Failed") { writer.WriteLine(); + if (statusResponse.TryGetProperty("error", out var error) + && error.ValueKind is JsonValueKind.Object + && error.TryGetProperty("error", out var message) + && !string.IsNullOrWhiteSpace(message.GetString())) + writer.WriteLine($"Error: {message.GetString()}"); return false; } } @@ -490,7 +497,7 @@ internal static async Task ReadPasteRedirectAsync( if (response.IsSuccessStatusCode) return true; - writer.WriteLine("Redirect URL was rejected. Paste the latest redirect URL or wait for automatic completion."); + writer.WriteLine($"Redirect URL was rejected: {await ReadMcpErrorAsync(response)}"); } catch (OperationCanceledException) { @@ -505,6 +512,37 @@ internal static async Task ReadPasteRedirectAsync( return false; } + internal static async Task ReadMcpErrorAsync(HttpResponseMessage response) + { + try + { + var body = await response.Content.ReadAsStringAsync(); + if (!string.IsNullOrWhiteSpace(body)) + { + using var document = JsonDocument.Parse(body); + if (document.RootElement.ValueKind is JsonValueKind.Object + && document.RootElement.TryGetProperty("error", out var error) + && error.ValueKind is JsonValueKind.String + && !string.IsNullOrWhiteSpace(error.GetString())) + return error.GetString()!; + } + } + catch (JsonException) + { + return FormatHttpError(response); + } + + return FormatHttpError(response); + } + + private static string FormatHttpError(HttpResponseMessage response) + { + var reason = string.IsNullOrWhiteSpace(response.ReasonPhrase) + ? response.StatusCode.ToString() + : response.ReasonPhrase; + return $"HTTP {(int)response.StatusCode} {reason}"; + } + private static async Task WaitUntilCancelledAsync(CancellationToken ct) { try @@ -686,7 +724,7 @@ private static int RunRemove(string[] args, NetclawPaths paths, TextWriter write } var serverName = new McpServerName(args[2]); - var (config, secrets) = LoadConfigFiles(paths); + var (config, _) = LoadConfigFiles(paths); var removed = false; var mcpServers = GetSectionOrNull(config, "McpServers"); @@ -696,12 +734,13 @@ private static int RunRemove(string[] args, NetclawPaths paths, TextWriter write removed = true; } - var secretMcp = GetSectionOrNull(secrets, "McpServers"); - if (secretMcp?.Remove(serverName.Value) == true) + var removedSecrets = ConfigFileHelper.UpdateSecretsFile(paths, (secrets, _) => { - WriteSecretsFile(paths, secrets); - removed = true; - } + var secretMcp = GetSectionOrNull(secrets, "McpServers"); + var removedSecret = secretMcp?.Remove(serverName.Value) == true; + return (removedSecret, removedSecret); + }); + removed |= removedSecrets; if (removed) { @@ -776,7 +815,10 @@ internal static async Task ProbeServerAsync( } catch (HttpRequestException ex) { - return new McpProbeResult(McpProbeStatus.Unreachable, 0, ex.Message); + var status = ex.StatusCode is null + ? "connection failed" + : $"HTTP {(int)ex.StatusCode.Value} {ex.StatusCode.Value}"; + return new McpProbeResult(McpProbeStatus.Unreachable, 0, status); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { @@ -784,7 +826,7 @@ internal static async Task ProbeServerAsync( } catch (Exception ex) when (ex is IOException or SocketException) { - return new McpProbeResult(McpProbeStatus.Unreachable, 0, ex.Message); + return new McpProbeResult(McpProbeStatus.Unreachable, 0, "connection failed"); } } @@ -849,8 +891,8 @@ private static Dictionary GetOrCreateSection( private static void WriteConfigFile(string path, Dictionary data) => ConfigFileHelper.WriteConfigFile(path, data); - private static void WriteSecretsFile(NetclawPaths paths, Dictionary data) - => ConfigFileHelper.WriteSecretsFile(paths, data); + private static void UpdateSecretsFile(NetclawPaths paths, Func, bool> update) + => ConfigFileHelper.UpdateSecretsFile(paths, (secrets, _) => update(secrets)); internal static Dictionary LoadMcpServers(NetclawPaths paths) { diff --git a/src/Netclaw.Cli/Provider/ProviderCommand.cs b/src/Netclaw.Cli/Provider/ProviderCommand.cs index 08be43d31..2531c2c90 100644 --- a/src/Netclaw.Cli/Provider/ProviderCommand.cs +++ b/src/Netclaw.Cli/Provider/ProviderCommand.cs @@ -428,7 +428,7 @@ private static int RunRemove(string[] args, NetclawPaths paths, TextWriter write return 1; } - var (config, secrets) = ConfigFileHelper.LoadConfigFiles(paths); + var (config, _) = ConfigFileHelper.LoadConfigFiles(paths); var removed = false; var providers = ConfigFileHelper.GetSectionOrNull(config, "Providers"); @@ -438,12 +438,13 @@ private static int RunRemove(string[] args, NetclawPaths paths, TextWriter write removed = true; } - var secretProviders = ConfigFileHelper.GetSectionOrNull(secrets, "Providers"); - if (secretProviders?.Remove(name) == true) + var removedSecrets = ConfigFileHelper.UpdateSecretsFile(paths, (secrets, _) => { - ConfigFileHelper.WriteSecretsFile(paths, secrets); - removed = true; - } + var secretProviders = ConfigFileHelper.GetSectionOrNull(secrets, "Providers"); + var removedSecret = secretProviders?.Remove(name) == true; + return (removedSecret, removedSecret); + }); + removed |= removedSecrets; if (removed) { diff --git a/src/Netclaw.Cli/Provider/ProviderRenamer.cs b/src/Netclaw.Cli/Provider/ProviderRenamer.cs index ed0620763..624278b3a 100644 --- a/src/Netclaw.Cli/Provider/ProviderRenamer.cs +++ b/src/Netclaw.Cli/Provider/ProviderRenamer.cs @@ -3,9 +3,11 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Runtime.ExceptionServices; using System.Text.Json; using Netclaw.Cli.Config; using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; namespace Netclaw.Cli.Provider; @@ -49,17 +51,34 @@ internal static class ProviderRenamer /// /// public static RenameResult Rename(NetclawPaths paths, string oldName, string newName) + => Rename( + paths, + oldName, + newName, + ConfigFileHelper.WriteConfigFile, + static (path, text) => AtomicFile.WriteAllText(path, text), + secretsProtector: null); + + internal static RenameResult Rename( + NetclawPaths paths, + string oldName, + string newName, + Action> writeConfigFile, + Action restoreConfigFile, + ISecretsProtector? secretsProtector) { var trimmed = newName?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(trimmed)) return RenameResult.Fail("Provider name cannot be empty."); - var (config, secrets) = ConfigFileHelper.LoadConfigFiles(paths); + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); var providers = ConfigFileHelper.GetSectionOrNull(config, "Providers"); if (providers is null || !providers.ContainsKey(oldName)) return RenameResult.Fail($"Provider '{oldName}' not found."); + var originalConfigText = File.ReadAllText(paths.NetclawConfigPath); + // Collision check: walk both config and secrets dictionaries. A key // that case-insensitive-equals oldName is the entry we're renaming and // is not a collision. Any other key that case-insensitive-equals the @@ -67,28 +86,82 @@ public static RenameResult Rename(NetclawPaths paths, string oldName, string new if (HasCollision(providers, oldName, trimmed)) return RenameResult.Fail($"A provider named '{trimmed}' already exists."); - var secretProviders = ConfigFileHelper.GetSectionOrNull(secrets, "Providers"); - if (secretProviders is not null && HasCollision(secretProviders, oldName, trimmed)) - return RenameResult.Fail($"A provider named '{trimmed}' already exists in secrets."); - var entry = providers[oldName]; providers.Remove(oldName); providers[trimmed] = entry; var reassigned = CascadeRenameModelRoles(config, oldName, trimmed); + var configWritten = false; - ConfigFileHelper.WriteConfigFile(paths.NetclawConfigPath, config); - - if (secretProviders is not null && secretProviders.TryGetValue(oldName, out var secretEntry)) + RenameSecretsResult secretsResult; + try + { + secretsResult = ConfigFileHelper.UpdateSecretsFile(paths, (latestSecrets, _) => + { + var latestSecretProviders = ConfigFileHelper.GetSectionOrNull(latestSecrets, "Providers"); + if (latestSecretProviders is null) + { + writeConfigFile(paths.NetclawConfigPath, config); + configWritten = true; + return (false, RenameSecretsResult.NoOldName); + } + + if (HasCollision(latestSecretProviders, oldName, trimmed)) + return (false, RenameSecretsResult.Collision); + + if (!latestSecretProviders.TryGetValue(oldName, out var secretEntry)) + { + writeConfigFile(paths.NetclawConfigPath, config); + configWritten = true; + return (false, RenameSecretsResult.NoOldName); + } + + writeConfigFile(paths.NetclawConfigPath, config); + configWritten = true; + latestSecretProviders.Remove(oldName); + latestSecretProviders[trimmed] = secretEntry; + return (true, RenameSecretsResult.Renamed); + }, secretsProtector); + } + catch (Exception ex) when (configWritten) { - secretProviders.Remove(oldName); - secretProviders[trimmed] = secretEntry; - ConfigFileHelper.WriteSecretsFile(paths, secrets); + RestoreOriginalConfigAndThrow(paths.NetclawConfigPath, originalConfigText, restoreConfigFile, ex); + throw; } + if (secretsResult is RenameSecretsResult.Collision) + return RenameResult.Fail($"A provider named '{trimmed}' already exists in secrets."); + return RenameResult.Ok(reassigned); } + private static void RestoreOriginalConfigAndThrow( + string configPath, + string originalConfigText, + Action restoreConfigFile, + Exception renameException) + { + try + { + restoreConfigFile(configPath, originalConfigText); + } + catch (Exception restoreException) + { + throw new InvalidOperationException( + "Provider rename failed after writing netclaw.json, then failed to restore the original netclaw.json.", + new AggregateException(renameException, restoreException)); + } + + ExceptionDispatchInfo.Capture(renameException).Throw(); + } + + private enum RenameSecretsResult + { + NoOldName, + Renamed, + Collision, + } + private static List CascadeRenameModelRoles( Dictionary config, string oldName, string newName) { diff --git a/src/Netclaw.Cli/Secrets/SecretsCommand.cs b/src/Netclaw.Cli/Secrets/SecretsCommand.cs index 2d8d168ea..243b9b0c3 100644 --- a/src/Netclaw.Cli/Secrets/SecretsCommand.cs +++ b/src/Netclaw.Cli/Secrets/SecretsCommand.cs @@ -51,18 +51,6 @@ private static int RunSet(string[] args, NetclawPaths paths, TextWriter writer) var keyPath = args[2]; var value = args[3]; - // Load existing secrets or start fresh - JsonObject root; - if (File.Exists(paths.SecretsPath)) - { - var existing = File.ReadAllText(paths.SecretsPath); - root = JsonNode.Parse(existing)?.AsObject() ?? []; - } - else - { - root = []; - } - string[] segments; try { @@ -74,12 +62,16 @@ private static int RunSet(string[] args, NetclawPaths paths, TextWriter writer) return 1; } - SecretsJsonUpdater.UpsertValue(root, segments, value); - - // Write with encryption — the protector encrypts all plaintext leaves var protector = SecretsProtection.CreateProtector(paths); - var json = root.ToJsonString(JsonDefaults.Indented); - SecretsFileWriter.Write(paths.SecretsPath, json, protector); + SecretsFileWriter.Update( + paths.SecretsPath, + (root, _) => + { + SecretsJsonUpdater.UpsertValue(root, segments, value); + return (root, true); + }, + JsonDefaults.Indented, + protector); writer.WriteLine($"Set {string.Join('.', segments)} (encrypted)."); return 0; diff --git a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs index a39c60fd5..d9db3cbe1 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs @@ -692,13 +692,15 @@ private void WriteFixedCredentials() if (!string.IsNullOrWhiteSpace(FixApiKey)) { - var (_, secrets) = ConfigFileHelper.LoadConfigFiles(_paths); - var secretProviders = ConfigFileHelper.GetOrCreateSection(secrets, "Providers"); - secretProviders[name] = new Dictionary + ConfigFileHelper.UpdateSecretsFile(_paths, (secrets, _) => { - ["ApiKey"] = FixApiKey - }; - ConfigFileHelper.WriteSecretsFile(_paths, secrets); + var secretProviders = ConfigFileHelper.GetOrCreateSection(secrets, "Providers"); + secretProviders[name] = new Dictionary + { + ["ApiKey"] = FixApiKey + }; + return true; + }); } if (FixEndpoint is not null && DetailProvider.Entry is not null @@ -757,15 +759,17 @@ public void ConfirmRemove() return; } - var (config, secrets) = ConfigFileHelper.LoadConfigFiles(_paths); + var (config, _) = ConfigFileHelper.LoadConfigFiles(_paths); var providers = ConfigFileHelper.GetSectionOrNull(config, "Providers"); if (providers?.Remove(RemoveProviderName) == true) ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); - var secretProviders = ConfigFileHelper.GetSectionOrNull(secrets, "Providers"); - if (secretProviders?.Remove(RemoveProviderName) == true) - ConfigFileHelper.WriteSecretsFile(_paths, secrets); + ConfigFileHelper.UpdateSecretsFile(_paths, (secrets, _) => + { + var secretProviders = ConfigFileHelper.GetSectionOrNull(secrets, "Providers"); + return secretProviders?.Remove(RemoveProviderName) == true; + }); StatusMessage.Value = $"Removed provider '{RemoveProviderName}'. Restart daemon for changes to take effect."; RemoveProviderName = null; diff --git a/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs b/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs index 4a41c744d..7d6ae1ab3 100644 --- a/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs +++ b/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs @@ -18,14 +18,12 @@ internal sealed class ConfigEditorSession { private readonly NetclawPaths _paths; private readonly ConfigEditorStateStore _stateStore; - private readonly bool _secretsFileExists; - private bool _secretsChanged; + private readonly List _secretContributions = []; public ConfigEditorSession(NetclawPaths paths) { _paths = paths; _stateStore = new ConfigEditorStateStore(paths); - _secretsFileExists = File.Exists(paths.SecretsPath); Config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); Secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); } @@ -37,7 +35,9 @@ public ConfigEditorSession(NetclawPaths paths) public void Apply(SectionContribution contribution) { ApplyFieldActions(Config, contribution); - _secretsChanged |= ApplySecretActions(Secrets, contribution); + ApplySecretActions(Secrets, contribution); + if (HasMutatingSecretActions(contribution)) + _secretContributions.Add(contribution); _stateStore.Apply(contribution.StateActionsOrEmpty); } @@ -47,8 +47,17 @@ public void Save() Config["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, Config); - if (_secretsChanged && (_secretsFileExists || HasUserSecretData(Secrets))) - ConfigFileHelper.WriteSecretsFile(_paths, Secrets); + if (_secretContributions.Count > 0) + { + ConfigFileHelper.UpdateSecretsFile(_paths, (secrets, fileExisted) => + { + var changed = false; + foreach (var contribution in _secretContributions) + changed |= ApplySecretActions(secrets, contribution); + + return changed && (fileExisted || HasUserSecretData(secrets)); + }); + } } internal static bool ApplyFieldActions(Dictionary config, SectionContribution contribution) @@ -105,6 +114,9 @@ internal static void ApplyEditorStateActions( private static bool HasUserSecretData(Dictionary secrets) => secrets.Keys.Any(static key => !string.Equals(key, "configVersion", StringComparison.Ordinal)); + private static bool HasMutatingSecretActions(SectionContribution contribution) + => contribution.SecretActionsOrEmpty.Any(static action => action.Action is not SectionSecretActionKind.Preserve); + // Mirrors SecretsJsonUpdater's path-merge (colon-collision cleanup + nested upsert), but over the // Dictionary shape ConfigFileHelper loads rather than a JsonObject. The two share // ParseKeyPath; keep the collision cleanup below in sync with diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs index 148f2a85e..3b025a738 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs @@ -359,12 +359,12 @@ public void EnsureCurrentClientPaired(NetclawPaths paths) private static void WriteLocalDeviceTokenValue(NetclawPaths paths, string rawToken) { - var secrets = File.Exists(paths.SecretsPath) - ? ConfigFileHelper.LoadJsonDict(paths.SecretsPath) - : new Dictionary(); - secrets["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; - secrets["DeviceToken"] = rawToken; - ConfigFileHelper.WriteSecretsFile(paths, secrets); + ConfigFileHelper.UpdateSecretsFile(paths, (secrets, _) => + { + secrets["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + secrets["DeviceToken"] = rawToken; + return true; + }); } private static List ReadPairedDevices(NetclawPaths paths) diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs index bac870f88..54fa29610 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs @@ -493,15 +493,11 @@ public sealed class WizardSecretsBuilder { private readonly NetclawPaths _paths; private readonly Dictionary _secrets = []; - private readonly Dictionary _existingSecrets; private readonly List _sectionContributions = []; - private readonly bool _secretsFileExists; public WizardSecretsBuilder(NetclawPaths paths) { _paths = paths; - _secretsFileExists = File.Exists(paths.SecretsPath); - _existingSecrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); } internal NetclawPaths Paths => _paths; @@ -522,40 +518,45 @@ public void WriteSecretsFile() if (!hasDirectSecrets && _sectionContributions.Count == 0) return; - var merged = _existingSecrets.Count == 0 - ? new Dictionary() - : new Dictionary(_existingSecrets, StringComparer.Ordinal); + // Encrypt with the protector for THIS config's keys directory (the same one the + // read/decrypt path derives) rather than the process-wide SensitiveStringTypeConverter + // .Protector static — that global is an ambient hook for the framework-instantiated + // converters only; reaching for it here as a service locator is what let a parallel test + // leak a foreign protector and break the encrypt/decrypt round-trip. + SecretsFileWriter.Update( + _paths.SecretsPath, + (latestRoot, fileExisted) => + { + var latest = JsonSerializer.Deserialize>( + latestRoot.ToJsonString(JsonDefaults.ConfigFile), + JsonDefaults.ConfigRead) + ?? []; - var contributionChanged = false; - foreach (var contribution in _sectionContributions) - contributionChanged |= ConfigEditorSession.ApplySecretActions(merged, contribution); + var contributionChanged = false; + foreach (var contribution in _sectionContributions) + contributionChanged |= ConfigEditorSession.ApplySecretActions(latest, contribution); - if (!hasDirectSecrets && !contributionChanged) - return; + if (!hasDirectSecrets && !contributionChanged) + return (null, false); - var existingNode = JsonSerializer.SerializeToNode(merged, JsonDefaults.ConfigFile)?.AsObject() - ?? []; + var mergedRoot = JsonSerializer.SerializeToNode(latest, JsonDefaults.ConfigFile)?.AsObject() + ?? []; - foreach (var (key, value) in _secrets) - { - var segments = SecretsJsonUpdater.ParseKeyPath(key); - var node = JsonSerializer.SerializeToNode(value, JsonDefaults.ConfigFile); - if (node is JsonObject obj) - SecretsJsonUpdater.MergeObject(existingNode, segments, obj); - else - SecretsJsonUpdater.UpsertNode(existingNode, segments, node); - } + foreach (var (key, value) in _secrets) + { + var segments = SecretsJsonUpdater.ParseKeyPath(key); + var node = JsonSerializer.SerializeToNode(value, JsonDefaults.ConfigFile); + if (node is JsonObject obj) + SecretsJsonUpdater.MergeObject(mergedRoot, segments, obj); + else + SecretsJsonUpdater.UpsertNode(mergedRoot, segments, node); + } - if (hasDirectSecrets || contributionChanged && (_secretsFileExists || HasUserSecretData(merged))) - { - // Encrypt with the protector for THIS config's keys directory (the same one the - // read/decrypt path derives) rather than the process-wide SensitiveStringTypeConverter - // .Protector static — that global is an ambient hook for the framework-instantiated - // converters only; reaching for it here as a service locator is what let a parallel test - // leak a foreign protector and break the encrypt/decrypt round-trip. - SecretsFileWriter.Write(_paths.SecretsPath, existingNode.ToJsonString(JsonDefaults.ConfigFile), - protector: SecretsProtection.CreateProtector(_paths)); - } + var shouldWrite = hasDirectSecrets || contributionChanged && (fileExisted || HasUserSecretData(latest)); + return shouldWrite ? (mergedRoot, true) : (null, false); + }, + JsonDefaults.ConfigFile, + SecretsProtection.CreateProtector(_paths)); } internal void ApplyContribution(SectionContribution contribution) diff --git a/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj b/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj index 762769752..ef129857d 100644 --- a/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj +++ b/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj @@ -28,6 +28,8 @@ + diff --git a/src/Netclaw.Configuration.Tests/SecretsFileWriterTests.cs b/src/Netclaw.Configuration.Tests/SecretsFileWriterTests.cs index b2ad969e1..874246d81 100644 --- a/src/Netclaw.Configuration.Tests/SecretsFileWriterTests.cs +++ b/src/Netclaw.Configuration.Tests/SecretsFileWriterTests.cs @@ -3,7 +3,10 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Diagnostics; using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; using Netclaw.Configuration.Secrets; using Netclaw.Tests.Utilities; using Xunit; @@ -146,4 +149,224 @@ public void CountEncryptionStatus_empty_json_returns_zero() Assert.Equal(0, encrypted); Assert.Equal(0, plaintext); } + + [Fact] + public async Task Update_serializes_cross_section_mutations_and_second_writer_observes_committed_state() + { + var paths = new NetclawPaths(_dir.Path); + paths.EnsureDirectoriesExist(); + var protector = SecretsProtection.CreateProtector(paths); + SecretsFileWriter.Write(_secretsPath, + """ + { + "Slack": { + "BotToken": "xoxb-existing" + } + } + """, + protector); + + var secondSecretsPath = _secretsPath; + string? aliasPath = null; + if (!OperatingSystem.IsWindows()) + { + aliasPath = $"{_dir.Path}-alias"; + Directory.CreateSymbolicLink(aliasPath, _dir.Path); + secondSecretsPath = Path.Combine(aliasPath, "secrets.json"); + } + + using var firstEntered = new ManualResetEventSlim(); + using var secondStarted = new ManualResetEventSlim(); + using var releaseFirst = new ManualResetEventSlim(); + var cancellationToken = TestContext.Current.CancellationToken; + string? secondObservedBraveKey = null; + + try + { + var first = Task.Run(() => SecretsFileWriter.Update( + _secretsPath, + (root, _) => + { + firstEntered.Set(); + Assert.True(secondStarted.Wait(TimeSpan.FromSeconds(10), cancellationToken)); + Assert.True(releaseFirst.Wait(TimeSpan.FromSeconds(10), cancellationToken)); + + root["Search"] = new JsonObject + { + ["BraveApiKey"] = "brave-key" + }; + return (root, true); + }, + protector: protector, + cancellationToken: cancellationToken), cancellationToken); + + Assert.True(firstEntered.Wait(TimeSpan.FromSeconds(10), cancellationToken)); + + var second = Task.Run(() => + { + secondStarted.Set(); + return SecretsFileWriter.Update( + secondSecretsPath, + (root, _) => + { + secondObservedBraveKey = root["Search"]?["BraveApiKey"]?.GetValue(); + root["McpOAuthTokens"] = new JsonObject + { + ["memorizer"] = new JsonObject + { + ["AccessToken"] = "rotated-access-token" + } + }; + return (root, true); + }, + protector: protector, + cancellationToken: cancellationToken); + }, cancellationToken); + + Assert.True(secondStarted.Wait(TimeSpan.FromSeconds(10), cancellationToken)); + releaseFirst.Set(); + await Task.WhenAll(first, second); + + Assert.Equal("brave-key", secondObservedBraveKey); + + var decrypted = SecretsFileWriter.DecryptJsonLeaves(File.ReadAllText(_secretsPath), protector); + using var doc = JsonDocument.Parse(decrypted); + Assert.Equal("xoxb-existing", doc.RootElement.GetProperty("Slack").GetProperty("BotToken").GetString()); + Assert.Equal("brave-key", doc.RootElement.GetProperty("Search").GetProperty("BraveApiKey").GetString()); + Assert.Equal("rotated-access-token", + doc.RootElement.GetProperty("McpOAuthTokens").GetProperty("memorizer").GetProperty("AccessToken").GetString()); + Assert.DoesNotContain("xoxb-existing", File.ReadAllText(_secretsPath), StringComparison.Ordinal); + Assert.DoesNotContain("rotated-access-token", File.ReadAllText(_secretsPath), StringComparison.Ordinal); + } + finally + { + releaseFirst.Set(); + if (aliasPath is not null) + Directory.Delete(aliasPath); + } + } + + [Fact] + public async Task Update_serializes_cross_process_mutations_and_observes_committed_state() + { + SecretsFileWriter.Write(_secretsPath, """{"Initial":"keep"}"""); + using var child = StartSecretsLockProbe(_secretsPath, "from-child"); + var cancellationToken = TestContext.Current.CancellationToken; + + Assert.Equal("entered", await ReadRequiredStdoutLineAsync(child, cancellationToken)); + + string? parentObservedChild = null; + using var parentStarted = new ManualResetEventSlim(); + var parent = Task.Run(() => + { + parentStarted.Set(); + return SecretsFileWriter.Update( + _secretsPath, + (root, _) => + { + parentObservedChild = root["Child"]?.GetValue(); + root["Parent"] = "from-parent"; + return (root, true); + }, + cancellationToken: cancellationToken); + }, cancellationToken); + + Assert.True(parentStarted.Wait(TimeSpan.FromSeconds(10), cancellationToken)); + await child.StandardInput.WriteLineAsync("release"); + await child.StandardInput.FlushAsync(cancellationToken); + + Assert.Equal("committed", await ReadRequiredStdoutLineAsync(child, cancellationToken)); + await child.WaitForExitAsync(cancellationToken); + Assert.Equal(0, child.ExitCode); + Assert.True(await parent.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken)); + + Assert.Equal("from-child", parentObservedChild); + using var doc = JsonDocument.Parse(File.ReadAllText(_secretsPath)); + Assert.Equal("keep", doc.RootElement.GetProperty("Initial").GetString()); + Assert.Equal("from-child", doc.RootElement.GetProperty("Child").GetString()); + Assert.Equal("from-parent", doc.RootElement.GetProperty("Parent").GetString()); + } + + [Fact] + public void Update_when_file_replacement_fails_propagates_and_keeps_previous_content() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + SecretsFileWriter.Write(_secretsPath, """{"Existing":"keep"}"""); + var before = File.ReadAllText(_secretsPath); + var originalMode = File.GetUnixFileMode(_dir.Path); + + try + { + File.SetUnixFileMode(_dir.Path, UnixFileMode.UserRead | UnixFileMode.UserExecute); + + Assert.ThrowsAny(() => SecretsFileWriter.Update( + _secretsPath, + (root, _) => + { + root["Existing"] = "lost"; + return (root, true); + }, + cancellationToken: TestContext.Current.CancellationToken)); + } + finally + { + File.SetUnixFileMode(_dir.Path, originalMode); + } + + Assert.Equal(before, File.ReadAllText(_secretsPath)); + } + + private static Process StartSecretsLockProbe(string secretsPath, string childValue) + { + var info = new ProcessStartInfo("dotnet") + { + RedirectStandardError = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + info.ArgumentList.Add(LocateSecretsLockProbeDll()); + info.ArgumentList.Add(secretsPath); + info.ArgumentList.Add(childValue); + + return Process.Start(info) + ?? throw new InvalidOperationException("Failed to start secrets lock probe."); + } + + private static async Task ReadRequiredStdoutLineAsync(Process process, CancellationToken cancellationToken) + { + var line = await process.StandardOutput + .ReadLineAsync(cancellationToken) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30), cancellationToken); + + if (line is not null) + return line; + + var stderr = await process.StandardError.ReadToEndAsync(cancellationToken); + throw new InvalidOperationException($"Secrets lock probe exited before writing a line. stderr: {stderr}"); + } + + private static string LocateSecretsLockProbeDll() + { + var repo = new DirectoryInfo(AppContext.BaseDirectory); + while (repo is not null && !File.Exists(Path.Combine(repo.FullName, "Netclaw.slnx"))) + repo = repo.Parent; + Assert.NotNull(repo); + + var projectDir = Path.Combine(repo!.FullName, "tests", "Netclaw.SecretsLockProbe"); + var binMarker = $"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"; + var dll = Directory + .EnumerateFiles(projectDir, "Netclaw.SecretsLockProbe.dll", SearchOption.AllDirectories) + .Where(p => p.Contains(binMarker, StringComparison.Ordinal)) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + + Assert.True(dll is not null, + $"Netclaw.SecretsLockProbe.dll not found under {projectDir}/bin - is the project built?"); + return dll!; + } } diff --git a/src/Netclaw.Configuration/McpOAuthServerMetadata.cs b/src/Netclaw.Configuration/McpOAuthServerMetadata.cs deleted file mode 100644 index 1b19f3965..000000000 --- a/src/Netclaw.Configuration/McpOAuthServerMetadata.cs +++ /dev/null @@ -1,35 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -namespace Netclaw.Configuration; - -/// -/// Cached OAuth authorization server discovery result for an MCP server. -/// Serialized into mcp-oauth-metadata.json as a -/// Dictionary<string, McpOAuthServerMetadata> keyed by server name. -/// -public sealed class McpOAuthServerMetadata -{ - /// The MCP server URL this metadata was discovered from. - public string McpServerUrl { get; set; } = null!; - - /// OAuth authorization endpoint. - public string AuthorizationEndpoint { get; set; } = null!; - - /// OAuth token endpoint. - public string TokenEndpoint { get; set; } = null!; - - /// RFC 7591 dynamic client registration endpoint (optional). - public string? RegistrationEndpoint { get; set; } - - /// RFC 8707 resource indicator for the MCP server. - public string? ResourceIndicator { get; set; } - - /// Resolved client ID (from DCR or static config). - public string? ClientId { get; set; } - - /// When this metadata was cached. - public DateTimeOffset CachedAt { get; set; } -} diff --git a/src/Netclaw.Configuration/McpOAuthTokenSet.cs b/src/Netclaw.Configuration/McpOAuthTokenSet.cs index f65499e14..eecbd294d 100644 --- a/src/Netclaw.Configuration/McpOAuthTokenSet.cs +++ b/src/Netclaw.Configuration/McpOAuthTokenSet.cs @@ -6,8 +6,7 @@ namespace Netclaw.Configuration; /// -/// Per-MCP-server OAuth token storage. Serialized into mcp-oauth-tokens.json -/// as a Dictionary<string, McpOAuthTokenSet> keyed by server name. +/// Durable OAuth credentials for one configured MCP resource. /// public sealed class McpOAuthTokenSet { @@ -23,9 +22,60 @@ public sealed class McpOAuthTokenSet [ConfigValue(Key = "ExpiresAt", PersistTo = ConfigPersistStore.McpOAuthTokens)] public DateTimeOffset? ExpiresAt { get; set; } + /// Token type supplied by the authorization server. + public string TokenType { get; set; } = "Bearer"; + + /// Granted scope supplied by the authorization server. + public string? Scope { get; set; } + + /// When the SDK obtained this token set. + public DateTimeOffset ObtainedAt { get; set; } + /// Resolved client ID (from DCR or static config). public string? ClientId { get; set; } - /// Canonical resource URI for RFC 8707 resource indicators. + /// DCR-issued client secret, when one was issued. + public SensitiveString? ClientSecret { get; set; } + + /// Whether the stored client identity came from dynamic registration. + public bool DynamicClientRegistration { get; set; } + + /// + /// Legacy resource field. It is retained for deserialization only and is not + /// accepted as the security binding for cached credentials. + /// public string? McpServerUrl { get; set; } + + /// Canonical configured endpoint identity bound to these credentials. + public string? ResourceIdentity { get; set; } + + /// + /// Ownership epoch used to reject writes from retired connections and stale processes. + /// + public string? CredentialEpoch { get; set; } +} + +/// Flow-scoped credentials that are not yet active. +public sealed class McpOAuthPendingCredential +{ + public string FlowId { get; set; } = null!; + + public DateTimeOffset ExpiresAt { get; set; } + + public McpOAuthTokenSet Credentials { get; set; } = null!; +} + +/// Per-server durable active and pending OAuth state. +public sealed class McpOAuthCredentialEnvelope +{ + public McpOAuthTokenSet? Active { get; set; } + + public McpOAuthPendingCredential? Pending { get; set; } + + /// + /// A dynamic identity rejected as invalid_client. Explicit flows continue + /// withholding it until another dynamic identity is captured or a + /// replacement credential set publishes. + /// + public string? RejectedDynamicClientId { get; set; } } diff --git a/src/Netclaw.Configuration/NetclawPaths.cs b/src/Netclaw.Configuration/NetclawPaths.cs index 02f434c2a..728c5722a 100644 --- a/src/Netclaw.Configuration/NetclawPaths.cs +++ b/src/Netclaw.Configuration/NetclawPaths.cs @@ -123,7 +123,6 @@ public string ServerFeedAgentSyncStatePath(string feedName) public string PidFilePath => Path.Combine(BasePath, "netclaw.pid"); public string LockFilePath => Path.Combine(BasePath, "netclaw.lock"); public string SqliteDbPath => Path.Combine(BasePath, "netclaw.db"); - public string McpOAuthMetadataPath => Path.Combine(ConfigDirectory, "mcp-oauth-metadata.json"); public string KeysDirectory => Path.Combine(BasePath, "keys"); public NetclawPaths(string? basePath = null, string? workspacesDirectory = null) diff --git a/src/Netclaw.Configuration/SecretsFileWriter.cs b/src/Netclaw.Configuration/SecretsFileWriter.cs index 699e4d2e7..0fc29dad6 100644 --- a/src/Netclaw.Configuration/SecretsFileWriter.cs +++ b/src/Netclaw.Configuration/SecretsFileWriter.cs @@ -3,6 +3,9 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Netclaw.Configuration.Secrets; @@ -18,6 +21,9 @@ namespace Netclaw.Configuration; /// public static class SecretsFileWriter { + private static readonly TimeSpan SecretsLockTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan SecretsLockPollInterval = TimeSpan.FromMilliseconds(50); + private static readonly JsonSerializerOptions DefaultJsonOptions = new() { WriteIndented = true, @@ -28,6 +34,49 @@ public static class SecretsFileWriter /// On Linux/macOS, the file is set to owner-only read/write (chmod 600). /// public static void Write(string secretsPath, string json, ISecretsProtector? protector = null) + { + using var secretsLock = AcquireSecretsLock(secretsPath, CancellationToken.None); + WriteUnlocked(secretsPath, json, protector); + } + + /// + /// Read the latest secrets document and replace it while holding a path-scoped interprocess lock. + /// Returning a null updated root leaves the file unchanged. + /// + public static TResult Update( + string secretsPath, + Func update, + JsonSerializerOptions? options = null, + ISecretsProtector? protector = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(update); + + using var secretsLock = AcquireSecretsLock(secretsPath, cancellationToken); + var fileExists = File.Exists(secretsPath); + var root = fileExists + ? ReadUnlocked(secretsPath, protector) + : []; + + var outcome = update(root, fileExists); + if (outcome.UpdatedRoot is not null) + WriteUnlocked(secretsPath, outcome.UpdatedRoot.ToJsonString(options ?? DefaultJsonOptions), protector); + + return outcome.Result; + } + + private static JsonObject ReadUnlocked(string secretsPath, ISecretsProtector? protector) + { + var json = File.ReadAllText(secretsPath); + if (protector is not null) + json = DecryptJsonLeaves(json, protector); + + var node = JsonNode.Parse(json); + return node as JsonObject + ?? throw new InvalidDataException($"Secrets file '{secretsPath}' must contain a JSON object."); + } + + private static void WriteUnlocked(string secretsPath, string json, ISecretsProtector? protector) { if (protector is not null) json = EncryptJsonLeaves(json, protector); @@ -207,4 +256,118 @@ private static void CountNode(JsonNode node, ref int encrypted, ref int plaintex /// Set owner-only permissions (chmod 600) on Unix. No-op on Windows. /// internal static void SetOwnerOnlyPermissions(string path) => AtomicFile.HardenOwnerOnly(path); + + private static IDisposable AcquireSecretsLock(string secretsPath, CancellationToken cancellationToken) + { + var canonicalPath = GetCanonicalLockPath(secretsPath); + var lockIdentity = OperatingSystem.IsWindows() + ? canonicalPath.ToUpperInvariant() + : canonicalPath; + var lockId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(lockIdentity))); + var lockScope = OperatingSystem.IsWindows() ? @"Global\" : string.Empty; + var mutex = new Mutex(initiallyOwned: false, $"{lockScope}netclaw-secrets-file-{lockId}"); + var ownsMutex = false; + + try + { + try + { + ownsMutex = WaitForMutex(mutex, cancellationToken); + } + catch (AbandonedMutexException) + { + ownsMutex = true; + // A killed writer may leave only a sibling temp file; the destination remains atomic. + DeleteAbandonedTempFiles(canonicalPath); + } + + if (!ownsMutex) + throw new TimeoutException($"Timed out waiting to update secrets file '{secretsPath}'."); + + return new SecretsLock(mutex); + } + catch + { + if (ownsMutex) + mutex.ReleaseMutex(); + mutex.Dispose(); + throw; + } + } + + private static bool WaitForMutex(Mutex mutex, CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + return mutex.WaitOne(SecretsLockTimeout); + + var startedAt = Stopwatch.GetTimestamp(); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (mutex.WaitOne(TimeSpan.Zero)) + return true; + + var remaining = SecretsLockTimeout - Stopwatch.GetElapsedTime(startedAt); + if (remaining <= TimeSpan.Zero) + return false; + + var wait = remaining < SecretsLockPollInterval ? remaining : SecretsLockPollInterval; + if (cancellationToken.WaitHandle.WaitOne(wait)) + cancellationToken.ThrowIfCancellationRequested(); + } + } + + private static string GetCanonicalLockPath(string filePath) + { + var fullPath = Path.GetFullPath(filePath); + var directoryPath = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("Secrets path has no parent directory."); + return Path.Combine(GetCanonicalDirectoryPath(directoryPath), Path.GetFileName(fullPath)); + } + + private static string GetCanonicalDirectoryPath(string directoryPath) + { + var fullPath = Path.GetFullPath(directoryPath); + var root = Path.GetPathRoot(fullPath) + ?? throw new InvalidOperationException("Secrets path has no root directory."); + var current = root; + var relativeDirectory = Path.GetRelativePath(root, fullPath); + foreach (var segment in relativeDirectory.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + var nextPath = Path.Combine(current, segment); + if (!Directory.Exists(nextPath)) + { + current = nextPath; + continue; + } + + var directory = new DirectoryInfo(nextPath); + var target = directory.ResolveLinkTarget(returnFinalTarget: true); + current = target is null + ? directory.FullName + : GetCanonicalDirectoryPath(target.FullName); + } + + return current; + } + + private static void DeleteAbandonedTempFiles(string filePath) + { + var directory = Path.GetDirectoryName(filePath) + ?? throw new InvalidOperationException("Secrets path has no parent directory."); + var fileName = Path.GetFileName(filePath); + foreach (var tempPath in Directory.EnumerateFiles(directory, $"{fileName}.tmp-*")) + File.Delete(tempPath); + } + + private sealed class SecretsLock(Mutex mutex) : IDisposable + { + public void Dispose() + { + mutex.ReleaseMutex(); + mutex.Dispose(); + } + } } diff --git a/src/Netclaw.Configuration/SensitiveString.cs b/src/Netclaw.Configuration/SensitiveString.cs index 7d74c575a..806d545c5 100644 --- a/src/Netclaw.Configuration/SensitiveString.cs +++ b/src/Netclaw.Configuration/SensitiveString.cs @@ -115,7 +115,7 @@ public override bool CanConvertFrom(ITypeDescriptorContext? context, Type source /// /// System.Text.Json converter for . Used by code paths -/// that deserialize via STJ (e.g., McpOAuthService.LoadTokensFromDisk) +/// that deserialize via STJ (for example, the MCP OAuth credential store) /// rather than M.E.Configuration binding. /// public sealed class SensitiveStringJsonConverter : JsonConverter diff --git a/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs b/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs index 028adce84..d339250d1 100644 --- a/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs +++ b/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs @@ -14,6 +14,7 @@ using Netclaw.Channels; using Netclaw.Channels.Telemetry; using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; using Netclaw.Daemon.Configuration; using Netclaw.Daemon.Gateway; using Netclaw.Daemon.Mcp; @@ -283,22 +284,26 @@ public async Task IncludesMcpConnectorHealthFromRuntimeStatuses() } }; - var pkceService = new OAuthPkceService(new HttpClient()); - var oauthService = new McpOAuthService( - new HttpClient(), - new NetclawPaths(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())), + var paths = new NetclawPaths(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); + paths.EnsureDirectoriesExist(); + var credentials = new McpOAuthCredentialStore( + paths, TimeProvider.System, - NullLogger.Instance, - pkceService, - NullNotificationSink.Instance); + new NullSecretsProtector(), + NullLogger.Instance); + using var flowBroker = new McpOAuthFlowBroker(TimeProvider.System, CancellationToken.None); var manager = new McpClientManager( mcpServers, new ToolRegistry(), new ToolConfig(), - oauthService, + credentials, + flowBroker, + new DaemonConfig(), NullNotificationSink.Instance, TimeProvider.System, - NullLogger.Instance); + new McpClientRuntime(), + NullLogger.Instance, + new SessionConfig()); await manager.StartAsync(CancellationToken.None); try @@ -325,11 +330,14 @@ public async Task IncludesMcpConnectorHealthFromRuntimeStatuses() [Fact] public async Task IncludesAuthRequiredAndAuthFailedMcpConnectorStatuses() { - var authRequired = McpClientManager.CreateAwaitingAuthStatus(new McpServerName("textforge")); + var errorAt = DateTimeOffset.Parse("2026-07-22T12:00:00Z"); + var authRequired = McpClientManager.CreateAwaitingAuthStatus( + new McpServerName("textforge"), errorAt); var authFailed = McpClientManager.CreateAuthFailedStatus( new McpServerName("notion"), new HttpRequestException(httpRequestError: HttpRequestError.Unknown, "Unauthorized", null, HttpStatusCode.Unauthorized), - oauthManaged: true); + oauthManaged: true, + errorAt); var connectors = new[] { diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs new file mode 100644 index 000000000..3ce7d8e8f --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs @@ -0,0 +1,803 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using Netclaw.Actors.Tools; +using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; +using Netclaw.Daemon.Mcp; +using Netclaw.Providers.OAuth; +using Netclaw.Tests.Utilities; +using Netclaw.Tools; +using Xunit; + +namespace Netclaw.Daemon.Tests.Mcp; + +public sealed class McpClientManagerLifecycleTests +{ + private static readonly McpServerName ServerName = new("test"); + private static readonly DateTimeOffset InitialTime = DateTimeOffset.Parse("2026-07-22T12:00:00Z"); + + [Fact] + public async Task ConcurrentReconnects_CreateOneCandidateAndPublishOneGeneration() + { + var runtime = new ControlledMcpClientRuntime(); + var initial = runtime.Enqueue(new ClientPlan("old_tool")); + var initializeReplacement = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var replacement = runtime.Enqueue(new ClientPlan("new_tool") + { + Initialize = ct => initializeReplacement.Task.WaitAsync(ct), + }); + await using var harness = CreateHarness(runtime); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + var reconnects = Enumerable.Range(0, 16) + .Select(_ => harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)) + .ToArray(); + await replacement.Created.Task; + + Assert.Equal(2, runtime.CreateCount); + initializeReplacement.SetResult(); + Assert.All(await Task.WhenAll(reconnects), Assert.True); + + var snapshot = Assert.IsType(harness.Manager.GetSnapshot(ServerName)); + Assert.Equal(2, snapshot.Generation); + Assert.Same(replacement.Client, snapshot.Client); + AssertPublishedTools(harness, "new_tool"); + await initial.Disposed.Task; + Assert.Equal(1, initial.DisposeCount); + Assert.Equal(0, replacement.DisposeCount); + } + + [Fact] + public async Task FailedCandidate_DisposesOnlyCandidateAndKeepsPublishedSnapshot() + { + var runtime = new ControlledMcpClientRuntime(); + var initial = runtime.Enqueue(new ClientPlan("old_tool")); + var failed = runtime.Enqueue(new ClientPlan("unused") + { + Initialize = _ => Task.FromException(new InvalidOperationException("list failed")), + }); + var recovered = runtime.Enqueue(new ClientPlan("new_tool")); + var time = new FakeTimeProvider(InitialTime); + await using var harness = CreateHarness(runtime, time); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + var original = harness.Manager.GetSnapshot(ServerName); + time.Advance(TimeSpan.FromMinutes(3)); + + Assert.False(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); + + await failed.Disposed.Task; + var retained = Assert.IsType(harness.Manager.GetSnapshot(ServerName)); + Assert.Same(original?.Client, retained.Client); + Assert.Equal(1, retained.Generation); + Assert.Equal(McpConnectionState.Connected, retained.Status.State); + Assert.Equal(1, retained.Status.ToolCount); + var failureAt = time.GetUtcNow(); + Assert.Equal(failureAt, retained.Status.LastErrorAt); + AssertPublishedTools(harness, "old_tool"); + Assert.Equal(0, initial.DisposeCount); + Assert.Equal(1, failed.DisposeCount); + + time.Advance(TimeSpan.FromMinutes(2)); + Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); + var recovery = Assert.IsType(harness.Manager.GetSnapshot(ServerName)); + Assert.Same(recovered.Client, recovery.Client); + Assert.Equal(failureAt, recovery.Status.LastErrorAt); + Assert.NotEqual(time.GetUtcNow(), recovery.Status.LastErrorAt); + AssertPublishedTools(harness, "new_tool"); + } + + [Fact] + public async Task ReplacementsAndShutdown_DisposeEveryClientExactlyOnce() + { + var runtime = new ControlledMcpClientRuntime(); + var first = runtime.Enqueue(new ClientPlan("one")); + var second = runtime.Enqueue(new ClientPlan("two")); + var third = runtime.Enqueue(new ClientPlan("three")); + await using var harness = CreateHarness(runtime); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); + Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); + await harness.Manager.StopAsync(TestContext.Current.CancellationToken); + + await Task.WhenAll(first.Disposed.Task, second.Disposed.Task, third.Disposed.Task); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, second.DisposeCount); + Assert.Equal(1, third.DisposeCount); + Assert.Equal(3, runtime.CreateCount); + Assert.Empty(harness.Registry.GetToolsForServer(ServerName, int.MaxValue)); + } + + [Fact] + public async Task CancellationAndApplicationErrors_DoNotReconnectOrDisposeHealthyClient() + { + var invocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var neverCompletes = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var runtime = new ControlledMcpClientRuntime(); + var plan = runtime.Enqueue(new ClientPlan("run") + { + Invoke = async (call, ct) => + { + if (call == 1) + { + invocationEntered.TrySetResult(); + await neverCompletes.Task.WaitAsync(ct); + return "unreachable"; + } + + if (call == 2) + { + return JsonDocument.Parse( + """{"content":[{"type":"text","text":"declared failure"}],"isError":true}""") + .RootElement.Clone(); + } + + if (call == 3) + throw new McpException("application MCP failure"); + + throw new InvalidOperationException("application failure"); + }, + }); + await using var harness = CreateHarness(runtime); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + using var callerCancellation = new CancellationTokenSource(); + var cancelledCall = InvokeAsync(harness.Manager, callerCancellation.Token); + await invocationEntered.Task; + callerCancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => cancelledCall); + + var toolError = await InvokeAsync(harness.Manager, TestContext.Current.CancellationToken); + Assert.Equal("Error: MCP tool 'test/run' reported a failure: declared failure", toolError); + var applicationMcpError = await InvokeAsync(harness.Manager, TestContext.Current.CancellationToken); + Assert.Equal("Error: MCP tool 'test/run' failed: application MCP failure", applicationMcpError); + await Assert.ThrowsAsync( + () => InvokeAsync(harness.Manager, TestContext.Current.CancellationToken)); + + Assert.Equal(1, runtime.CreateCount); + Assert.Equal(4, plan.InvocationCount); + Assert.Equal(0, plan.DisposeCount); + Assert.Equal(1, harness.Manager.GetSnapshot(ServerName)?.Generation); + } + + [Fact] + public async Task ShutdownRacingReconnect_PublishesNothingAndDisposesEveryClient() + { + var initialization = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var runtime = new ControlledMcpClientRuntime(); + var initial = runtime.Enqueue(new ClientPlan("old")); + var candidate = runtime.Enqueue(new ClientPlan("new") + { + Initialize = ct => initialization.Task.WaitAsync(ct), + }); + await using var harness = CreateHarness(runtime); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + var reconnect = harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken); + await candidate.Created.Task; + + var stop = harness.Manager.StopAsync(TestContext.Current.CancellationToken); + Assert.True(harness.Manager.IsStopping); + Assert.False(await reconnect); + await stop; + + await Task.WhenAll(initial.Disposed.Task, candidate.Disposed.Task); + Assert.Null(harness.Manager.GetSnapshot(ServerName)); + Assert.Equal(1, initial.DisposeCount); + Assert.Equal(1, candidate.DisposeCount); + Assert.Equal(2, runtime.CreateCount); + Assert.Empty(harness.Registry.GetToolsForServer(ServerName, int.MaxValue)); + } + + [Fact] + public async Task Replacement_DrainsPriorGenerationBeforeDisposal() + { + var invocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var runtime = new ControlledMcpClientRuntime(); + var initial = runtime.Enqueue(new ClientPlan("run") + { + Invoke = async (_, ct) => + { + invocationEntered.TrySetResult(); + await releaseInvocation.Task.WaitAsync(ct); + return "completed on old generation"; + }, + }); + var replacement = runtime.Enqueue(new ClientPlan("run")); + await using var harness = CreateHarness(runtime); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + var invocation = InvokeAsync(harness.Manager, TestContext.Current.CancellationToken); + await invocationEntered.Task; + Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); + + Assert.Equal(0, initial.DisposeCount); + Assert.Same(replacement.Client, harness.Manager.GetSnapshot(ServerName)?.Client); + releaseInvocation.SetResult(); + Assert.Equal("completed on old generation", await invocation); + await initial.Disposed.Task; + Assert.Equal(1, initial.DisposeCount); + } + + [Fact] + public async Task TransportFailure_ReleasesLeaseBeforeReconnectAndDoesNotReplay() + { + var runtime = new ControlledMcpClientRuntime(); + var initial = runtime.Enqueue(new ClientPlan("run") + { + Invoke = (_, _) => Task.FromException(new HttpRequestException("session lost")), + }); + var replacement = runtime.Enqueue(new ClientPlan("run")); + await using var harness = CreateHarness(runtime); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + var error = await Assert.ThrowsAsync( + () => InvokeAsync(harness.Manager, TestContext.Current.CancellationToken)); + + Assert.Equal("session lost", error.Message); + await initial.Disposed.Task; + Assert.Equal(1, initial.InvocationCount); + Assert.Equal(1, initial.DisposeCount); + Assert.Equal(0, replacement.InvocationCount); + Assert.Equal(2, harness.Manager.GetSnapshot(ServerName)?.Generation); + } + + [Fact] + public async Task ShutdownWithActiveInvocation_BoundsDrainThenCancelsAndDisposesAfterRelease() + { + var invocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var invocationCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var holdInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var runtime = new ControlledMcpClientRuntime(); + var active = runtime.Enqueue(new ClientPlan("run") + { + Invoke = async (_, ct) => + { + invocationEntered.TrySetResult(); + try + { + await holdInvocation.Task.WaitAsync(ct); + return "unreachable"; + } + catch (OperationCanceledException) + { + invocationCancelled.TrySetResult(); + throw; + } + }, + }); + var time = new FakeTimeProvider(InitialTime); + await using var harness = CreateHarness(runtime, time); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + var invocation = InvokeAsync(harness.Manager, TestContext.Current.CancellationToken); + await invocationEntered.Task; + var stop = harness.Manager.StopAsync(TestContext.Current.CancellationToken); + + Assert.True(harness.Manager.IsStopping); + Assert.Equal(0, active.DisposeCount); + await Assert.ThrowsAsync( + () => InvokeAsync(harness.Manager, TestContext.Current.CancellationToken)); + Assert.False(stop.IsCompleted); + + time.Advance(McpClientManager.ShutdownDrainTimeout); + await invocationCancelled.Task; + await Assert.ThrowsAnyAsync(() => invocation); + await stop; + await active.Disposed.Task; + + Assert.Equal(1, active.DisposeCount); + Assert.Null(harness.Manager.GetSnapshot(ServerName)); + Assert.Empty(harness.Registry.GetToolsForServer(ServerName, int.MaxValue)); + } + + [Fact] + public async Task ReplacementThenShutdown_WaitsForRetiredGenerationAndCancelsItsInvocation() + { + var invocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var invocationCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var holdInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var runtime = new ControlledMcpClientRuntime(); + var retired = runtime.Enqueue(new ClientPlan("old_tool") + { + Invoke = async (_, ct) => + { + invocationEntered.TrySetResult(); + try + { + await holdInvocation.Task.WaitAsync(ct); + return "unreachable"; + } + catch (OperationCanceledException) + { + invocationCancelled.TrySetResult(); + throw; + } + }, + }); + var current = runtime.Enqueue(new ClientPlan("new_tool")); + var time = new FakeTimeProvider(InitialTime); + await using var harness = CreateHarness(runtime, time); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + var invocation = InvokeAsync(harness.Manager, "old_tool", TestContext.Current.CancellationToken); + await invocationEntered.Task; + Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); + AssertPublishedTools(harness, "new_tool"); + Assert.Equal(0, retired.DisposeCount); + + var stop = harness.Manager.StopAsync(TestContext.Current.CancellationToken); + Assert.Null(harness.Manager.GetSnapshot(ServerName)); + Assert.Empty(harness.Registry.GetToolsForServer(ServerName, int.MaxValue)); + Assert.False(stop.IsCompleted); + + time.Advance(McpClientManager.ShutdownDrainTimeout); + await invocationCancelled.Task; + await Assert.ThrowsAnyAsync(() => invocation); + await stop; + + await Task.WhenAll(retired.Disposed.Task, current.Disposed.Task); + Assert.Equal(1, retired.DisposeCount); + Assert.Equal(1, current.DisposeCount); + } + + [Fact] + public async Task CandidateInitializationAndDisposalFailure_AreBothSurfacedAndPriorToolsRemainPublished() + { + var runtime = new ControlledMcpClientRuntime(); + var initial = runtime.Enqueue(new ClientPlan("old_tool")); + var candidate = runtime.Enqueue(new ClientPlan("new_tool") + { + Initialize = _ => Task.FromException(new InvalidOperationException("candidate init failed")), + DisposeFailure = new IOException("candidate dispose failed"), + }); + await using var harness = CreateHarness(runtime); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + var failure = await Assert.ThrowsAsync( + () => harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); + + Assert.Contains(failure.InnerExceptions, ex => ex.Message == "candidate init failed"); + Assert.Contains(failure.InnerExceptions, ex => ex.Message == "candidate dispose failed"); + AssertPublishedTools(harness, "old_tool"); + Assert.Equal(0, initial.DisposeCount); + Assert.Equal(1, candidate.DisposeCount); + } + + [Fact] + public async Task RetiredClientDisposalFailure_IsPreservedUntilShutdownAndRegistryIsRemoved() + { + var runtime = new ControlledMcpClientRuntime(); + var retired = runtime.Enqueue(new ClientPlan("old_tool") + { + DisposeFailure = new IOException("retired dispose failed"), + }); + var current = runtime.Enqueue(new ClientPlan("new_tool")); + var harness = CreateHarness(runtime); + try + { + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); + await retired.Disposed.Task; + AssertPublishedTools(harness, "new_tool"); + + var failure = await Assert.ThrowsAsync( + () => harness.Manager.StopAsync(TestContext.Current.CancellationToken)); + harness.MarkStopFailureObserved(); + + Assert.Equal("retired dispose failed", failure.Message); + Assert.Null(harness.Manager.GetSnapshot(ServerName)); + Assert.Empty(harness.Registry.GetToolsForServer(ServerName, int.MaxValue)); + Assert.Equal(1, retired.DisposeCount); + Assert.Equal(1, current.DisposeCount); + } + finally + { + await harness.DisposeAsync(); + } + } + + [Fact] + public async Task RepeatedReconnects_PruneSuccessfulOwnersButRetainDisposalFailureForShutdown() + { + const int replacementCount = 25; + var runtime = new ControlledMcpClientRuntime(); + var faulted = runtime.Enqueue(new ClientPlan("tool_0") + { + DisposeFailure = new IOException("retained disposal failure"), + }); + var replacements = Enumerable.Range(1, replacementCount) + .Select(index => runtime.Enqueue(new ClientPlan($"tool_{index}"))) + .ToList(); + var harness = CreateHarness(runtime); + harness.MarkStopFailureObserved(); + try + { + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + for (var index = 1; index <= replacementCount; index++) + { + Assert.True(await harness.Manager.TryReconnectAsync( + ServerName, + TestContext.Current.CancellationToken)); + Assert.Equal(2, harness.Manager.GetTrackedOwnerCount(ServerName)); + } + + Assert.Equal(1, faulted.DisposeCount); + Assert.All(replacements.Take(replacementCount - 1), plan => Assert.Equal(1, plan.DisposeCount)); + Assert.Equal(0, replacements[^1].DisposeCount); + + var failure = await Assert.ThrowsAsync( + () => harness.Manager.StopAsync(TestContext.Current.CancellationToken)); + + Assert.Equal("retained disposal failure", failure.Message); + Assert.Equal(1, replacements[^1].DisposeCount); + Assert.Equal(0, harness.Manager.GetTrackedOwnerCount(ServerName)); + } + finally + { + await harness.DisposeAsync(); + } + } + + [Fact] + public async Task ApplicationExceptionsWrappingTransportLikeErrors_DoNotReconnect() + { + var runtime = new ControlledMcpClientRuntime(); + var plan = runtime.Enqueue(new ClientPlan("run") + { + Invoke = (call, _) => Task.FromException(call switch + { + 1 => new InvalidOperationException("wrapped IO", new IOException("io")), + 2 => new InvalidOperationException("wrapped timeout", new TimeoutException("timeout")), + _ => new InvalidOperationException("wrapped disposed", new ObjectDisposedException("application")), + }), + }); + await using var harness = CreateHarness(runtime); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + for (var call = 0; call < 3; call++) + { + await Assert.ThrowsAsync( + () => InvokeAsync(harness.Manager, TestContext.Current.CancellationToken)); + } + + Assert.Equal(3, plan.InvocationCount); + Assert.Equal(1, runtime.CreateCount); + Assert.Equal(0, plan.DisposeCount); + Assert.Equal(1, harness.Manager.GetSnapshot(ServerName)?.Generation); + } + + [Fact] + public async Task DisposeWithoutStop_CleansCurrentAndRetiredOwnersAfterReplacement() + { + var invocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var invocationCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var holdInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var runtime = new ControlledMcpClientRuntime(); + var retired = runtime.Enqueue(new ClientPlan("old_tool") + { + Invoke = async (_, ct) => + { + invocationEntered.TrySetResult(); + try + { + await holdInvocation.Task.WaitAsync(ct); + return "unreachable"; + } + catch (OperationCanceledException) + { + invocationCancelled.TrySetResult(); + throw; + } + }, + }); + var current = runtime.Enqueue(new ClientPlan("new_tool")); + var harness = CreateHarness(runtime); + try + { + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + var invocation = InvokeAsync(harness.Manager, "old_tool", TestContext.Current.CancellationToken); + await invocationEntered.Task; + Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); + Assert.Equal(0, retired.DisposeCount); + + harness.DisposeManagerWithoutStop(); + + Assert.Null(harness.Manager.GetSnapshot(ServerName)); + Assert.Empty(harness.Registry.GetToolsForServer(ServerName, int.MaxValue)); + await invocationCancelled.Task; + await Assert.ThrowsAnyAsync(() => invocation); + await Task.WhenAll(retired.Disposed.Task, current.Disposed.Task); + Assert.Equal(1, retired.DisposeCount); + Assert.Equal(1, current.DisposeCount); + } + finally + { + await harness.DisposeAsync(); + } + } + + [Fact] + public async Task FailedReplacement_KeepsConnectedStatusWithoutUnavailableAlert() + { + var runtime = new ControlledMcpClientRuntime(); + runtime.Enqueue(new ClientPlan("old_tool")); + runtime.Enqueue(new ClientPlan("new_tool") + { + Initialize = _ => Task.FromException(new InvalidOperationException("replacement list failed")), + }); + var alerts = new RecordingNotificationSink(); + await using var harness = CreateHarness(runtime, new FakeTimeProvider(InitialTime), alerts); + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + Assert.False(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); + + var status = harness.Manager.GetServerStatuses()[ServerName]; + Assert.Equal(McpConnectionState.Connected, status.State); + Assert.Equal(1, status.ToolCount); + Assert.Equal("Failed to reach MCP server. Check daemon logs for details.", status.ErrorMessage); + AssertPublishedTools(harness, "old_tool"); + Assert.Empty(alerts.Alerts); + } + + [Fact] + public async Task ProviderErrorBodyNeverLeaksThroughStatusOrNotification() + { + const string providerBody = "code=auth-code access_token=access-value client_secret=secret-value"; + var runtime = new ControlledMcpClientRuntime(); + runtime.Enqueue(new ClientPlan("unused") + { + Initialize = _ => Task.FromException(new InvalidOperationException(providerBody)), + }); + var alerts = new RecordingNotificationSink(); + await using var harness = CreateHarness(runtime, new FakeTimeProvider(InitialTime), alerts); + + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + var status = harness.Manager.GetServerStatuses()[ServerName]; + var alert = Assert.Single(alerts.Alerts); + Assert.DoesNotContain("auth-code", status.ErrorMessage, StringComparison.Ordinal); + Assert.DoesNotContain("access-value", status.ErrorMessage, StringComparison.Ordinal); + Assert.DoesNotContain("secret-value", status.ErrorMessage, StringComparison.Ordinal); + Assert.DoesNotContain("auth-code", alert.Summary, StringComparison.Ordinal); + Assert.DoesNotContain("access-value", alert.Summary, StringComparison.Ordinal); + Assert.DoesNotContain("secret-value", alert.Summary, StringComparison.Ordinal); + } + + private static Task InvokeAsync(McpClientManager manager, CancellationToken cancellationToken) + => InvokeAsync(manager, "run", cancellationToken); + + private static Task InvokeAsync( + McpClientManager manager, + string toolName, + CancellationToken cancellationToken) + => manager.InvokeAsync( + ServerName.Value, + toolName, + null, + TestToolExecutionContext.CreateBound( + "slack/thread-1", + null, + TrustAudience.Team, + "slack").Invocation, + cancellationToken); + + private static void AssertPublishedTools(ManagerHarness harness, params string[] expected) + { + Assert.Equal(expected, harness.Manager.GetToolNames(ServerName)); + Assert.Equal( + expected, + harness.Registry + .GetToolsForServer(ServerName, int.MaxValue) + .OfType() + .Select(tool => tool.BareToolName) + .Order(StringComparer.Ordinal) + .ToArray()); + Assert.Equal(expected.Length, harness.Manager.GetServerStatuses()[ServerName].ToolCount); + } + + private static ManagerHarness CreateHarness(ControlledMcpClientRuntime runtime) + => new( + runtime, + new FakeTimeProvider(InitialTime), + NullNotificationSink.Instance); + + private static ManagerHarness CreateHarness( + ControlledMcpClientRuntime runtime, + FakeTimeProvider timeProvider) + => new(runtime, timeProvider, NullNotificationSink.Instance); + + private static ManagerHarness CreateHarness( + ControlledMcpClientRuntime runtime, + FakeTimeProvider timeProvider, + IOperationalNotificationSink notificationSink) + => new(runtime, timeProvider, notificationSink); + + private sealed class ManagerHarness : IAsyncDisposable + { + private readonly McpOAuthFlowBroker _flowBroker; + private bool _stopFailureObserved; + private bool _managerDisposed; + + public ManagerHarness( + ControlledMcpClientRuntime runtime, + FakeTimeProvider timeProvider, + IOperationalNotificationSink notificationSink) + { + var paths = new NetclawPaths(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + paths.EnsureDirectoriesExist(); + var credentials = new McpOAuthCredentialStore( + paths, + timeProvider, + new NullSecretsProtector(), + NullLogger.Instance); + _flowBroker = new McpOAuthFlowBroker(timeProvider, CancellationToken.None); + Registry = new ToolRegistry(); + Manager = new McpClientManager( + new Dictionary + { + [ServerName.Value] = new() + { + Enabled = true, + Transport = "stdio", + Command = "not-launched-by-controlled-runtime", + }, + }, + Registry, + new ToolConfig(), + credentials, + _flowBroker, + new DaemonConfig(), + notificationSink, + timeProvider, + runtime, + NullLogger.Instance, + new SessionConfig()); + } + + public McpClientManager Manager { get; } + + public ToolRegistry Registry { get; } + + public void MarkStopFailureObserved() => _stopFailureObserved = true; + + public void DisposeManagerWithoutStop() + { + Manager.Dispose(); + _managerDisposed = true; + } + + public async ValueTask DisposeAsync() + { + if (!_stopFailureObserved && !_managerDisposed) + await Manager.StopAsync(TestContext.Current.CancellationToken); + if (!_managerDisposed) + Manager.Dispose(); + _flowBroker.Dispose(); + } + } + + private sealed class ControlledMcpClientRuntime : IMcpClientRuntime + { + private readonly ConcurrentQueue _plans = new(); + private readonly ConcurrentDictionary _clients = new(); + private readonly ConcurrentDictionary _functions = new(); + private int _createCount; + + public int CreateCount => Volatile.Read(ref _createCount); + + public ClientPlan Enqueue(ClientPlan plan) + { + _plans.Enqueue(plan); + return plan; + } + + public Task CreateAsync( + IClientTransport transport, + McpClientOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_plans.TryDequeue(out var plan)) + throw new InvalidOperationException("No controlled MCP client plan was queued."); + + var implementationType = typeof(McpClient).Assembly.GetType( + "ModelContextProtocol.Client.McpClientImpl", + throwOnError: true)!; + var client = (McpClient)RuntimeHelpers.GetUninitializedObject(implementationType); + plan.Client = client; + _clients[client] = plan; + Interlocked.Increment(ref _createCount); + plan.Created.TrySetResult(); + return Task.FromResult(client); + } + + public async ValueTask InitializeAsync( + McpClient client, + CancellationToken cancellationToken) + { + var plan = _clients[client]; + if (plan.Initialize is not null) + await plan.Initialize(cancellationToken); + + var functions = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var name in plan.ToolNames) + { + var function = AIFunctionFactory.Create(() => "unused", name, name); + functions[name] = function; + _functions[function] = plan; + } + + return new McpClientInitialization(functions.Values.ToList()); + } + + public async ValueTask InvokeAsync( + AIFunction function, + AIFunctionArguments? arguments, + CancellationToken cancellationToken) + { + var plan = _functions[function]; + var call = Interlocked.Increment(ref plan.InvocationCountStorage); + return plan.Invoke is null + ? "ok" + : await plan.Invoke(call, cancellationToken); + } + + public ValueTask DisposeAsync(McpClient client) + { + var plan = _clients[client]; + Interlocked.Increment(ref plan.DisposeCountStorage); + plan.Disposed.TrySetResult(); + return plan.DisposeFailure is null + ? ValueTask.CompletedTask + : new ValueTask(Task.FromException(plan.DisposeFailure)); + } + } + + private sealed class ClientPlan(params string[] toolNames) + { + public string[] ToolNames { get; } = toolNames; + + public Func? Initialize { get; init; } + + public Func>? Invoke { get; init; } + + public Exception? DisposeFailure { get; init; } + + public TaskCompletionSource Created { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Disposed { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public McpClient? Client { get; set; } + + public int InvocationCountStorage; + + public int DisposeCountStorage; + + public int InvocationCount => Volatile.Read(ref InvocationCountStorage); + + public int DisposeCount => Volatile.Read(ref DisposeCountStorage); + } + + private sealed class RecordingNotificationSink : IOperationalNotificationSink + { + public List Alerts { get; } = []; + + public void Emit(OperationalAlert alert) => Alerts.Add(alert); + } + +} diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerStatusTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerStatusTests.cs index f3e99850a..ca1c715a5 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerStatusTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerStatusTests.cs @@ -13,6 +13,8 @@ namespace Netclaw.Daemon.Tests.Mcp; public sealed class McpClientManagerStatusTests { + private static readonly DateTimeOffset ErrorAt = DateTimeOffset.Parse("2026-07-22T12:00:00Z"); + [Fact] public void BuildConnectionFailureStatus_WithoutTokensButWithOAuthHints_ReturnsAwaitingAuth() { @@ -28,10 +30,12 @@ public void BuildConnectionFailureStatus_WithoutTokensButWithOAuthHints_ReturnsA entry, new HttpRequestException(httpRequestError: HttpRequestError.Unknown, "Unauthorized", null, HttpStatusCode.Unauthorized), hasCachedTokens: false, - hasOAuthRuntimeHints: true); + hasOAuthRuntimeHints: true, + ErrorAt); Assert.Equal(McpConnectionState.AwaitingAuth, status.State); Assert.Contains("netclaw mcp auth notion", status.ErrorMessage); + Assert.Equal(ErrorAt, status.LastErrorAt); } [Fact] @@ -48,11 +52,13 @@ public void BuildConnectionFailureStatus_WithTokensAndAuthFailure_ReturnsAuthFai entry, new HttpRequestException(httpRequestError: HttpRequestError.Unknown, "Forbidden", null, HttpStatusCode.Forbidden), hasCachedTokens: true, - hasOAuthRuntimeHints: true); + hasOAuthRuntimeHints: true, + ErrorAt); Assert.Equal(McpConnectionState.AuthFailed, status.State); Assert.Contains("403 Forbidden", status.ErrorMessage); Assert.Contains("netclaw mcp auth notion", status.ErrorMessage); + Assert.Equal(ErrorAt, status.LastErrorAt); } [Fact] @@ -69,9 +75,58 @@ public void BuildConnectionFailureStatus_ForNetworkFailure_ReturnsUnreachable() entry, new HttpRequestException("Connection refused"), hasCachedTokens: false, - hasOAuthRuntimeHints: false); + hasOAuthRuntimeHints: false, + ErrorAt); Assert.Equal(McpConnectionState.Unreachable, status.State); - Assert.Contains("Connection refused", status.ErrorMessage); + Assert.Equal("Failed to reach MCP server. Check daemon logs for details.", status.ErrorMessage); + Assert.DoesNotContain("Connection refused", status.ErrorMessage); + Assert.Equal(ErrorAt, status.LastErrorAt); + } + + [Fact] + public void PublicErrorsNeverIncludeProviderBodySecrets() + { + const string providerBody = "code=oauth-code access_token=token-value client_secret=secret-value"; + var entry = new McpServerEntry + { + Transport = "http", + Url = "https://mcp.example.com", + }; + var exception = new HttpRequestException( + HttpRequestError.Unknown, + providerBody, + null, + HttpStatusCode.InternalServerError); + + var status = McpClientManager.BuildConnectionFailureStatus( + new McpServerName("notion"), + entry, + exception, + hasCachedTokens: false, + hasOAuthRuntimeHints: false, + ErrorAt); + var oauthError = McpClientManager.CreateSafeOAuthError(exception, "connection initialization"); + + Assert.Equal("MCP server request failed (HTTP 500 InternalServerError).", status.ErrorMessage); + Assert.DoesNotContain("oauth-code", status.ErrorMessage, StringComparison.Ordinal); + Assert.DoesNotContain("token-value", oauthError.Error, StringComparison.Ordinal); + Assert.DoesNotContain("secret-value", oauthError.Error, StringComparison.Ordinal); + } + + [Fact] + public void WrappedStaleCredentialEpochIsClassifiedAsCredentialPersistence() + { + var exception = new InvalidOperationException( + "SDK token cache callback failed.", + new McpOAuthStaleCredentialEpochException("Pending credentials changed.")); + + var error = McpClientManager.CreateSafeOAuthError(exception, "connection initialization"); + + Assert.Equal("credential persistence", error.Operation); + Assert.Equal( + "MCP OAuth credential persistence failed. Check daemon logs for details.", + error.Error); + Assert.DoesNotContain("Pending credentials changed", error.Error, StringComparison.Ordinal); } } diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs index 27ce28cb0..ad075c7b7 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs @@ -6,7 +6,6 @@ using System.Net; using System.Net.Http.Json; using System.Security.Claims; -using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using Microsoft.AspNetCore.Authentication; @@ -19,9 +18,9 @@ using Microsoft.Extensions.Options; using Netclaw.Actors.Tools; using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; using Netclaw.Daemon.Mcp; using Netclaw.Daemon.Security; -using Netclaw.Providers.OAuth; using Netclaw.Tests.Utilities; using Netclaw.Tools; using Xunit; @@ -44,15 +43,13 @@ public sealed class McpEndpointRouteBuilderExtensionsTests : IDisposable /// /// Creates a test host wired with real . - /// Constructs a real over a - /// so tests can exercise OAuth state transitions without a live server. + /// Uses the production flow broker and credential store with a manager whose + /// network path remains dormant unless a test supplies a pending broker flow. /// private async Task CreateAppAsync( bool spoofLoopback, - Func? discoveryHandler = null, - Func? tokenHandler = null, Dictionary? mcpServers = null, - IMcpReconnectable? reconnectable = null) + McpOAuthFlowBroker? flowBroker = null) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); @@ -66,18 +63,12 @@ private async Task CreateAppAsync( var servers = mcpServers ?? []; - // Construct a real McpOAuthService over fake HTTP handlers - var discoveryClient = new HttpClient( - new FakeHttpMessageHandler(discoveryHandler ?? DefaultDiscoveryHandler)); - var pkceService = new OAuthPkceService( - new HttpClient(new FakeHttpMessageHandler(tokenHandler ?? DefaultTokenHandler))); - var oauthService = new McpOAuthService( - discoveryClient, + var credentialStore = new McpOAuthCredentialStore( paths, TimeProvider.System, - NullLogger.Instance, - pkceService, - NullNotificationSink.Instance); + new NullSecretsProtector(), + NullLogger.Instance); + flowBroker ??= new McpOAuthFlowBroker(TimeProvider.System, CancellationToken.None); // Minimal McpClientManager with empty state var toolRegistry = new ToolRegistry(); @@ -85,16 +76,20 @@ private async Task CreateAppAsync( servers, toolRegistry, new ToolConfig(), - oauthService, + credentialStore, + flowBroker, + new DaemonConfig(), NullNotificationSink.Instance, TimeProvider.System, - NullLogger.Instance); + new McpClientRuntime(), + NullLogger.Instance, + new SessionConfig()); - builder.Services.AddSingleton(oauthService); + builder.Services.AddSingleton(credentialStore); + builder.Services.AddSingleton(flowBroker); builder.Services.AddSingleton(mcpManager); builder.Services.AddSingleton(mcpManager); builder.Services.AddSingleton(servers); - builder.Services.AddSingleton(reconnectable ?? new NoOpReconnectable()); builder.Services.AddSingleton>(NullLogger.Instance); var app = builder.Build(); @@ -148,10 +143,12 @@ public async Task Callback_returns_failure_html_when_code_and_state_are_missing( // No Authorization header — proves AllowAnonymous is wired var response = await client.GetAsync("/api/mcp/oauth/callback", ct); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); var html = await response.Content.ReadAsStringAsync(ct); Assert.Contains("Authorization failed", html); + Assert.DoesNotContain("abc", html, StringComparison.Ordinal); + Assert.DoesNotContain("unknown-state", html, StringComparison.Ordinal); Assert.Contains("Missing code or state parameter", html); } @@ -162,10 +159,9 @@ public async Task Callback_returns_failure_html_for_unknown_state() await using var app = await CreateAppAsync(spoofLoopback: false); var client = app.GetTestClient(); - // Unknown state triggers CompleteAuthorizationAsync to throw InvalidOperationException var response = await client.GetAsync("/api/mcp/oauth/callback?code=abc&state=unknown-state", ct); - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); var html = await response.Content.ReadAsStringAsync(ct); Assert.Contains("Authorization failed", html); @@ -240,6 +236,32 @@ public async Task GetStatuses_returns_200_with_empty_dictionary_when_no_servers_ Assert.Empty(body.EnumerateObject()); } + [Fact] + public async Task GetStatuses_includes_lastErrorAt_for_connection_failure() + { + var ct = TestContext.Current.CancellationToken; + var servers = new Dictionary + { + ["broken"] = new() + { + Enabled = true, + Transport = "stdio", + Command = "definitely-not-a-real-command", + }, + }; + await using var app = await CreateAppAsync(spoofLoopback: true, mcpServers: servers); + var manager = app.Services.GetRequiredService(); + await manager.StartAsync(ct); + + var response = await app.GetTestClient().GetAsync("/api/mcp/statuses", ct); + var body = await response.Content.ReadFromJsonAsync(ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Unreachable", body.GetProperty("broken").GetProperty("state").GetString()); + Assert.Equal(JsonValueKind.String, body.GetProperty("broken").GetProperty("lastErrorAt").ValueKind); + await manager.StopAsync(ct); + } + [Fact] public async Task GetTools_returns_200_with_empty_array_for_unknown_server() { @@ -283,6 +305,33 @@ public async Task GetOauthStatusByState_returns_200_with_status_field_for_any_st Assert.True(body.TryGetProperty("status", out _)); } + [Fact] + public async Task OAuthStatusByNameAndStateIncludeSameSafeStructuredTerminalError() + { + var ct = TestContext.Current.CancellationToken; + using var broker = new McpOAuthFlowBroker(TimeProvider.System, CancellationToken.None); + var flow = broker.StartOrJoin(new McpServerName("failed-server")).Flow; + broker.Fail(flow, new McpErrorResponse( + "MCP OAuth dynamic client registration failed: HTTP 403 Forbidden.", + "dynamic client registration", + 403)); + await using var app = await CreateAppAsync(spoofLoopback: true, flowBroker: broker); + + var client = app.GetTestClient(); + var response = await client.GetAsync( + $"/api/mcp/oauth/status-by-state/{flow.State}", ct); + var body = await response.Content.ReadFromJsonAsync(ct); + var byNameResponse = await client.GetAsync("/api/mcp/oauth/status/failed-server", ct); + var byName = await byNameResponse.Content.ReadFromJsonAsync(ct); + + Assert.Equal("Failed", body.GetProperty("status").GetString()); + Assert.Equal(403, body.GetProperty("error").GetProperty("status").GetInt32()); + Assert.Equal( + "dynamic client registration", + body.GetProperty("error").GetProperty("operation").GetString()); + Assert.Equal(body.GetRawText(), byName.GetRawText()); + } + // ─── Happy-path: oauth/start then callback ───────────────────────────────── [Fact] @@ -300,7 +349,17 @@ public async Task OauthStart_returns_200_with_authorizationUrl_and_state() } }; - await using var app = await CreateAppAsync(spoofLoopback: true, mcpServers: servers); + using var broker = new McpOAuthFlowBroker(TimeProvider.System, CancellationToken.None); + var pending = broker.StartOrJoin(new McpServerName("test-mcp")).Flow; + var expectedUrl = new Uri("https://auth.example.com/authorize?client_id=test-client"); + var owner = pending.HandleAuthorizationRedirectAsync( + expectedUrl, + new Uri("http://127.0.0.1:5199/api/mcp/oauth/callback"), + ct); + await using var app = await CreateAppAsync( + spoofLoopback: true, + mcpServers: servers, + flowBroker: broker); var client = app.GetTestClient(); var response = await client.PostAsync("/api/mcp/oauth/start/test-mcp", null, ct); @@ -311,10 +370,13 @@ public async Task OauthStart_returns_200_with_authorizationUrl_and_state() Assert.True(body.TryGetProperty("state", out var stateProp)); Assert.False(string.IsNullOrWhiteSpace(urlProp.GetString())); Assert.False(string.IsNullOrWhiteSpace(stateProp.GetString())); + Assert.Equal(expectedUrl.ToString(), urlProp.GetString()); + broker.Fail(pending, new McpErrorResponse("test cleanup")); + await Assert.ThrowsAnyAsync(async () => await owner); } [Fact] - public async Task Callback_happy_path_returns_success_html_and_triggers_reconnect() + public async Task Callback_happy_path_returns_success_html_after_owner_exchange_completes() { var ct = TestContext.Current.CancellationToken; var servers = new Dictionary @@ -328,106 +390,58 @@ public async Task Callback_happy_path_returns_success_html_and_triggers_reconnec } }; - var reconnectable = new TrackingReconnectable(); - + using var broker = new McpOAuthFlowBroker(TimeProvider.System, CancellationToken.None); + var flow = broker.StartOrJoin(new McpServerName("test-mcp")).Flow; + var owner = flow.HandleAuthorizationRedirectAsync( + new Uri("https://auth.example.com/authorize"), + new Uri("http://127.0.0.1:5199/api/mcp/oauth/callback"), + ct); await using var app = await CreateAppAsync( spoofLoopback: true, mcpServers: servers, - reconnectable: reconnectable); + flowBroker: broker); var client = app.GetTestClient(); - // Start the OAuth flow to get a valid state token - var startResponse = await client.PostAsync("/api/mcp/oauth/start/test-mcp", null, ct); - Assert.Equal(HttpStatusCode.OK, startResponse.StatusCode); - - var startBody = await startResponse.Content.ReadFromJsonAsync(ct); - var state = startBody.GetProperty("state").GetString()!; - // Complete via callback — no Authorization header (AllowAnonymous) - var callbackResponse = await client.GetAsync( - $"/api/mcp/oauth/callback?code=test-code&state={state}", ct); + var callback = client.GetAsync( + $"/api/mcp/oauth/callback?code=test-code&state={flow.State}", ct); + Assert.Equal("test-code", await owner); + broker.BeginCommit(flow); + broker.Complete(flow); + var callbackResponse = await callback; Assert.Equal(HttpStatusCode.OK, callbackResponse.StatusCode); var html = await callbackResponse.Content.ReadAsStringAsync(ct); Assert.Contains("Authorization complete", html); - - // Wait until the fire-and-forget reconnect task signals completion. - // TrackingReconnectable.ReconnectCalled is set before TCS is signalled so - // the assertion below is race-free. - await reconnectable.ReconnectCalledTask.WaitAsync(ct); - Assert.True(reconnectable.WasReconnectCalled, "TryReconnectAsync should have been called post-OAuth"); } - // ─── Default fake HTTP handlers ─────────────────────────────────────────── - - private static HttpResponseMessage DefaultDiscoveryHandler(HttpRequestMessage request) - { - var uri = request.RequestUri!.ToString(); - return uri switch - { - "https://mcp.example.com/" or "https://mcp.example.com" => - new HttpResponseMessage(HttpStatusCode.Unauthorized), - "https://mcp.example.com/.well-known/oauth-protected-resource" => - JsonResponse(new - { - authorization_servers = new[] { "https://auth.example.com" }, - resource = "https://mcp.example.com/resource" - }), - "https://auth.example.com/.well-known/oauth-authorization-server" => - JsonResponse(new - { - authorization_endpoint = "https://auth.example.com/authorize", - token_endpoint = "https://auth.example.com/token" - }), - _ => new HttpResponseMessage(HttpStatusCode.NotFound) - }; - } - - private static HttpResponseMessage DefaultTokenHandler(HttpRequestMessage request) => - JsonResponse(new - { - access_token = "test-access-token", - refresh_token = "test-refresh-token", - expires_in = 3600 - }); - - private static HttpResponseMessage JsonResponse(object body, HttpStatusCode status = HttpStatusCode.OK) => - new(status) - { - Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") - }; - - // ─── Fakes ──────────────────────────────────────────────────────────────── - - private sealed class NoOpReconnectable : IMcpReconnectable + [Fact] + public async Task Callback_exchange_failure_returns_safe_html_without_code() { - public IReadOnlyDictionary GetServerStatuses() => - new Dictionary(); + var ct = TestContext.Current.CancellationToken; + using var broker = new McpOAuthFlowBroker(TimeProvider.System, CancellationToken.None); + var flow = broker.StartOrJoin(new McpServerName("test-mcp")).Flow; + var owner = flow.HandleAuthorizationRedirectAsync( + new Uri("https://auth.example.com/authorize"), + new Uri("http://127.0.0.1:5199/api/mcp/oauth/callback"), + ct); + await using var app = await CreateAppAsync(spoofLoopback: true, flowBroker: broker); + var callback = app.GetTestClient().GetAsync( + $"/api/mcp/oauth/callback?code=sensitive-code&state={flow.State}", ct); + Assert.Equal("sensitive-code", await owner); + broker.Fail(flow, new McpErrorResponse( + "MCP OAuth authorization code exchange failed: HTTP 403 Forbidden.", + "authorization code exchange", + 403)); + + var response = await callback; + var html = await response.Content.ReadAsStringAsync(ct); - public Task TryReconnectAsync(McpServerName serverName, CancellationToken ct = default) => - Task.FromResult(false); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + Assert.Contains("authorization code exchange failed", html, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("sensitive-code", html, StringComparison.Ordinal); + Assert.DoesNotContain(flow.State, html, StringComparison.Ordinal); } - private sealed class TrackingReconnectable : IMcpReconnectable - { - private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public bool WasReconnectCalled { get; private set; } - - /// - /// Completes when has been called. - /// Use this instead of Task.Delay to synchronize with the fire-and-forget reconnect. - /// - public Task ReconnectCalledTask => _tcs.Task; - - public IReadOnlyDictionary GetServerStatuses() => - new Dictionary(); - - public Task TryReconnectAsync(McpServerName serverName, CancellationToken ct = default) - { - WasReconnectCalled = true; - _tcs.TrySetResult(); - return Task.FromResult(true); - } - } } diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs new file mode 100644 index 000000000..13b64f6ff --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs @@ -0,0 +1,685 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using ModelContextProtocol.Authentication; +using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; +using Netclaw.Daemon.Mcp; +using Netclaw.Tests.Utilities; +using Netclaw.Tools; +using Xunit; + +namespace Netclaw.Daemon.Tests.Mcp; + +public sealed class McpOAuthCredentialStoreTests : IDisposable +{ + private static readonly McpServerName ServerName = new("test-server"); + private const string Resource = "https://mcp.example.com/tools?tenant=one"; + private readonly DisposableTempDir _dir = new(); + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 7, 22, 12, 0, 0, TimeSpan.Zero)); + + [Fact] + public async Task EveryAdapterReadsSharedPersistedActiveView() + { + var store = CreateStore(); + var context = store.CreateContext(ServerName, Resource, "static-client", false); + var writer = store.CreateTokenCache( + ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); + var reader = store.CreateTokenCache( + ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); + + await writer.StoreTokensAsync(Tokens("access-one", "refresh-one"), CancellationToken.None); + var loaded = await reader.GetTokensAsync(CancellationToken.None); + + Assert.Equal("access-one", loaded?.AccessToken); + Assert.Equal("refresh-one", loaded?.RefreshToken); + } + + [Fact] + public async Task ConcurrentStoresForOneServerKeepMemoryAndDiskCoherent() + { + var paths = Paths(); + var store = CreateStore(paths); + var context = store.CreateContext(ServerName, Resource, "static-client", false); + var adapters = Enumerable.Range(0, 12) + .Select(_ => store.CreateTokenCache( + ServerName, + Resource, + context, + McpOAuthCredentialTarget.Active, + null, + null, + false)) + .ToArray(); + + await Task.WhenAll(adapters.Select((adapter, index) => Task.Run(async () => + await adapter.StoreTokensAsync( + Tokens($"access-{index}", $"refresh-{index}"), + TestContext.Current.CancellationToken)))); + + var memory = store.GetEnvelopeForTests(ServerName).Active; + var restarted = CreateStore(paths).GetEnvelopeForTests(ServerName).Active; + Assert.NotNull(memory); + Assert.Equal(memory!.AccessToken.Value, restarted?.AccessToken.Value); + Assert.Equal(memory.RefreshToken?.Value, restarted?.RefreshToken?.Value); + } + + [Fact] + public async Task PersistenceFailureDoesNotAdvanceSharedMemory() + { + var paths = Paths(); + Directory.CreateDirectory(paths.SecretsPath); + var store = CreateStore(paths); + var context = store.CreateContext(ServerName, Resource, "static-client", false); + var cache = store.CreateTokenCache( + ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); + + await Assert.ThrowsAnyAsync(async () => + await cache.StoreTokensAsync(Tokens("not-published", null), CancellationToken.None)); + + Assert.Null(await cache.GetTokensAsync(CancellationToken.None)); + Assert.Null(store.GetEnvelopeForTests(ServerName).Active); + } + + [Fact] + public async Task StoreHonorsCancellationBeforeDiskOrMemoryMutation() + { + var store = CreateStore(); + var context = store.CreateContext(ServerName, Resource, "static-client", false); + var cache = store.CreateTokenCache( + ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAsync(async () => + await cache.StoreTokensAsync(Tokens("cancelled", null), cancellation.Token)); + + Assert.Null(store.GetEnvelopeForTests(ServerName).Active); + } + + [Fact] + public async Task RefreshResponseWithoutRefreshTokenRetainsPriorToken() + { + var store = CreateStore(); + var context = store.CreateContext(ServerName, Resource, "static-client", false); + var cache = store.CreateTokenCache( + ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); + await cache.StoreTokensAsync(Tokens("old-access", "keep-refresh"), CancellationToken.None); + + await cache.StoreTokensAsync(Tokens("new-access", null), CancellationToken.None); + + var active = store.GetEnvelopeForTests(ServerName).Active; + Assert.Equal("new-access", active?.AccessToken.Value); + Assert.Equal("keep-refresh", active?.RefreshToken?.Value); + } + + [Fact] + public async Task PromotedInteractiveCacheWritesLaterRefreshToActiveAndSurvivesRestart() + { + var paths = Paths(); + var store = CreateStore(paths); + var context = store.CreateContext(ServerName, Resource, "static-client", true); + var cache = store.CreateTokenCache( + ServerName, + Resource, + context, + McpOAuthCredentialTarget.Pending, + "published-flow", + _time.GetUtcNow().AddMinutes(5), + true); + await cache.StoreTokensAsync(Tokens("authorized-access", "authorized-refresh"), CancellationToken.None); + store.PromotePending(ServerName, context, "published-flow", CancellationToken.None); + + await cache.StoreTokensAsync(Tokens("refreshed-after-publication", "rotated-refresh"), CancellationToken.None); + + var active = store.GetEnvelopeForTests(ServerName).Active; + var restarted = CreateStore(paths).GetEnvelopeForTests(ServerName).Active; + Assert.Equal("refreshed-after-publication", active?.AccessToken.Value); + Assert.Equal("rotated-refresh", active?.RefreshToken?.Value); + Assert.Equal(active?.CredentialEpoch, restarted?.CredentialEpoch); + Assert.Equal("refreshed-after-publication", restarted?.AccessToken.Value); + } + + [Fact] + public async Task OmittedRefreshTokenNeverCrossesResourceOrPendingFlowBoundary() + { + var store = CreateStore(); + await StoreActiveAsync(store, Resource, "old-resource-access"); + var changedResource = "https://other.example.com/tools"; + var context = store.CreateContext(ServerName, changedResource, "static-client", true); + var cache = store.CreateTokenCache( + ServerName, + changedResource, + context, + McpOAuthCredentialTarget.Pending, + "new-resource-flow", + _time.GetUtcNow().AddMinutes(5), + true); + + await cache.StoreTokensAsync(Tokens("new-resource-access", null), CancellationToken.None); + + Assert.Null(store.GetEnvelopeForTests(ServerName).Pending?.Credentials.RefreshToken); + Assert.Equal("refresh", store.GetEnvelopeForTests(ServerName).Active?.RefreshToken?.Value); + } + + [Fact] + public async Task PendingRefreshRetentionIsLimitedToOwningFlowAndEpoch() + { + var store = CreateStore(); + await StoreActiveAsync(store, Resource, "active-access"); + var owner = store.CreateContext(ServerName, Resource, "static-client", true); + var ownerCache = store.CreateTokenCache( + ServerName, + Resource, + owner, + McpOAuthCredentialTarget.Pending, + "owner-flow", + _time.GetUtcNow().AddMinutes(5), + true); + await ownerCache.StoreTokensAsync(Tokens("pending-one", "flow-refresh"), CancellationToken.None); + await ownerCache.StoreTokensAsync(Tokens("pending-two", null), CancellationToken.None); + + var competing = store.CreateContext(ServerName, Resource, "static-client", true); + var competingCache = store.CreateTokenCache( + ServerName, + Resource, + competing, + McpOAuthCredentialTarget.Pending, + "competing-flow", + _time.GetUtcNow().AddMinutes(5), + true); + + await Assert.ThrowsAsync(async () => + await competingCache.StoreTokensAsync(Tokens("competing", null), CancellationToken.None)); + Assert.Equal("flow-refresh", store.GetEnvelopeForTests(ServerName).Pending?.Credentials.RefreshToken?.Value); + Assert.Equal("owner-flow", store.GetEnvelopeForTests(ServerName).Pending?.FlowId); + } + + [Fact] + public async Task RetiredGenerationCannotOverwriteNewOwnerEpoch() + { + var store = CreateStore(); + var retiredContext = store.CreateContext(ServerName, Resource, "static-client", false); + var retiredCache = store.CreateTokenCache( + ServerName, Resource, retiredContext, McpOAuthCredentialTarget.Active, null, null, false); + await retiredCache.StoreTokensAsync(Tokens("generation-one", "refresh-one"), CancellationToken.None); + + var currentContext = store.CreateContext(ServerName, Resource, "static-client", false); + store.CreateTokenCache( + ServerName, Resource, currentContext, McpOAuthCredentialTarget.Active, null, null, false); + store.ClaimActiveEpoch(ServerName, currentContext, CancellationToken.None); + + await Assert.ThrowsAsync(async () => + await retiredCache.StoreTokensAsync(Tokens("stale-overwrite", "stale-refresh"), CancellationToken.None)); + + var active = store.GetEnvelopeForTests(ServerName).Active; + Assert.Equal("generation-one", active?.AccessToken.Value); + Assert.Equal(currentContext.OwnerEpoch, active?.CredentialEpoch); + } + + [Fact] + public async Task CrossProcessStaleWriterCannotOverwriteRotatedCredentials() + { + var paths = Paths(); + var seed = CreateStore(paths); + await StoreActiveAsync(seed, Resource, "seed-access"); + var firstProcess = CreateStore(paths); + var staleProcess = CreateStore(paths); + var firstContext = firstProcess.CreateContext(ServerName, Resource, "static-client", false); + var staleContext = staleProcess.CreateContext(ServerName, Resource, "static-client", false); + var firstCache = firstProcess.CreateTokenCache( + ServerName, Resource, firstContext, McpOAuthCredentialTarget.Active, null, null, false); + var staleCache = staleProcess.CreateTokenCache( + ServerName, Resource, staleContext, McpOAuthCredentialTarget.Active, null, null, false); + + await firstCache.StoreTokensAsync(Tokens("rotated-access", "rotated-refresh"), CancellationToken.None); + firstProcess.ClaimActiveEpoch(ServerName, firstContext, CancellationToken.None); + await Assert.ThrowsAsync(async () => + await staleCache.StoreTokensAsync(Tokens("stale-access", "stale-refresh"), CancellationToken.None)); + + var durable = CreateStore(paths).GetEnvelopeForTests(ServerName).Active; + Assert.Equal("rotated-access", durable?.AccessToken.Value); + Assert.Equal("rotated-refresh", durable?.RefreshToken?.Value); + Assert.Equal("rotated-access", staleProcess.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); + } + + [Fact] + public async Task CrossProcessStaleWriterCannotOverwritePromotedCredentials() + { + var paths = Paths(); + var seed = CreateStore(paths); + await StoreActiveAsync(seed, Resource, "old-active"); + var staleProcess = CreateStore(paths); + var staleContext = staleProcess.CreateContext(ServerName, Resource, "static-client", false); + var staleCache = staleProcess.CreateTokenCache( + ServerName, Resource, staleContext, McpOAuthCredentialTarget.Active, null, null, false); + var authorizingProcess = CreateStore(paths); + var authContext = authorizingProcess.CreateContext(ServerName, Resource, "static-client", true); + var pending = authorizingProcess.CreateTokenCache( + ServerName, + Resource, + authContext, + McpOAuthCredentialTarget.Pending, + "promoted-flow", + _time.GetUtcNow().AddMinutes(5), + true); + await pending.StoreTokensAsync(Tokens("promoted-access", "promoted-refresh"), CancellationToken.None); + authorizingProcess.PromotePending(ServerName, authContext, "promoted-flow", CancellationToken.None); + + await Assert.ThrowsAsync(async () => + await staleCache.StoreTokensAsync(Tokens("stale-access", "stale-refresh"), CancellationToken.None)); + + var durable = CreateStore(paths).GetEnvelopeForTests(ServerName).Active; + Assert.Equal("promoted-access", durable?.AccessToken.Value); + Assert.Equal("promoted-refresh", durable?.RefreshToken?.Value); + } + + [Fact] + public async Task DynamicClientIdentityAndSecretSurviveRestartWithoutMetadataFile() + { + var paths = Paths(); + var store = CreateStore(paths); + var context = store.CreateContext(ServerName, Resource, null, false); + context.CaptureDynamicRegistration(new DynamicClientRegistrationResponse + { + ClientId = "dynamic-client", + ClientSecret = "dynamic-secret", + }); + var cache = store.CreateTokenCache( + ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); + await cache.StoreTokensAsync(Tokens("access", "refresh"), CancellationToken.None); + + var restarted = CreateStore(paths); + var restored = restarted.CreateContext(ServerName, Resource, null, false).SnapshotIdentity(); + + Assert.Equal("dynamic-client", restored.ClientId); + Assert.Equal("dynamic-secret", restored.ClientSecret); + Assert.True(restored.DynamicClientRegistration); + Assert.False(File.Exists(Path.Combine(paths.ConfigDirectory, "mcp-oauth-metadata.json"))); + } + + [Fact] + public async Task RawSecretsFileNeverContainsOAuthTokensOrDcrClientSecret() + { + var paths = Paths(); + var protector = SecretsProtection.CreateProtector(paths); + var store = new McpOAuthCredentialStore( + paths, + _time, + protector, + NullLogger.Instance); + var context = store.CreateContext(ServerName, Resource, null, false); + context.CaptureDynamicRegistration(new DynamicClientRegistrationResponse + { + ClientId = "encrypted-client-id", + ClientSecret = "dcr-secret-must-not-leak", + }); + var cache = store.CreateTokenCache( + ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); + + await cache.StoreTokensAsync( + Tokens("access-token-must-not-leak", "refresh-token-must-not-leak"), + CancellationToken.None); + + var raw = File.ReadAllText(paths.SecretsPath); + Assert.DoesNotContain("access-token-must-not-leak", raw, StringComparison.Ordinal); + Assert.DoesNotContain("refresh-token-must-not-leak", raw, StringComparison.Ordinal); + Assert.DoesNotContain("dcr-secret-must-not-leak", raw, StringComparison.Ordinal); + Assert.Contains("ENC:", raw, StringComparison.Ordinal); + var restarted = new McpOAuthCredentialStore( + paths, + _time, + protector, + NullLogger.Instance); + var active = restarted.GetEnvelopeForTests(ServerName).Active; + Assert.Equal("access-token-must-not-leak", active?.AccessToken.Value); + Assert.Equal("refresh-token-must-not-leak", active?.RefreshToken?.Value); + Assert.Equal("dcr-secret-must-not-leak", active?.ClientSecret?.Value); + } + + [Fact] + public async Task LegacyMetadataFileRemainsByteForByteUntouched() + { + var paths = Paths(); + var metadataPath = Path.Combine(paths.ConfigDirectory, "mcp-oauth-metadata.json"); + var original = Encoding.UTF8.GetBytes( + "{\n \"legacy\": { \"clientId\": \"old-client\", \"note\": \"leave exactly as-is\" }\n}\n"); + File.WriteAllBytes(metadataPath, original); + var store = CreateStore(paths); + + await StoreActiveAsync(store, Resource, "new-access"); + _ = CreateStore(paths); + + Assert.Equal(original, File.ReadAllBytes(metadataPath)); + } + + [Theory] + [InlineData("HTTPS://MCP.EXAMPLE.COM:443/tools?tenant=one#fragment")] + [InlineData("https://mcp.example.com/tools?tenant=one")] + public async Task EquivalentResourceSpellingsReturnCredentials(string equivalent) + { + var store = CreateStore(); + await StoreActiveAsync(store, Resource); + + var cache = store.CreateTokenCache( + ServerName, + equivalent, + store.CreateContext(ServerName, equivalent, null, false), + McpOAuthCredentialTarget.Active, + null, + null, + false); + + Assert.Equal("access", (await cache.GetTokensAsync(CancellationToken.None))?.AccessToken); + } + + [Theory] + [InlineData("http://mcp.example.com/tools?tenant=one")] + [InlineData("https://mcp.example.com/other?tenant=one")] + [InlineData("https://mcp.example.com/tools?tenant=two")] + public async Task ChangedResourceWithholdsAllDynamicCredentialsAndPreservesDisk(string changed) + { + var store = CreateStore(); + await StoreDynamicActiveAsync(store, Resource); + + var context = store.CreateContext(ServerName, changed, null, false); + var cache = store.CreateTokenCache( + ServerName, changed, context, McpOAuthCredentialTarget.Active, null, null, false); + + Assert.Null(await cache.GetTokensAsync(CancellationToken.None)); + Assert.Null(context.SnapshotIdentity().ClientId); + Assert.True(store.RequiresAuthorization(ServerName, changed)); + Assert.Equal("access", store.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); + } + + [Fact] + public void StaticClientIdRemainsAuthoritativeAfterResourceChange() + { + var store = CreateStore(); + + var context = store.CreateContext(ServerName, "https://other.example/mcp", "configured-client", false); + + Assert.Equal("configured-client", context.SnapshotIdentity().ClientId); + Assert.False(context.SnapshotIdentity().DynamicClientRegistration); + } + + [Fact] + public async Task LegacyUnboundRecordFailsClosedWithoutStampingBinding() + { + var paths = Paths(); + File.WriteAllText(paths.SecretsPath, """ + { + "McpOAuthTokens": { + "test-server": { + "AccessToken": "legacy-access", + "RefreshToken": "legacy-refresh", + "ClientId": "legacy-client", + "McpServerUrl": "https://mcp.example.com/tools" + } + } + } + """); + var store = CreateStore(paths); + var context = store.CreateContext(ServerName, Resource, null, false); + var cache = store.CreateTokenCache( + ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); + + Assert.Null(await cache.GetTokensAsync(CancellationToken.None)); + Assert.Null(context.SnapshotIdentity().ClientId); + Assert.Null(store.GetEnvelopeForTests(ServerName).Active?.ResourceIdentity); + Assert.Contains("legacy-access", File.ReadAllText(paths.SecretsPath), StringComparison.Ordinal); + } + + [Fact] + public async Task PendingCredentialsPromoteOnlyForMatchingSuccessfulFlow() + { + var store = CreateStore(); + await StoreActiveAsync(store, Resource, "active-access"); + var context = store.CreateContext(ServerName, Resource, "static-client", true); + var pending = store.CreateTokenCache( + ServerName, + Resource, + context, + McpOAuthCredentialTarget.Pending, + "flow-one", + _time.GetUtcNow().AddMinutes(5), + true); + await pending.StoreTokensAsync(Tokens("pending-access", "pending-refresh"), CancellationToken.None); + + Assert.Equal("active-access", store.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); + Assert.Equal("pending-access", store.GetEnvelopeForTests(ServerName).Pending?.Credentials.AccessToken.Value); + + store.PromotePending(ServerName, context, "flow-one", CancellationToken.None); + + Assert.Equal("pending-access", store.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); + Assert.Null(store.GetEnvelopeForTests(ServerName).Pending); + } + + [Fact] + public async Task FailedCandidateRemovesOnlyPendingCredentials() + { + var store = CreateStore(); + await StoreActiveAsync(store, Resource, "active-access"); + var context = store.CreateContext(ServerName, Resource, "static-client", true); + var pending = store.CreateTokenCache( + ServerName, + Resource, + context, + McpOAuthCredentialTarget.Pending, + "failed-flow", + _time.GetUtcNow().AddMinutes(5), + true); + await pending.StoreTokensAsync(Tokens("failed-candidate", null), CancellationToken.None); + + store.RemovePending(ServerName, "failed-flow", CancellationToken.None); + + Assert.Equal("active-access", store.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); + Assert.Null(store.GetEnvelopeForTests(ServerName).Pending); + } + + [Fact] + public async Task RestartPrunesAbandonedPendingWithoutChangingActive() + { + var paths = Paths(); + var store = CreateStore(paths); + await StoreActiveAsync(store, Resource, "active-access"); + var pending = store.CreateTokenCache( + ServerName, + Resource, + store.CreateContext(ServerName, Resource, null, true), + McpOAuthCredentialTarget.Pending, + "abandoned", + _time.GetUtcNow().AddMinutes(5), + true); + await pending.StoreTokensAsync(Tokens("abandoned-access", null), CancellationToken.None); + + var restarted = CreateStore(paths); + + Assert.Equal("active-access", restarted.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); + Assert.Null(restarted.GetEnvelopeForTests(ServerName).Pending); + } + + [Fact] + public async Task RestartKeepsPromotedCredentialsAfterCrashBeforeRuntimePublication() + { + var paths = Paths(); + var store = CreateStore(paths); + await StoreActiveAsync(store, Resource, "old-active"); + using var broker = new McpOAuthFlowBroker(_time, CancellationToken.None); + var flow = broker.StartOrJoin(ServerName).Flow; + var redirectOwner = flow.HandleAuthorizationRedirectAsync( + new Uri("https://auth.example/authorize"), + new Uri("http://127.0.0.1:5199/api/mcp/oauth/callback"), + CancellationToken.None); + flow.DeliverCode("commit-code"); + Assert.Equal("commit-code", await redirectOwner); + var context = store.CreateContext(ServerName, Resource, "static-client", true); + var pending = store.CreateTokenCache( + ServerName, + Resource, + context, + McpOAuthCredentialTarget.Pending, + flow.State, + _time.GetUtcNow().AddMinutes(5), + true); + await pending.StoreTokensAsync(Tokens("promoted-before-crash", "new-refresh"), CancellationToken.None); + broker.BeginCommit(flow); + store.PromotePending(ServerName, context, flow.State, CancellationToken.None); + + // Simulate process loss after durable promotion but before runtime publication/Complete. + var restarted = CreateStore(paths); + + Assert.Equal("promoted-before-crash", restarted.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); + Assert.Null(restarted.GetEnvelopeForTests(ServerName).Pending); + } + + [Fact] + public async Task RejectedDynamicIdentityRemainsWithheldAcrossRepeatedAttempts() + { + var store = CreateStore(); + await StoreDynamicActiveAsync(store, Resource); + store.MarkDynamicIdentityRejected(ServerName, Resource, null, CancellationToken.None); + + var next = store.CreateContext(ServerName, Resource, null, true).SnapshotIdentity(); + var later = store.CreateContext(ServerName, Resource, null, true).SnapshotIdentity(); + + Assert.Null(next.ClientId); + Assert.Null(later.ClientId); + Assert.Equal("dynamic-client", store.GetEnvelopeForTests(ServerName).RejectedDynamicClientId); + } + + [Fact] + public async Task RejectedMarkerNeverDiscardsConfiguredStaticClientId() + { + var store = CreateStore(); + await StoreDynamicActiveAsync(store, Resource); + store.MarkDynamicIdentityRejected(ServerName, Resource, null, CancellationToken.None); + + var context = store.CreateContext(ServerName, Resource, "static-client", true).SnapshotIdentity(); + + Assert.Equal("static-client", context.ClientId); + Assert.False(context.DynamicClientRegistration); + } + + [Fact] + public async Task FailedFlowAfterReplacementCaptureKeepsRejectedMarkerAndWithholdsOldIdentity() + { + var store = CreateStore(); + await StoreDynamicActiveAsync(store, Resource); + store.MarkDynamicIdentityRejected(ServerName, Resource, null, CancellationToken.None); + var context = store.CreateContext(ServerName, Resource, null, true); + + store.CaptureDynamicRegistration( + ServerName, + context, + new DynamicClientRegistrationResponse + { + ClientId = "replacement-client", + ClientSecret = "replacement-secret", + }, + CancellationToken.None); + var pending = store.CreateTokenCache( + ServerName, + Resource, + context, + McpOAuthCredentialTarget.Pending, + "failed-replacement", + _time.GetUtcNow().AddMinutes(5), + true); + await pending.StoreTokensAsync(Tokens("failed-access", "failed-refresh"), CancellationToken.None); + store.RemovePending(ServerName, "failed-replacement", CancellationToken.None); + + var nextAttempt = store.CreateContext(ServerName, Resource, null, true).SnapshotIdentity(); + Assert.Equal("dynamic-client", store.GetEnvelopeForTests(ServerName).RejectedDynamicClientId); + Assert.Equal("replacement-client", context.SnapshotIdentity().ClientId); + Assert.Null(nextAttempt.ClientId); + } + + [Fact] + public async Task PromotedReplacementClearsRejectedMarker() + { + var store = CreateStore(); + await StoreDynamicActiveAsync(store, Resource); + store.MarkDynamicIdentityRejected(ServerName, Resource, null, CancellationToken.None); + var context = store.CreateContext(ServerName, Resource, null, true); + store.CaptureDynamicRegistration( + ServerName, + context, + new DynamicClientRegistrationResponse + { + ClientId = "replacement-client", + ClientSecret = "replacement-secret", + }, + CancellationToken.None); + var pending = store.CreateTokenCache( + ServerName, + Resource, + context, + McpOAuthCredentialTarget.Pending, + "successful-replacement", + _time.GetUtcNow().AddMinutes(5), + true); + await pending.StoreTokensAsync(Tokens("replacement-access", "replacement-refresh"), CancellationToken.None); + + store.PromotePending(ServerName, context, "successful-replacement", CancellationToken.None); + + Assert.Null(store.GetEnvelopeForTests(ServerName).RejectedDynamicClientId); + Assert.Equal("replacement-client", store.GetEnvelopeForTests(ServerName).Active?.ClientId); + } + + private async Task StoreActiveAsync( + McpOAuthCredentialStore store, + string resource, + string accessToken = "access") + { + var context = store.CreateContext(ServerName, resource, "static-client", false); + var cache = store.CreateTokenCache( + ServerName, resource, context, McpOAuthCredentialTarget.Active, null, null, false); + await cache.StoreTokensAsync(Tokens(accessToken, "refresh"), CancellationToken.None); + } + + private async Task StoreDynamicActiveAsync(McpOAuthCredentialStore store, string resource) + { + var context = store.CreateContext(ServerName, resource, null, false); + context.CaptureDynamicRegistration(new DynamicClientRegistrationResponse + { + ClientId = "dynamic-client", + ClientSecret = "dynamic-secret", + }); + var cache = store.CreateTokenCache( + ServerName, resource, context, McpOAuthCredentialTarget.Active, null, null, false); + await cache.StoreTokensAsync(Tokens("access", "refresh"), CancellationToken.None); + } + + private TokenContainer Tokens(string accessToken, string? refreshToken) => new() + { + AccessToken = accessToken, + RefreshToken = refreshToken, + TokenType = "Bearer", + Scope = "read write", + ExpiresIn = 3600, + ObtainedAt = _time.GetUtcNow(), + }; + + private NetclawPaths Paths() + { + var paths = new NetclawPaths(_dir.Path); + paths.EnsureDirectoriesExist(); + return paths; + } + + private McpOAuthCredentialStore CreateStore(NetclawPaths? paths = null) + => new( + paths ?? Paths(), + _time, + new NullSecretsProtector(), + NullLogger.Instance); + + public void Dispose() => _dir.Dispose(); +} diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthFlowBrokerTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthFlowBrokerTests.cs new file mode 100644 index 000000000..87e072d4c --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthFlowBrokerTests.cs @@ -0,0 +1,201 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Time.Testing; +using Netclaw.Daemon.Mcp; +using Netclaw.Tools; +using Xunit; + +namespace Netclaw.Daemon.Tests.Mcp; + +public sealed class McpOAuthFlowBrokerTests +{ + private static readonly McpServerName ServerName = new("oauth-server"); + private static readonly Uri AuthorizationUrl = new("https://auth.example/authorize?client_id=one"); + private static readonly Uri RedirectUri = new("http://127.0.0.1:7331/api/mcp/oauth/callback"); + + [Fact] + public void ConcurrentStartsForOneServerShareOpaqueFlow() + { + using var broker = CreateBroker(); + + var first = broker.StartOrJoin(ServerName); + var second = broker.StartOrJoin(ServerName); + + Assert.True(first.Created); + Assert.False(second.Created); + Assert.Same(first.Flow, second.Flow); + Assert.Equal(43, first.Flow.State.Length); + Assert.DoesNotContain(ServerName.Value, first.Flow.State, StringComparison.Ordinal); + } + + [Fact] + public async Task FirstDelegateOwnsUrlAndCodeFollowersFailWithoutCodeReuse() + { + using var broker = CreateBroker(); + var flow = broker.StartOrJoin(ServerName).Flow; + + var owner = flow.HandleAuthorizationRedirectAsync( + AuthorizationUrl, + RedirectUri, + CancellationToken.None); + var follower = flow.HandleAuthorizationRedirectAsync( + new Uri("https://auth.example/authorize?client_id=two"), + RedirectUri, + CancellationToken.None); + + Assert.Equal(AuthorizationUrl, await flow.WaitForAuthorizationUrlAsync(CancellationToken.None)); + await Assert.ThrowsAsync(async () => await follower); + broker.GetForCallback(flow.State).DeliverCode("owner-code"); + Assert.Equal("owner-code", await owner); + broker.BeginCommit(flow); + broker.Complete(flow); + Assert.Equal(McpOAuthFlowStatus.Completed, broker.GetStatusByState(flow.State).Status); + } + + [Fact] + public void MissingOrMismatchedStateDoesNotAffectPendingFlow() + { + using var broker = CreateBroker(); + var flow = broker.StartOrJoin(ServerName).Flow; + + Assert.Throws(() => broker.GetForCallback("wrong-state")); + + Assert.Same(flow, broker.GetForCallback(flow.State)); + Assert.Equal(McpOAuthFlowStatus.Pending, broker.GetStatusByState(flow.State).Status); + } + + [Fact] + public async Task ReusedStateCannotDeliverCodeTwice() + { + using var broker = CreateBroker(); + var flow = broker.StartOrJoin(ServerName).Flow; + var owner = flow.HandleAuthorizationRedirectAsync(AuthorizationUrl, RedirectUri, CancellationToken.None); + + flow.DeliverCode("one-time-code"); + Assert.Equal("one-time-code", await owner); + + Assert.Throws(() => flow.DeliverCode("reused-code")); + } + + [Fact] + public async Task TimeProviderExpiryCancelsOwnerAndLeavesFailedTombstone() + { + var time = new FakeTimeProvider(new DateTimeOffset(2026, 7, 22, 12, 0, 0, TimeSpan.Zero)); + using var broker = new McpOAuthFlowBroker(time, CancellationToken.None); + var flow = broker.StartOrJoin(ServerName).Flow; + var owner = flow.HandleAuthorizationRedirectAsync(AuthorizationUrl, RedirectUri, CancellationToken.None); + + time.Advance(McpOAuthFlowBroker.FlowLifetime); + + await Assert.ThrowsAnyAsync(async () => await owner); + var terminal = broker.GetStatusByState(flow.State); + Assert.Equal(McpOAuthFlowStatus.Failed, terminal.Status); + Assert.Contains("expired", terminal.Error?.Error, StringComparison.OrdinalIgnoreCase); + Assert.Throws(() => broker.GetForCallback(flow.State)); + } + + [Fact] + public async Task ExpiryAtCommitRejectsPublicationClaim() + { + var time = new FakeTimeProvider(new DateTimeOffset(2026, 7, 22, 12, 0, 0, TimeSpan.Zero)); + using var broker = new McpOAuthFlowBroker(time, CancellationToken.None); + var flow = broker.StartOrJoin(ServerName).Flow; + var owner = flow.HandleAuthorizationRedirectAsync(AuthorizationUrl, RedirectUri, CancellationToken.None); + flow.DeliverCode("burned-code"); + Assert.Equal("burned-code", await owner); + + time.Advance(McpOAuthFlowBroker.FlowLifetime); + + Assert.Throws(() => broker.BeginCommit(flow)); + Assert.Equal(McpOAuthFlowStatus.Failed, broker.GetStatusByState(flow.State).Status); + } + + [Fact] + public async Task ClaimedCommitCannotLoseRaceToExpiry() + { + var time = new FakeTimeProvider(new DateTimeOffset(2026, 7, 22, 12, 0, 0, TimeSpan.Zero)); + using var broker = new McpOAuthFlowBroker(time, CancellationToken.None); + var flow = broker.StartOrJoin(ServerName).Flow; + var owner = flow.HandleAuthorizationRedirectAsync(AuthorizationUrl, RedirectUri, CancellationToken.None); + flow.DeliverCode("commit-code"); + Assert.Equal("commit-code", await owner); + time.Advance(McpOAuthFlowBroker.FlowLifetime - TimeSpan.FromTicks(1)); + + broker.BeginCommit(flow); + time.Advance(TimeSpan.FromTicks(1)); + broker.Complete(flow); + + Assert.Equal(McpOAuthFlowStatus.Completed, broker.GetStatusByState(flow.State).Status); + } + + [Fact] + public async Task StartRequestCancellationDoesNotCancelDaemonOwnedFlow() + { + using var broker = CreateBroker(); + var flow = broker.StartOrJoin(ServerName).Flow; + using var requestCancellation = new CancellationTokenSource(); + var request = flow.WaitForAuthorizationUrlAsync(requestCancellation.Token); + requestCancellation.Cancel(); + + await Assert.ThrowsAnyAsync(async () => await request); + + var owner = flow.HandleAuthorizationRedirectAsync(AuthorizationUrl, RedirectUri, CancellationToken.None); + Assert.Equal(AuthorizationUrl, await flow.WaitForAuthorizationUrlAsync(CancellationToken.None)); + flow.DeliverCode("still-running"); + Assert.Equal("still-running", await owner); + } + + [Fact] + public async Task CallbackRequestCancellationDoesNotCancelExchangeOwner() + { + using var broker = CreateBroker(); + var flow = broker.StartOrJoin(ServerName).Flow; + var owner = flow.HandleAuthorizationRedirectAsync(AuthorizationUrl, RedirectUri, CancellationToken.None); + flow.DeliverCode("delivered"); + Assert.Equal("delivered", await owner); + using var requestCancellation = new CancellationTokenSource(); + var callbackWait = flow.WaitForTerminalAsync(requestCancellation.Token); + requestCancellation.Cancel(); + + await Assert.ThrowsAnyAsync(async () => await callbackWait); + + broker.BeginCommit(flow); + broker.Complete(flow); + Assert.Equal(McpOAuthFlowStatus.Completed, broker.GetStatusByState(flow.State).Status); + } + + [Fact] + public async Task DaemonCancellationCancelsOwnerWithoutReturningCodeToFollower() + { + using var daemonCancellation = new CancellationTokenSource(); + using var broker = new McpOAuthFlowBroker(TimeProvider.System, daemonCancellation.Token); + var flow = broker.StartOrJoin(ServerName).Flow; + var owner = flow.HandleAuthorizationRedirectAsync(AuthorizationUrl, RedirectUri, CancellationToken.None); + var follower = flow.HandleAuthorizationRedirectAsync(AuthorizationUrl, RedirectUri, CancellationToken.None); + + daemonCancellation.Cancel(); + + await Assert.ThrowsAnyAsync(async () => await owner); + await Assert.ThrowsAsync(async () => await follower); + } + + [Fact] + public void TerminalFlowAllowsNewAttemptWithNewStateWhileKeepingOldStatus() + { + using var broker = CreateBroker(); + var oldFlow = broker.StartOrJoin(ServerName).Flow; + broker.Fail(oldFlow, new McpErrorResponse("DCR failed: HTTP 403 Forbidden.", "dynamic client registration", 403)); + + var next = broker.StartOrJoin(ServerName); + + Assert.True(next.Created); + Assert.NotEqual(oldFlow.State, next.Flow.State); + Assert.Equal(403, broker.GetStatusByState(oldFlow.State).Error?.Status); + } + + private static McpOAuthFlowBroker CreateBroker() + => new(TimeProvider.System, CancellationToken.None); +} diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthHeaderConflictTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthHeaderConflictTests.cs deleted file mode 100644 index c4b2abafb..000000000 --- a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthHeaderConflictTests.cs +++ /dev/null @@ -1,277 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Collections.Concurrent; -using System.Net; -using System.Reflection; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Logging.Abstractions; -using Netclaw.Actors.Tools; -using Netclaw.Configuration; -using Netclaw.Daemon.Mcp; -using Netclaw.Providers.OAuth; -using Netclaw.Tests.Utilities; -using Netclaw.Tools; -using Xunit; - -namespace Netclaw.Daemon.Tests.Mcp; - -/// -/// Regression tests for GitHub issue #1350: when an operator configures static -/// headers on an HTTP MCP server, the daemon must NOT block the connection with -/// "Awaiting Auth" even if the server's OAuth discovery probe returns metadata. -/// The probe still runs (caching metadata for fallback), but the blocking gate -/// is skipped so the real connection attempt — with the user's headers — decides. -/// -public sealed class McpOAuthHeaderConflictTests : IDisposable -{ - private readonly DisposableTempDir _dir = new(); - - /// - /// The bug reproduction: server has a static Authorization header AND returns - /// 401 + WWW-Authenticate with resource_metadata on the probe. Without the fix, - /// the connection is blocked with AwaitingAuth instead of using the static header. - /// - [Fact] - public async Task StaticAuthHeader_WhenServerReturnsOAuthMetadata_StillConnects() - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - var ct = cts.Token; - - var entry = new McpServerEntry - { - Transport = "http", - Url = "https://mcp.example.com", - Enabled = true, - Headers = new Dictionary - { - ["Authorization"] = new SensitiveString("Bearer my-static-token-1350"), - }, - }; - - using var manager = CreateManager("mcp-static", entry, CreateDiscoveryClientThatReturnsOAuthHints()); - try - { - await manager.StartAsync(ct); - - var status = manager.GetServerStatuses()[new McpServerName("mcp-static")]; - - Assert.NotEqual(McpConnectionState.AwaitingAuth, status.State); - } - finally - { - await manager.StopAsync(ct); - } - } - - /// - /// Non-Authorization headers (e.g. X-API-Key) also suppress the pre-flight block. - /// The probe still caches metadata, but the real connection attempt decides. - /// - [Fact] - public async Task NonAuthorizationHeader_WhenServerReturnsOAuthMetadata_StillConnects() - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - var ct = cts.Token; - - var entry = new McpServerEntry - { - Transport = "http", - Url = "https://mcp.example.com", - Enabled = true, - Headers = new Dictionary - { - ["X-API-Key"] = new SensitiveString("sk-my-api-key"), - }, - }; - - using var manager = CreateManager("mcp-apikey", entry, CreateDiscoveryClientThatReturnsOAuthHints()); - try - { - await manager.StartAsync(ct); - - var status = manager.GetServerStatuses()[new McpServerName("mcp-apikey")]; - - Assert.NotEqual(McpConnectionState.AwaitingAuth, status.State); - } - finally - { - await manager.StopAsync(ct); - } - } - - /// - /// Positive control: when no OAuth hints are returned by the server, - /// the static auth header works regardless of the bug fix. - /// - [Fact] - public async Task StaticAuthHeader_WhenServerReturnsNoOAuthMetadata_ConnectsNormally() - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - var ct = cts.Token; - - using var noOAuthClient = new HttpClient(new FakeHttpMessageHandler(_ => - new HttpResponseMessage(HttpStatusCode.OK))); - - var entry = new McpServerEntry - { - Transport = "http", - Url = "https://mcp.example.com", - Enabled = true, - Headers = new Dictionary - { - ["Authorization"] = new SensitiveString("Bearer my-static-token-no-oauth"), - }, - }; - - using var manager = CreateManager("mcp-static-no-oauth", entry, noOAuthClient); - try - { - await manager.StartAsync(ct); - - var status = manager.GetServerStatuses()[new McpServerName("mcp-static-no-oauth")]; - Assert.NotEqual(McpConnectionState.AwaitingAuth, status.State); - } - finally - { - await manager.StopAsync(ct); - } - } - - /// - /// Pre-existing cached OAuth tokens should skip discovery even when the server - /// returns aggressive OAuth hints. - /// - [Fact] - public async Task CachedOAuthTokens_AreCheckedBeforeOAuthDiscovery() - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - var ct = cts.Token; - - var entry = new McpServerEntry - { - Transport = "http", - Url = "https://mcp.example.com", - Enabled = true, - }; - - using var manager = CreateManager( - "mcp-with-tokens", entry, CreateDiscoveryClientThatReturnsOAuthHints(), - seedTokens: serverName => - { - return new McpOAuthTokenSet - { - AccessToken = new SensitiveString("cached-access-token"), - RefreshToken = new SensitiveString("cached-refresh-token"), - ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), - }; - }); - - try - { - await manager.StartAsync(ct); - - var status = manager.GetServerStatuses()[new McpServerName("mcp-with-tokens")]; - Assert.NotEqual(McpConnectionState.AwaitingAuth, status.State); - } - finally - { - await manager.StopAsync(ct); - } - } - - /// - /// Builds a with the OAuth service wired to the - /// given discovery . Optionally seeds the - /// token cache via . - /// - private McpClientManager CreateManager( - string serverName, - McpServerEntry entry, - HttpClient oauthHttpClient, - Func? seedTokens = null) - { - var paths = new NetclawPaths(_dir.Path); - paths.EnsureDirectoriesExist(); - - using var pkceHttpClient = new HttpClient(); - var oauthService = new McpOAuthService( - oauthHttpClient, - paths, - TimeProvider.System, - NullLogger.Instance, - new OAuthPkceService(pkceHttpClient), - NullNotificationSink.Instance); - - if (seedTokens is not null) - { - var name = new McpServerName(serverName); - var tokensField = typeof(McpOAuthService) - .GetField("_tokens", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(tokensField); - var cache = (ConcurrentDictionary)tokensField.GetValue(oauthService)!; - cache.TryAdd(name, seedTokens(name)); - } - - return new McpClientManager( - new Dictionary { [serverName] = entry }, - new ToolRegistry(), - new ToolConfig(), - oauthService, - NullNotificationSink.Instance, - TimeProvider.System, - NullLogger.Instance); - } - - private static HttpClient CreateDiscoveryClientThatReturnsOAuthHints() - { - return new HttpClient(new FakeHttpMessageHandler(request => - { - var url = request.RequestUri!.ToString(); - - if (url is "https://mcp.example.com/" or "https://mcp.example.com") - { - var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); - response.Headers.Add("WWW-Authenticate", - "Bearer resource_metadata=\"https://mcp.example.com/oauth/resource-metadata\""); - return response; - } - - if (url == "https://mcp.example.com/oauth/resource-metadata") - { - return JsonResponse(new - { - authorization_servers = new[] { "https://auth.example.com" }, - resource = "https://mcp.example.com/resource" - }); - } - - if (url == "https://mcp.example.com/.well-known/oauth-protected-resource") - { - return JsonResponse(new - { - authorization_servers = new[] { "https://auth.example.com" }, - resource = "https://mcp.example.com/resource" - }); - } - - throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}"); - })); - } - - private static HttpResponseMessage JsonResponse(object body, HttpStatusCode status = HttpStatusCode.OK) - { - return new HttpResponseMessage(status) - { - Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") - }; - } - - public void Dispose() - { - _dir.Dispose(); - } -} diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthServiceTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthServiceTests.cs deleted file mode 100644 index a5685d07a..000000000 --- a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthServiceTests.cs +++ /dev/null @@ -1,174 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Net; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Logging.Abstractions; -using Netclaw.Configuration; -using Netclaw.Configuration.Secrets; -using Netclaw.Daemon.Mcp; -using Netclaw.Providers.OAuth; -using Netclaw.Tests.Utilities; -using Netclaw.Tools; -using Xunit; - -namespace Netclaw.Daemon.Tests.Mcp; - -public sealed class McpOAuthServiceTests : IDisposable -{ - private readonly DisposableTempDir _dir = new(); - - [Fact] - public async Task GetFlowStatusByState_ReauthWithExistingToken_RemainsPending() - { - var service = CreateService( - CreateDiscoveryClient(), - CreatePkceService(JsonResponse(new - { - access_token = "access-token", - refresh_token = "refresh-token", - expires_in = 3600 - }))); - - var entry = CreateHttpEntry(); - - var (_, initialState) = await service.StartAuthorizationFlowAsync(new McpServerName("textforge"), entry, CancellationToken.None); - await service.CompleteAuthorizationAsync("first-code", initialState, CancellationToken.None); - - var (_, reauthState) = await service.StartAuthorizationFlowAsync(new McpServerName("textforge"), entry, CancellationToken.None); - - Assert.Equal(McpOAuthFlowStatus.Pending, service.GetFlowStatusByState(reauthState)); - Assert.Equal(McpOAuthFlowStatus.Pending, service.GetFlowStatus(new McpServerName("textforge"))); - } - - [Fact] - public async Task GetFlowStatusByState_WhenTokenExchangeFails_ReturnsFailed() - { - var service = CreateService( - CreateDiscoveryClient(), - CreatePkceService(JsonResponse(new { error = "invalid_request" }, HttpStatusCode.BadRequest))); - - var (_, state) = await service.StartAuthorizationFlowAsync(new McpServerName("textforge"), CreateHttpEntry(), CancellationToken.None); - - await Assert.ThrowsAsync( - () => service.CompleteAuthorizationAsync("bad-code", state, CancellationToken.None)); - - Assert.Equal(McpOAuthFlowStatus.Failed, service.GetFlowStatusByState(state)); - } - - [Fact] - public async Task LoadTokensFromDisk_survives_encrypted_round_trip() - { - var paths = new NetclawPaths(_dir.Path); - paths.EnsureDirectoriesExist(); - var protector = SecretsProtection.CreateProtector(paths); - SensitiveStringTypeConverter.Protector = protector; - - try - { - // First service: complete auth flow → persist tokens (encrypted) - var service1 = CreateService(CreateDiscoveryClient(), - CreatePkceService(JsonResponse(new - { - access_token = "access-tok", - refresh_token = "refresh-tok", - expires_in = 3600 - })), - protector); - - var entry = CreateHttpEntry(); - var (_, state) = await service1.StartAuthorizationFlowAsync(new McpServerName("notion"), entry, CancellationToken.None); - await service1.CompleteAuthorizationAsync("auth-code", state, CancellationToken.None); - - // Verify tokens were encrypted on disk - var onDisk = File.ReadAllText(paths.SecretsPath); - Assert.Contains("ENC:", onDisk, StringComparison.Ordinal); - Assert.DoesNotContain("access-tok", onDisk, StringComparison.Ordinal); - - // Second service: simulates daemon restart — must load encrypted tokens - var service2 = CreateService(CreateDiscoveryClient(), - CreatePkceService(JsonResponse(new { access_token = "unused" })), - protector); - - var tokenSet = service2.GetTokenSet(new McpServerName("notion")); - Assert.NotNull(tokenSet); - Assert.Equal("access-tok", tokenSet.AccessToken.Value); - Assert.NotNull(tokenSet.RefreshToken); - Assert.Equal("refresh-tok", tokenSet.RefreshToken.Value); - Assert.NotNull(tokenSet.ExpiresAt); - Assert.True(tokenSet.ExpiresAt > DateTimeOffset.UtcNow); - } - finally - { - SensitiveStringTypeConverter.Protector = null; - } - } - - public void Dispose() - { - _dir.Dispose(); - } - - private McpOAuthService CreateService(HttpClient discoveryClient, OAuthPkceService pkceService, - ISecretsProtector? protector = null) - { - return new McpOAuthService( - discoveryClient, - new NetclawPaths(_dir.Path), - TimeProvider.System, - NullLogger.Instance, - pkceService, - NullNotificationSink.Instance, - protector); - } - - private static McpServerEntry CreateHttpEntry() - { - return new McpServerEntry - { - Transport = "http", - Url = "https://mcp.example.com", - OAuthClientId = "test-client" - }; - } - - private static HttpClient CreateDiscoveryClient() - { - return new HttpClient(new FakeHttpMessageHandler(request => request.RequestUri!.ToString() switch - { - "https://mcp.example.com/" or "https://mcp.example.com" => new HttpResponseMessage(HttpStatusCode.Unauthorized), - "https://mcp.example.com/.well-known/oauth-protected-resource" => JsonResponse(new - { - authorization_servers = new[] { "https://auth.example.com" }, - resource = "https://mcp.example.com/resource" - }), - "https://auth.example.com/.well-known/oauth-authorization-server" => JsonResponse(new - { - authorization_endpoint = "https://auth.example.com/authorize", - token_endpoint = "https://auth.example.com/token" - }), - _ => throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}") - })); - } - - private static OAuthPkceService CreatePkceService(HttpResponseMessage tokenResponse) - { - return new OAuthPkceService(new HttpClient(new FakeHttpMessageHandler(request => request.RequestUri!.ToString() switch - { - "https://auth.example.com/token" => tokenResponse, - _ => throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}") - }))); - } - - private static HttpResponseMessage JsonResponse(object body, HttpStatusCode status = HttpStatusCode.OK) - { - return new HttpResponseMessage(status) - { - Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") - }; - } - -} diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpReconnectionServiceTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpReconnectionServiceTests.cs index 34133206c..fcdc7cb5f 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpReconnectionServiceTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpReconnectionServiceTests.cs @@ -191,7 +191,8 @@ public void SetStatus(string name, McpConnectionState state, int toolCount = 0) var serverName = new McpServerName(name); _statuses[serverName] = new McpServerStatus( serverName, state, toolCount, - state == McpConnectionState.Unreachable ? "test error" : null); + state == McpConnectionState.Unreachable ? "test error" : null, + state == McpConnectionState.Unreachable ? DateTimeOffset.Parse("2026-05-01T00:00:00Z") : null); } public IReadOnlyDictionary GetServerStatuses() => _statuses; diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs new file mode 100644 index 000000000..009d460f2 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs @@ -0,0 +1,1663 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using ModelContextProtocol; +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Netclaw.Actors.Tools; +using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; +using Netclaw.Daemon.Mcp; +using Netclaw.Tests.Utilities; +using Netclaw.Tools; +using Xunit; + +namespace Netclaw.Daemon.Tests.Mcp; + +public sealed class McpSdkOAuthFlowIntegrationTests +{ + private static readonly Uri RedirectUri = new("http://127.0.0.1:5199/api/mcp/oauth/callback"); + + [Fact] + public async Task SdkRedirectDelegateFlow_PerformsDiscoveryDcrPkceExchangeAndStoresTokens() + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromMinutes(1)); + var ct = timeout.Token; + + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + var tokenCache = new RecordingTokenCache(); + var broker = OperatorPromptBroker.Authorizing(server, "netclaw-broker-state"); + DynamicClientRegistrationResponse? dcrResponse = null; + + var oauth = new ClientOAuthOptions + { + RedirectUri = RedirectUri, + AdditionalAuthorizationParameters = new Dictionary + { + ["state"] = broker.State, + }, + AuthorizationRedirectDelegate = broker.HandleAuthorizationAsync, + TokenCache = tokenCache, + DynamicClientRegistration = new DynamicClientRegistrationOptions + { + ClientName = "netclaw-oauth-spike", + ResponseDelegate = (response, _) => + { + dcrResponse = response; + return Task.CompletedTask; + }, + }, + }; + + await using var client = await CreateClientAsync(server, oauth, ct); + var tools = await client.ListToolsAsync(cancellationToken: ct); + + Assert.Contains(tools, tool => tool.Name == "oauth_probe"); + + Assert.Equal(1, server.ProtectedResourceDiscoveryCount); + Assert.Equal(1, server.AuthorizationServerDiscoveryCount); + Assert.Equal(1, server.UnauthorizedMcpChallengeCount); + Assert.True(server.AuthorizedMcpRequestCount >= 1); + + var registration = Assert.Single(server.DynamicClientRegistrations); + Assert.Contains(RedirectUri.ToString(), registration.RedirectUris); + Assert.Equal("client_secret_post", registration.RequestedTokenEndpointAuthMethod); + Assert.Equal("fake.read fake.write", registration.Scope); + Assert.NotNull(dcrResponse); + Assert.Equal(registration.ClientId, dcrResponse!.ClientId); + Assert.Equal(registration.ClientSecret, dcrResponse.ClientSecret); + + var authorization = Assert.Single(server.AuthorizationRequests); + Assert.Equal(registration.ClientId, authorization.ClientId); + Assert.Equal(RedirectUri.ToString(), authorization.RedirectUri); + Assert.Equal(server.McpEndpoint.ToString(), authorization.Resource); + Assert.Equal("fake.read fake.write", authorization.Scope); + Assert.Equal(broker.State, authorization.State); + Assert.Equal("S256", authorization.CodeChallengeMethod); + Assert.False(string.IsNullOrWhiteSpace(authorization.CodeChallenge)); + + Assert.Equal(1, broker.DelegateInvocationCount); + Assert.Equal(1, broker.OperatorPromptCount); + Assert.Equal(broker.State, broker.ReturnedState); + Assert.Equal(authorization.Code, broker.ReturnedCode); + Assert.NotNull(broker.DeliveredAuthorizationUrl); + Assert.Equal(broker.State, GetQueryValue(broker.DeliveredAuthorizationUrl!, "state")); + Assert.Equal(authorization.CodeChallenge, GetQueryValue(broker.DeliveredAuthorizationUrl!, "code_challenge")); + Assert.Equal("S256", GetQueryValue(broker.DeliveredAuthorizationUrl!, "code_challenge_method")); + + var tokenRequest = Assert.Single(server.TokenRequests); + Assert.Equal(authorization.Code, tokenRequest.Code); + Assert.Equal(registration.ClientId, tokenRequest.ClientId); + Assert.Equal(registration.ClientSecret, tokenRequest.ClientSecret); + Assert.Equal(RedirectUri.ToString(), tokenRequest.RedirectUri); + Assert.Equal(server.McpEndpoint.ToString(), tokenRequest.Resource); + Assert.True(tokenRequest.PkceVerified); + + var storedTokens = tokenCache.StoredTokens; + var stored = Assert.Single(storedTokens); + Assert.Equal(tokenRequest.IssuedAccessToken, stored.AccessToken); + Assert.Equal(tokenRequest.IssuedRefreshToken, stored.RefreshToken); + Assert.Equal("Bearer", stored.TokenType); + Assert.Equal("fake.read fake.write", stored.Scope); + Assert.Equal(3600, stored.ExpiresIn); + } + + [Fact] + public async Task ProductionFlowFollowerFailsClassifiedWhileOwnerCompletesWithOneCodeExchange() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var flowBroker = new McpOAuthFlowBroker(TimeProvider.System, CancellationToken.None); + var flow = flowBroker.StartOrJoin(new McpServerName("production-flow")).Flow; + var tokenCache = new RecordingTokenCache(); + var oauth = new ClientOAuthOptions + { + RedirectUri = RedirectUri, + AdditionalAuthorizationParameters = new Dictionary + { + ["state"] = flow.State, + }, + AuthorizationRedirectDelegate = flow.HandleAuthorizationRedirectAsync, + TokenCache = tokenCache, + DynamicClientRegistration = new DynamicClientRegistrationOptions + { + ClientName = "netclaw-production-flow-test", + }, + }; + + var owner = CreateClientAsync(server, oauth, ct); + var authorizationUrl = await flow.WaitForAuthorizationUrlAsync(ct); + var follower = CreateClientAsync(server, oauth, ct); + + var followerError = await CaptureExceptionAsync(follower); + Assert.True(ContainsException(followerError)); + Assert.False(owner.IsCompleted); + Assert.Empty(server.TokenRequests); + + var authorization = await server.AuthorizeAsync(authorizationUrl, RedirectUri, ct); + flow.DeliverCode(authorization.Code); + await using var ownerClient = await owner; + var tools = await ownerClient.ListToolsAsync(cancellationToken: ct); + flowBroker.BeginCommit(flow); + flowBroker.Complete(flow); + + Assert.Contains(tools, tool => tool.Name == "oauth_probe"); + var tokenRequest = Assert.Single(server.TokenRequests); + Assert.Equal(authorization.Code, tokenRequest.Code); + Assert.True(tokenRequest.PkceVerified); + Assert.Single(tokenCache.StoredTokens); + Assert.Equal(McpOAuthFlowStatus.Completed, flowBroker.GetStatusByState(flow.State).Status); + } + + [Fact] + public async Task ManagerExplicitAuthorization_PublishesOnlyAfterSdkExchangeAndToolListing() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path, port: 7331); + + var started = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var authorization = await server.AuthorizeAsync( + new Uri(started.AuthorizationUrl), + new Uri("http://127.0.0.1:7331/api/mcp/oauth/callback"), + ct); + var flow = harness.Broker.GetForCallback(started.State); + flow.DeliverCode(authorization.Code); + + var terminal = await flow.WaitForTerminalAsync(ct); + + Assert.True( + terminal.Status is McpOAuthFlowStatus.Completed, + $"{terminal.Error?.Error} :: {harness.Manager.GetServerStatuses()[harness.ServerName].ErrorMessage} :: {harness.Logger.LastException}"); + Assert.Equal(McpConnectionState.Connected, harness.Manager.GetServerStatuses()[harness.ServerName].State); + Assert.Contains("oauth_probe", harness.Manager.GetToolNames(harness.ServerName)); + var active = harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active; + Assert.NotNull(active); + Assert.Equal("client-1", active!.ClientId); + Assert.Equal("secret-client-1", active.ClientSecret?.Value); + Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending); + Assert.Equal("http://127.0.0.1:7331/api/mcp/oauth/callback", server.DynamicClientRegistrations.Single().RedirectUris.Single()); + + await harness.Runtime.LastHttpOptions!.OAuth!.TokenCache!.StoreTokensAsync( + new TokenContainer + { + AccessToken = "refresh-after-publication", + RefreshToken = "rotated-after-publication", + TokenType = "Bearer", + ObtainedAt = TimeProvider.System.GetUtcNow(), + ExpiresIn = 3600, + }, + ct); + Assert.Equal( + "refresh-after-publication", + harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active?.AccessToken.Value); + Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending); + } + + [Fact] + public async Task ConcurrentManagerStartsShareCandidateUrlCredentialWriteAndGeneration() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path); + + var first = harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var second = harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var starts = await Task.WhenAll(first, second); + + Assert.Equal(starts[0], starts[1]); + Assert.Equal(1, harness.Runtime.CreateCount); + + var authorization = await server.AuthorizeAsync( + new Uri(starts[0].AuthorizationUrl), + RedirectUri, + ct); + var flow = harness.Broker.GetForCallback(starts[0].State); + flow.DeliverCode(authorization.Code); + Assert.Equal(McpOAuthFlowStatus.Completed, (await flow.WaitForTerminalAsync(ct)).Status); + + Assert.Single(server.DynamicClientRegistrations); + Assert.Single(server.TokenRequests); + Assert.Equal(1, harness.Manager.GetSnapshot(harness.ServerName)?.Generation); + } + + [Fact] + public async Task FailedExchangeAfterCodeDeliveryRequiresNewStatePkceVerifierAndTokenRequest() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path); + server.FailNextTokenExchange(); + var firstStart = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var firstOperation = Assert.IsAssignableFrom( + harness.Manager.GetInteractiveAuthorizationTask(harness.ServerName)); + var firstAuthorization = await server.AuthorizeAsync( + new Uri(firstStart.AuthorizationUrl), + RedirectUri, + ct); + var firstFlow = harness.Broker.GetForCallback(firstStart.State); + firstFlow.DeliverCode(firstAuthorization.Code); + Assert.Equal( + McpOAuthFlowStatus.Failed, + (await firstFlow.WaitForTerminalAsync(ct)).Status); + await firstOperation.WaitAsync(ct); + + var secondStart = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var secondAuthorization = await server.AuthorizeAsync( + new Uri(secondStart.AuthorizationUrl), + RedirectUri, + ct); + var secondFlow = harness.Broker.GetForCallback(secondStart.State); + secondFlow.DeliverCode(secondAuthorization.Code); + Assert.Equal( + McpOAuthFlowStatus.Completed, + (await secondFlow.WaitForTerminalAsync(ct)).Status); + + Assert.NotEqual(firstStart.State, secondStart.State); + var authorizations = server.AuthorizationRequests; + Assert.Equal(2, authorizations.Count); + Assert.NotEqual(authorizations[0].CodeChallenge, authorizations[1].CodeChallenge); + var tokenRequests = server.TokenRequests; + Assert.Equal(2, tokenRequests.Count); + Assert.NotEqual(tokenRequests[0].Code, tokenRequests[1].Code); + Assert.NotEqual(tokenRequests[0].CodeVerifier, tokenRequests[1].CodeVerifier); + Assert.True(tokenRequests[0].PkceVerified); + Assert.True(tokenRequests[1].PkceVerified); + } + + [Fact] + public async Task ReconnectWhileAuthorizationPendingDoesNotCreateCompetingCandidate() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path); + + var started = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var reconnected = await harness.Manager.TryReconnectAsync(harness.ServerName, ct); + + Assert.False(reconnected); + Assert.Equal(1, harness.Runtime.CreateCount); + + var authorization = await server.AuthorizeAsync(new Uri(started.AuthorizationUrl), RedirectUri, ct); + var flow = harness.Broker.GetForCallback(started.State); + flow.DeliverCode(authorization.Code); + Assert.Equal(McpOAuthFlowStatus.Completed, (await flow.WaitForTerminalAsync(ct)).Status); + } + + [Fact] + public async Task RuntimeFlowExpiryRemovesDurablePendingCredentialsWithoutRestart() + { + var ct = TestContext.Current.CancellationToken; + var time = new FakeTimeProvider(new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)); + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path, timeProvider: time); + var barrier = new InitializationBarrier(); + harness.Runtime.InitializationBarrier = barrier; + var started = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var operation = Assert.IsAssignableFrom( + harness.Manager.GetInteractiveAuthorizationTask(harness.ServerName)); + var authorization = await server.AuthorizeAsync(new Uri(started.AuthorizationUrl), RedirectUri, ct); + var flow = harness.Broker.GetForCallback(started.State); + flow.DeliverCode(authorization.Code); + await barrier.Reached.Task.WaitAsync(ct); + Assert.Equal(started.State, harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending?.FlowId); + + time.Advance(McpOAuthFlowBroker.FlowLifetime); + await operation.WaitAsync(ct); + + Assert.Equal(McpOAuthFlowStatus.Failed, harness.Broker.GetStatusByState(started.State).Status); + Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending); + barrier.Release.TrySetResult(true); + } + + [Fact] + public async Task FailedToolListingRemovesPendingAndPreservesActiveCredentials() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using (var first = CreateManagerHarness(server, directory.Path)) + { + await CompleteManagerAuthorizationAsync(server, first, ct); + } + + await using var failing = CreateManagerHarness(server, directory.Path, failToolListing: true); + var oldAccess = failing.Credentials.GetEnvelopeForTests(failing.ServerName).Active!.AccessToken.Value; + var started = await failing.Manager.StartAuthorizationAsync(failing.ServerName, ct); + var authorization = await server.AuthorizeAsync(new Uri(started.AuthorizationUrl), RedirectUri, ct); + var flow = failing.Broker.GetForCallback(started.State); + flow.DeliverCode(authorization.Code); + + var terminal = await flow.WaitForTerminalAsync(ct); + + Assert.Equal(McpOAuthFlowStatus.Failed, terminal.Status); + Assert.Equal(oldAccess, failing.Credentials.GetEnvelopeForTests(failing.ServerName).Active?.AccessToken.Value); + Assert.Null(failing.Credentials.GetEnvelopeForTests(failing.ServerName).Pending); + } + + [Fact] + public async Task FailedExplicitReplacementPreservesSameLiveConnectionAndActiveCredentials() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path); + await CompleteManagerAuthorizationAsync(server, harness, ct); + var published = Assert.IsType(harness.Manager.GetSnapshot(harness.ServerName)); + var active = harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active!; + harness.Runtime.FailNextToolListing(); + + var started = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var authorization = await server.AuthorizeAsync(new Uri(started.AuthorizationUrl), RedirectUri, ct); + var flow = harness.Broker.GetForCallback(started.State); + flow.DeliverCode(authorization.Code); + var terminal = await flow.WaitForTerminalAsync(ct); + + var retained = Assert.IsType(harness.Manager.GetSnapshot(harness.ServerName)); + var retainedCredentials = harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active; + Assert.Equal(McpOAuthFlowStatus.Failed, terminal.Status); + Assert.Same(published.Client, retained.Client); + Assert.Equal(published.Generation, retained.Generation); + Assert.Equal(published.ToolFunctions.Keys, retained.ToolFunctions.Keys); + Assert.Equal(active.AccessToken.Value, retainedCredentials?.AccessToken.Value); + Assert.Equal(active.RefreshToken?.Value, retainedCredentials?.RefreshToken?.Value); + Assert.Equal(active.CredentialEpoch, retainedCredentials?.CredentialEpoch); + Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending); + } + + [Fact] + public async Task PendingStoreConflictReportsCredentialPersistenceTerminalError() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path); + var started = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var competingContext = harness.Credentials.CreateContext( + harness.ServerName, + server.McpEndpoint.ToString(), + null, + true); + var competingCache = harness.Credentials.CreateTokenCache( + harness.ServerName, + server.McpEndpoint.ToString(), + competingContext, + McpOAuthCredentialTarget.Pending, + "competing-flow", + TimeProvider.System.GetUtcNow().AddMinutes(5), + true); + await competingCache.StoreTokensAsync( + new TokenContainer + { + AccessToken = "competing-access", + RefreshToken = "competing-refresh", + TokenType = "Bearer", + ObtainedAt = TimeProvider.System.GetUtcNow(), + ExpiresIn = 3600, + }, + ct); + var authorization = await server.AuthorizeAsync(new Uri(started.AuthorizationUrl), RedirectUri, ct); + var flow = harness.Broker.GetForCallback(started.State); + flow.DeliverCode(authorization.Code); + + var terminal = await flow.WaitForTerminalAsync(ct); + + Assert.Equal(McpOAuthFlowStatus.Failed, terminal.Status); + Assert.Equal("credential persistence", terminal.Error?.Operation); + Assert.DoesNotContain("competing-flow", terminal.Error?.Error, StringComparison.Ordinal); + } + + [Fact] + public async Task PromotionEpochConflictReportsCredentialPersistenceTerminalError() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path); + await CompleteManagerAuthorizationAsync(server, harness, ct); + var publishedCache = harness.Runtime.LastHttpOptions!.OAuth!.TokenCache!; + var barrier = new InitializationBarrier(); + harness.Runtime.InitializationBarrier = barrier; + var started = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var authorization = await server.AuthorizeAsync(new Uri(started.AuthorizationUrl), RedirectUri, ct); + var flow = harness.Broker.GetForCallback(started.State); + flow.DeliverCode(authorization.Code); + await barrier.Reached.Task.WaitAsync(ct); + try + { + await publishedCache.StoreTokensAsync( + new TokenContainer + { + AccessToken = "active-rotated-during-flow", + RefreshToken = "active-refresh-rotated-during-flow", + TokenType = "Bearer", + ObtainedAt = TimeProvider.System.GetUtcNow(), + ExpiresIn = 3600, + }, + ct); + } + finally + { + barrier.Release.TrySetResult(true); + } + + var terminal = await flow.WaitForTerminalAsync(ct); + + Assert.Equal(McpOAuthFlowStatus.Failed, terminal.Status); + Assert.Equal("credential persistence", terminal.Error?.Operation); + Assert.Equal( + "active-rotated-during-flow", + harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active?.AccessToken.Value); + Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending); + } + + [Fact] + public async Task RestartUsesStoredDynamicIdentityWithoutMetadataOrReregistration() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using (var first = CreateManagerHarness(server, directory.Path)) + { + await CompleteManagerAuthorizationAsync(server, first, ct); + } + + await using var restarted = CreateManagerHarness(server, directory.Path); + await restarted.Manager.StartAsync(ct); + + Assert.Equal(McpConnectionState.Connected, restarted.Manager.GetServerStatuses()[restarted.ServerName].State); + Assert.Single(server.DynamicClientRegistrations); + Assert.Equal("client-1", restarted.Runtime.LastHttpOptions?.OAuth?.ClientId); + Assert.Equal("secret-client-1", restarted.Runtime.LastHttpOptions?.OAuth?.ClientSecret); + Assert.False(File.Exists(Path.Combine(directory.Path, "config", "mcp-oauth-metadata.json"))); + } + + [Fact] + public async Task InvalidClientMarkerSurvivesFailedReplacementAndForcesAnotherFreshDcr() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using (var first = CreateManagerHarness(server, directory.Path)) + { + await CompleteManagerAuthorizationAsync(server, first, ct); + } + + server.RejectClient("client-1"); + await using var harness = CreateManagerHarness(server, directory.Path); + var rejectedStart = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var rejectedAuthorization = await server.AuthorizeAsync(new Uri(rejectedStart.AuthorizationUrl), RedirectUri, ct); + var rejectedFlow = harness.Broker.GetForCallback(rejectedStart.State); + rejectedFlow.DeliverCode(rejectedAuthorization.Code); + var rejectedTerminal = await rejectedFlow.WaitForTerminalAsync(ct); + + Assert.Equal(McpOAuthFlowStatus.Failed, rejectedTerminal.Status); + Assert.Equal( + "client-1", + harness.Credentials.GetEnvelopeForTests(harness.ServerName).RejectedDynamicClientId); + + var replacementStart = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + + Assert.Contains("client_id=client-2", replacementStart.AuthorizationUrl, StringComparison.Ordinal); + Assert.Equal(2, server.DynamicClientRegistrations.Count); + Assert.Equal( + "client-1", + harness.Credentials.GetEnvelopeForTests(harness.ServerName).RejectedDynamicClientId); + + server.RejectClient("client-2"); + var replacementAuthorization = await server.AuthorizeAsync( + new Uri(replacementStart.AuthorizationUrl), + RedirectUri, + ct); + var replacementFlow = harness.Broker.GetForCallback(replacementStart.State); + replacementFlow.DeliverCode(replacementAuthorization.Code); + Assert.Equal( + McpOAuthFlowStatus.Failed, + (await replacementFlow.WaitForTerminalAsync(ct)).Status); + Assert.Equal( + "client-1", + harness.Credentials.GetEnvelopeForTests(harness.ServerName).RejectedDynamicClientId); + + var thirdStart = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + Assert.Contains("client_id=client-3", thirdStart.AuthorizationUrl, StringComparison.Ordinal); + Assert.Equal(3, server.DynamicClientRegistrations.Count); + } + + [Fact] + public async Task RepointedProfileWithholdsOldCredentialsAndReportsAwaitingAuth() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using (var first = CreateManagerHarness(server, directory.Path)) + { + await CompleteManagerAuthorizationAsync(server, first, ct); + } + + await using var repointed = CreateManagerHarness( + server, + directory.Path, + endpointOverride: "https://changed-resource.test/mcp"); + await repointed.Manager.StartAsync(ct); + + Assert.Equal( + McpConnectionState.AwaitingAuth, + repointed.Manager.GetServerStatuses()[repointed.ServerName].State); + Assert.Equal( + server.McpEndpoint.ToString(), + repointed.Credentials.GetEnvelopeForTests(repointed.ServerName).Active?.ResourceIdentity); + } + + [Fact] + public async Task LegacyUnboundCredentialsReportAwaitingAuthWithoutBeingStamped() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + var paths = new NetclawPaths(directory.Path); + paths.EnsureDirectoriesExist(); + File.WriteAllText(paths.SecretsPath, """ + { + "McpOAuthTokens": { + "fake-oauth": { + "AccessToken": "legacy-access", + "RefreshToken": "legacy-refresh", + "ClientId": "legacy-client" + } + } + } + """); + await using var harness = CreateManagerHarness(server, directory.Path); + + await harness.Manager.StartAsync(ct); + + Assert.Equal( + McpConnectionState.AwaitingAuth, + harness.Manager.GetServerStatuses()[harness.ServerName].State); + Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active?.ResourceIdentity); + Assert.Contains("legacy-access", File.ReadAllText(paths.SecretsPath), StringComparison.Ordinal); + } + + [Fact] + public async Task ExpiredAccessWithoutRefreshReportsAwaitingAuthRemedy() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + var paths = new NetclawPaths(directory.Path); + paths.EnsureDirectoriesExist(); + var canonical = McpOAuthCredentialStore.CanonicalizeResource(server.McpEndpoint.ToString()); + File.WriteAllText(paths.SecretsPath, $$""" + { + "McpOAuthTokens": { + "fake-oauth": { + "Active": { + "AccessToken": "expired-access", + "ExpiresAt": "2020-01-01T00:00:00+00:00", + "ResourceIdentity": "{{canonical}}" + } + } + } + } + """); + await using var harness = CreateManagerHarness(server, directory.Path); + + await harness.Manager.StartAsync(ct); + + var status = harness.Manager.GetServerStatuses()[harness.ServerName]; + Assert.Equal(McpConnectionState.AwaitingAuth, status.State); + Assert.Contains("netclaw mcp auth fake-oauth", status.ErrorMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task NonInteractiveStartupReportsAwaitingAuthWithoutBrokerFlow() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path); + + await harness.Manager.StartAsync(ct); + + var status = harness.Manager.GetServerStatuses()[harness.ServerName]; + Assert.Equal(McpConnectionState.AwaitingAuth, status.State); + Assert.Contains($"netclaw mcp auth {harness.ServerName.Value}", status.ErrorMessage); + Assert.Equal(McpOAuthFlowStatus.NotStarted, harness.Broker.GetStatus(harness.ServerName)); + } + + [Fact] + public async Task ExplicitAuthorizationRejectsDisabledEntryWithoutFlowOrPublication() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path, enabled: false); + + var error = await Assert.ThrowsAsync(async () => + await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct)); + + Assert.Contains("disabled", error.Error.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal( + McpConnectionState.Disabled, + harness.Manager.GetServerStatuses()[harness.ServerName].State); + Assert.Empty(harness.Manager.GetToolNames(harness.ServerName)); + Assert.Equal(McpOAuthFlowStatus.NotStarted, harness.Broker.GetStatus(harness.ServerName)); + Assert.Equal(0, harness.Runtime.CreateCount); + } + + [Fact] + public async Task NoAuthServerKeepsSdkOAuthDormant() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct, requireOAuth: false); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path); + + await harness.Manager.StartAsync(ct); + + Assert.Equal(McpConnectionState.Connected, harness.Manager.GetServerStatuses()[harness.ServerName].State); + Assert.Equal(0, server.ProtectedResourceDiscoveryCount); + Assert.Equal(0, server.AuthorizationServerDiscoveryCount); + Assert.Empty(server.DynamicClientRegistrations); + Assert.NotNull(harness.Runtime.LastHttpOptions?.OAuth); + } + + [Fact] + public async Task StaticHeadersAndUserAgentRemainAuthoritativeWithDormantOAuth() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync( + ct, + acceptedBearer: "operator-token"); + using var directory = new DisposableTempDir(); + var headers = new Dictionary + { + ["Authorization"] = new("Bearer operator-token"), + ["User-Agent"] = new("operator-agent/9.1"), + ["X-Operator"] = new("kept"), + }; + await using var harness = CreateManagerHarness(server, directory.Path, headers: headers); + + await harness.Manager.StartAsync(ct); + + Assert.Equal(McpConnectionState.Connected, harness.Manager.GetServerStatuses()[harness.ServerName].State); + Assert.Equal("Bearer operator-token", server.LastMcpHeaders["Authorization"]); + Assert.Equal("operator-agent/9.1", server.LastMcpHeaders["User-Agent"]); + Assert.Equal("kept", server.LastMcpHeaders["X-Operator"]); + Assert.Equal(0, server.ProtectedResourceDiscoveryCount); + Assert.Empty(server.DynamicClientRegistrations); + Assert.Null(harness.Runtime.LastHttpOptions?.OAuth); + } + + [Fact] + public async Task ChallengedStaticAuthorizationIsNotReplacedBySdkOAuth() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + using var directory = new DisposableTempDir(); + var headers = new Dictionary + { + ["Authorization"] = new("Bearer operator-token-that-provider-rejects"), + }; + await using var harness = CreateManagerHarness(server, directory.Path, headers: headers); + + await harness.Manager.StartAsync(ct); + + Assert.Equal(McpConnectionState.AuthFailed, harness.Manager.GetServerStatuses()[harness.ServerName].State); + Assert.Equal( + "Bearer operator-token-that-provider-rejects", + server.LastMcpHeaders["Authorization"]); + Assert.Null(harness.Runtime.LastHttpOptions?.OAuth); + Assert.Equal(0, server.ProtectedResourceDiscoveryCount); + Assert.Empty(server.DynamicClientRegistrations); + } + + [Fact] + public async Task BodylessDcr403ProducesStructuredTerminalError() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct, rejectDcrWithoutBody: true); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path); + + var error = await Assert.ThrowsAsync(async () => + await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct)); + + Assert.Equal("dynamic client registration", error.Error.Operation); + Assert.Contains("403", error.Error.Error, StringComparison.Ordinal); + Assert.DoesNotContain("token", error.Error.Error, StringComparison.OrdinalIgnoreCase); + Assert.Contains("403", harness.Logger.LastMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ProviderBodySecretsStayInDaemonLogAndOutOfPublicOAuthErrors() + { + const string providerBody = "code=oauth-code access_token=token-value client_secret=secret-value"; + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct, dcrRejectionBody: providerBody); + using var directory = new DisposableTempDir(); + await using var harness = CreateManagerHarness(server, directory.Path); + + var error = await Assert.ThrowsAsync(async () => + await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct)); + var terminal = harness.Broker.GetLatestStatus(harness.ServerName); + + Assert.DoesNotContain("oauth-code", error.Error.Error, StringComparison.Ordinal); + Assert.DoesNotContain("token-value", terminal.Error?.Error, StringComparison.Ordinal); + Assert.DoesNotContain("secret-value", terminal.Error?.Error, StringComparison.Ordinal); + Assert.Contains(harness.Logger.Exceptions, exception => + exception.ToString().Contains("secret-value", StringComparison.Ordinal)); + } + + private static async Task CompleteManagerAuthorizationAsync( + FakeOAuthMcpServer server, + ManagerOAuthHarness harness, + CancellationToken ct) + { + var started = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + var authorization = await server.AuthorizeAsync(new Uri(started.AuthorizationUrl), RedirectUri, ct); + var flow = harness.Broker.GetForCallback(started.State); + flow.DeliverCode(authorization.Code); + Assert.Equal(McpOAuthFlowStatus.Completed, (await flow.WaitForTerminalAsync(ct)).Status); + } + + private static ManagerOAuthHarness CreateManagerHarness( + FakeOAuthMcpServer server, + string basePath, + int port = DaemonConfig.DefaultPort, + bool failToolListing = false, + Dictionary? headers = null, + string? endpointOverride = null, + bool enabled = true, + TimeProvider? timeProvider = null) + { + timeProvider ??= TimeProvider.System; + var paths = new NetclawPaths(basePath); + paths.EnsureDirectoriesExist(); + var credentials = new McpOAuthCredentialStore( + paths, + timeProvider, + new NullSecretsProtector(), + NullLogger.Instance); + var broker = new McpOAuthFlowBroker(timeProvider, CancellationToken.None); + var runtime = new FakeServerMcpRuntime(server, failToolListing); + var logger = new RecordingLogger(); + var serverName = new McpServerName("fake-oauth"); + var manager = new McpClientManager( + new Dictionary + { + [serverName.Value] = new() + { + Enabled = enabled, + Transport = "http", + Url = endpointOverride ?? server.McpEndpoint.ToString(), + Headers = headers, + }, + }, + new ToolRegistry(), + new ToolConfig(), + credentials, + broker, + new DaemonConfig { Port = port }, + NullNotificationSink.Instance, + timeProvider, + runtime, + logger, + new SessionConfig()); + return new ManagerOAuthHarness(manager, credentials, broker, runtime, logger, serverName); + } + + private sealed class ManagerOAuthHarness( + McpClientManager manager, + McpOAuthCredentialStore credentials, + McpOAuthFlowBroker broker, + FakeServerMcpRuntime runtime, + RecordingLogger logger, + McpServerName serverName) : IAsyncDisposable + { + public McpClientManager Manager { get; } = manager; + + public McpOAuthCredentialStore Credentials { get; } = credentials; + + public McpOAuthFlowBroker Broker { get; } = broker; + + public FakeServerMcpRuntime Runtime { get; } = runtime; + + public RecordingLogger Logger { get; } = logger; + + public McpServerName ServerName { get; } = serverName; + + public async ValueTask DisposeAsync() + { + await Manager.StopAsync(CancellationToken.None); + Manager.Dispose(); + Broker.Dispose(); + } + } + + private sealed class RecordingLogger : ILogger + { + public Exception? LastException { get; private set; } + + public string? LastMessage { get; private set; } + + public List Exceptions { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + LastMessage = formatter(state, exception); + if (exception is not null) + { + LastException = exception; + Exceptions.Add(exception); + } + } + } + + private sealed class FakeServerMcpRuntime( + FakeOAuthMcpServer server, + bool failToolListing) : IMcpClientRuntime + { + private int _createCount; + private int _failNextToolListing; + + public int CreateCount => Volatile.Read(ref _createCount); + + public HttpClientTransportOptions? LastHttpOptions { get; private set; } + + public InitializationBarrier? InitializationBarrier { get; set; } + + public void FailNextToolListing() => Interlocked.Exchange(ref _failNextToolListing, 1); + + public IClientTransport CreateHttpTransport(HttpClientTransportOptions options) + { + LastHttpOptions = options; + return new HttpClientTransport(options, server.CreateHttpClient(), ownsHttpClient: true); + } + + public Task CreateAsync( + IClientTransport transport, + McpClientOptions options, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _createCount); + return McpClient.CreateAsync(transport, options, cancellationToken: cancellationToken); + } + + public async ValueTask InitializeAsync( + McpClient client, + CancellationToken cancellationToken) + { + if (failToolListing || Interlocked.Exchange(ref _failNextToolListing, 0) == 1) + throw new InvalidOperationException("Controlled tool listing failure."); + var tools = await client.ListToolsAsync(cancellationToken: cancellationToken); + var barrier = InitializationBarrier; + if (barrier is not null) + { + barrier.Reached.TrySetResult(true); + await barrier.Release.Task.WaitAsync(cancellationToken); + } + return new McpClientInitialization(tools.Cast().ToList()); + } + + public ValueTask InvokeAsync( + AIFunction function, + AIFunctionArguments? arguments, + CancellationToken cancellationToken) + => function.InvokeAsync(arguments, cancellationToken); + + public ValueTask DisposeAsync(McpClient client) => client.DisposeAsync(); + } + + private sealed class InitializationBarrier + { + public TaskCompletionSource Reached { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Release { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private static async Task CreateClientAsync( + FakeOAuthMcpServer server, + ClientOAuthOptions oauth, + CancellationToken ct) + { + var transport = new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = server.McpEndpoint, + Name = "fake-oauth-mcp", + TransportMode = HttpTransportMode.StreamableHttp, + OAuth = oauth, + }, server.CreateHttpClient(), ownsHttpClient: true); + + try + { + return await McpClient.CreateAsync(transport, new McpClientOptions + { + ClientInfo = new Implementation + { + Name = "netclaw-oauth-spike", + Version = "1.0.0", + }, + }, cancellationToken: ct); + } + catch + { + await transport.DisposeAsync(); + throw; + } + } + + private static async Task CaptureExceptionAsync(Task task) + { + try + { + await task; + return null; + } + catch (Exception ex) + { + return ex; + } + } + + private static bool ContainsException(Exception? exception) + where TException : Exception + { + while (exception is not null) + { + if (exception is TException) + return true; + exception = exception.InnerException; + } + + return false; + } + + private static string? GetQueryValue(Uri uri, string name) + => ParseQuery(uri.Query).GetValueOrDefault(name); + + private static IReadOnlyDictionary ParseQuery(string query) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var segment in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = segment.Split('=', 2); + var key = Uri.UnescapeDataString(parts[0].Replace('+', ' ')); + var value = parts.Length == 2 ? Uri.UnescapeDataString(parts[1].Replace('+', ' ')) : string.Empty; + result[key] = value; + } + + return result; + } + + private sealed class OperatorPromptBroker + { + private readonly FakeOAuthMcpServer _server; + private readonly object _sync = new(); + private Task? _authorizationTask; + private int _delegateInvocationCount; + private int _operatorPromptCount; + + private OperatorPromptBroker(FakeOAuthMcpServer server, string state) + { + _server = server; + State = state; + } + + public string State { get; } + + public int DelegateInvocationCount => Volatile.Read(ref _delegateInvocationCount); + + public int OperatorPromptCount => Volatile.Read(ref _operatorPromptCount); + + public Uri? DeliveredAuthorizationUrl { get; private set; } + + public string? ReturnedCode { get; private set; } + + public string? ReturnedState { get; private set; } + + public static OperatorPromptBroker Authorizing(FakeOAuthMcpServer server, string state) + => new(server, state); + + public async Task HandleAuthorizationAsync( + Uri authorizationUri, + Uri redirectUri, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _delegateInvocationCount); + + Task authorizationTask; + lock (_sync) + { + if (_authorizationTask is null) + { + PublishPrompt(authorizationUri); + _authorizationTask = _server.AuthorizeAsync(authorizationUri, redirectUri, cancellationToken); + } + + authorizationTask = _authorizationTask; + } + + var result = await authorizationTask.WaitAsync(cancellationToken); + ReturnedCode = result.Code; + ReturnedState = result.State; + return result.Code; + } + + private void PublishPrompt(Uri authorizationUri) + { + lock (_sync) + { + if (DeliveredAuthorizationUrl is not null) + return; + + DeliveredAuthorizationUrl = authorizationUri; + Interlocked.Increment(ref _operatorPromptCount); + } + } + } + + private sealed class RecordingTokenCache : ITokenCache + { + private readonly List _storedTokens = []; + private readonly object _sync = new(); + private TokenContainer? _current; + + public IReadOnlyList StoredTokens + { + get + { + lock (_sync) + return _storedTokens.Select(Clone).ToList(); + } + } + + public ValueTask GetTokensAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_sync) + return new ValueTask(_current is null ? null : Clone(_current)); + } + + public ValueTask StoreTokensAsync(TokenContainer tokens, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var clone = Clone(tokens); + lock (_sync) + { + _current = clone; + _storedTokens.Add(Clone(tokens)); + } + + return default; + } + + private static TokenContainer Clone(TokenContainer tokens) + => new() + { + AccessToken = tokens.AccessToken, + RefreshToken = tokens.RefreshToken, + ExpiresIn = tokens.ExpiresIn, + ObtainedAt = tokens.ObtainedAt, + Scope = tokens.Scope, + TokenType = tokens.TokenType, + }; + } + + private sealed class FakeOAuthMcpServer : IAsyncDisposable + { + private readonly WebApplication _app; + private readonly FakeOAuthMcpServerState _state; + + private FakeOAuthMcpServer(WebApplication app, FakeOAuthMcpServerState state) + { + _app = app; + _state = state; + } + + public Uri McpEndpoint => _state.McpEndpoint; + + public int ProtectedResourceDiscoveryCount => _state.ProtectedResourceDiscoveryCount; + + public int AuthorizationServerDiscoveryCount => _state.AuthorizationServerDiscoveryCount; + + public int UnauthorizedMcpChallengeCount => _state.UnauthorizedMcpChallengeCount; + + public int AuthorizedMcpRequestCount => _state.AuthorizedMcpRequestCount; + + public IReadOnlyList DynamicClientRegistrations + => _state.DynamicClientRegistrations; + + public IReadOnlyList AuthorizationRequests + => _state.AuthorizationRequests; + + public IReadOnlyList TokenRequests => _state.TokenRequests; + + public IReadOnlyDictionary LastMcpHeaders => _state.LastMcpHeaders; + + public void RejectClient(string clientId) => _state.RejectClient(clientId); + + public void FailNextTokenExchange() => _state.FailNextTokenExchange(); + + public static async Task StartAsync( + CancellationToken ct, + bool requireOAuth = true, + string? acceptedBearer = null, + bool rejectDcrWithoutBody = false, + string? dcrRejectionBody = null) + { + var origin = new Uri("https://oauth-mcp.test"); + var state = new FakeOAuthMcpServerState( + origin, + requireOAuth, + rejectDcrWithoutBody, + dcrRejectionBody); + if (acceptedBearer is not null) + state.AcceptBearer(acceptedBearer); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddSingleton(state); + builder.Services + .AddMcpServer(options => + { + options.ServerInfo = new Implementation + { + Name = "fake-oauth-mcp", + Version = "1.0.0", + }; + options.ServerInstructions = "In-process OAuth MCP fake for SDK integration tests."; + }) + .WithHttpTransport() + .WithTools(); + + var app = builder.Build(); + app.Use(async (ctx, next) => + { + if (!ctx.Request.Path.StartsWithSegments("/mcp")) + { + await next(ctx); + return; + } + + state.RecordMcpHeaders(ctx.Request.Headers); + if (!state.RequireOAuth) + { + await next(ctx); + return; + } + + if (ctx.Request.Headers.TryGetValue("Authorization", out var authorization) + && state.TryAcceptBearer(authorization.ToString())) + { + state.RecordAuthorizedMcpRequest(); + await next(ctx); + return; + } + + state.RecordUnauthorizedMcpChallenge(); + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + ctx.Response.Headers.Append("WWW-Authenticate", + $"Bearer resource_metadata=\"{state.ProtectedResourceMetadataEndpoint}\", scope=\"fake.read fake.write\""); + }); + + Func> registerHandler = state.HandleDynamicClientRegistrationAsync; + Func> tokenHandler = state.HandleTokenAsync; + app.MapGet("/.well-known/oauth-protected-resource/mcp", () => state.HandleProtectedResourceMetadata()); + app.MapGet("/.well-known/oauth-authorization-server", () => state.HandleAuthorizationServerMetadata()); + app.MapPost("/oauth/register", registerHandler); + app.MapGet("/oauth/authorize", (HttpContext ctx) => state.HandleAuthorize(ctx)); + app.MapPost("/oauth/token", tokenHandler); + app.MapMcp("/mcp"); + + await app.StartAsync(ct); + return new FakeOAuthMcpServer(app, state); + } + + public HttpClient CreateHttpClient() + { + var client = _app.GetTestClient(); + client.BaseAddress = _state.Origin; + return client; + } + + public async Task AuthorizeAsync( + Uri authorizationUri, + Uri redirectUri, + CancellationToken ct) + { + using var client = CreateHttpClient(); + using var response = await client.GetAsync(authorizationUri, HttpCompletionOption.ResponseHeadersRead, ct); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var location = response.Headers.Location; + Assert.NotNull(location); + Assert.Equal(redirectUri.GetLeftPart(UriPartial.Path), location!.GetLeftPart(UriPartial.Path)); + var query = ParseQuery(location.Query); + var code = Assert.Contains("code", query); + var state = Assert.Contains("state", query); + return new BrowserAuthorizationResult(code, state); + } + + public async ValueTask DisposeAsync() + { + await _app.StopAsync(CancellationToken.None); + await _app.DisposeAsync(); + } + } + + private sealed class FakeOAuthMcpTools + { + [McpServerTool(Name = "oauth_probe")] + [Description("Returns a deterministic value once OAuth has succeeded.")] + public static string OAuthProbe() => "oauth-ok"; + } + + private sealed class FakeOAuthMcpServerState + { + private readonly ConcurrentDictionary _clients = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _authorizationCodes = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _acceptedAccessTokens = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _rejectedClients = new(StringComparer.Ordinal); + private readonly ConcurrentQueue _registrations = new(); + private readonly ConcurrentQueue _authorizations = new(); + private readonly ConcurrentQueue _tokenRequests = new(); + private int _clientSequence; + private int _codeSequence; + private int _tokenSequence; + private int _protectedResourceDiscoveryCount; + private int _authorizationServerDiscoveryCount; + private int _unauthorizedMcpChallengeCount; + private int _authorizedMcpRequestCount; + private int _failNextTokenExchange; + private IReadOnlyDictionary _lastMcpHeaders = new Dictionary(); + + public FakeOAuthMcpServerState( + Uri origin, + bool requireOAuth, + bool rejectDcrWithoutBody, + string? dcrRejectionBody) + { + Origin = origin; + RequireOAuth = requireOAuth; + RejectDcrWithoutBody = rejectDcrWithoutBody; + DcrRejectionBody = dcrRejectionBody; + McpEndpoint = new Uri(origin, "/mcp"); + ProtectedResourceMetadataEndpoint = new Uri(origin, "/.well-known/oauth-protected-resource/mcp"); + AuthorizationEndpoint = new Uri(origin, "/oauth/authorize"); + TokenEndpoint = new Uri(origin, "/oauth/token"); + RegistrationEndpoint = new Uri(origin, "/oauth/register"); + } + + public Uri Origin { get; } + + public bool RequireOAuth { get; } + + private bool RejectDcrWithoutBody { get; } + + private string? DcrRejectionBody { get; } + + public Uri McpEndpoint { get; } + + public Uri ProtectedResourceMetadataEndpoint { get; } + + private Uri AuthorizationEndpoint { get; } + + private Uri TokenEndpoint { get; } + + private Uri RegistrationEndpoint { get; } + + public int ProtectedResourceDiscoveryCount => Volatile.Read(ref _protectedResourceDiscoveryCount); + + public int AuthorizationServerDiscoveryCount => Volatile.Read(ref _authorizationServerDiscoveryCount); + + public int UnauthorizedMcpChallengeCount => Volatile.Read(ref _unauthorizedMcpChallengeCount); + + public int AuthorizedMcpRequestCount => Volatile.Read(ref _authorizedMcpRequestCount); + + public IReadOnlyList DynamicClientRegistrations => _registrations.ToArray(); + + public IReadOnlyList AuthorizationRequests => _authorizations.ToArray(); + + public IReadOnlyList TokenRequests => _tokenRequests.ToArray(); + + public IReadOnlyDictionary LastMcpHeaders => _lastMcpHeaders; + + public IResult HandleProtectedResourceMetadata() + { + Interlocked.Increment(ref _protectedResourceDiscoveryCount); + return Results.Json(new + { + resource = McpEndpoint.ToString(), + authorization_servers = new[] { Origin.ToString().TrimEnd('/') }, + scopes_supported = new[] { "fake.read", "fake.write" }, + }); + } + + public IResult HandleAuthorizationServerMetadata() + { + Interlocked.Increment(ref _authorizationServerDiscoveryCount); + return Results.Json(new + { + issuer = Origin.ToString().TrimEnd('/'), + authorization_endpoint = AuthorizationEndpoint.ToString(), + token_endpoint = TokenEndpoint.ToString(), + registration_endpoint = RegistrationEndpoint.ToString(), + response_types_supported = new[] { "code" }, + grant_types_supported = new[] { "authorization_code", "refresh_token" }, + token_endpoint_auth_methods_supported = new[] { "client_secret_post" }, + code_challenge_methods_supported = new[] { "S256" }, + }); + } + + public async Task HandleDynamicClientRegistrationAsync(HttpContext context) + { + if (RejectDcrWithoutBody) + return Results.StatusCode(StatusCodes.Status403Forbidden); + if (DcrRejectionBody is not null) + return Results.Text(DcrRejectionBody, statusCode: StatusCodes.Status403Forbidden); + + using var document = await JsonDocument.ParseAsync(context.Request.Body, cancellationToken: context.RequestAborted); + var root = document.RootElement; + var redirectUris = ReadStringArray(root, "redirect_uris"); + var grantTypes = ReadStringArray(root, "grant_types"); + var responseTypes = ReadStringArray(root, "response_types"); + var requestedTokenMethod = ReadOptionalString(root, "token_endpoint_auth_method"); + var scope = ReadOptionalString(root, "scope"); + var clientId = $"client-{Interlocked.Increment(ref _clientSequence)}"; + var clientSecret = $"secret-{clientId}"; + + _clients[clientId] = new RegisteredClient(clientId, clientSecret, redirectUris); + _registrations.Enqueue(new DynamicClientRegistrationObservation( + clientId, + clientSecret, + redirectUris, + grantTypes, + responseTypes, + requestedTokenMethod, + scope)); + + return Results.Json(new + { + client_id = clientId, + client_secret = clientSecret, + client_id_issued_at = 1, + client_secret_expires_at = 0, + redirect_uris = redirectUris, + grant_types = grantTypes, + response_types = responseTypes, + token_endpoint_auth_method = "client_secret_post", + }); + } + + public IResult HandleAuthorize(HttpContext context) + { + var query = context.Request.Query; + var clientId = Required(query, "client_id"); + if (!_clients.TryGetValue(clientId, out var client)) + return Results.BadRequest($"Unknown client_id '{clientId}'."); + + var redirectUri = Required(query, "redirect_uri"); + if (!client.RedirectUris.Contains(redirectUri, StringComparer.Ordinal)) + return Results.BadRequest("redirect_uri was not registered."); + + var codeChallenge = Required(query, "code_challenge"); + var codeChallengeMethod = Required(query, "code_challenge_method"); + var code = $"code-{Interlocked.Increment(ref _codeSequence)}"; + var observation = new AuthorizationObservation( + ClientId: clientId, + RedirectUri: redirectUri, + ResponseType: Required(query, "response_type"), + CodeChallenge: codeChallenge, + CodeChallengeMethod: codeChallengeMethod, + Resource: Optional(query, "resource"), + Scope: Optional(query, "scope"), + State: Optional(query, "state"), + Code: code); + + _authorizationCodes[code] = new AuthorizationCodeRecord( + clientId, + redirectUri, + codeChallenge, + codeChallengeMethod, + observation.Resource, + observation.Scope); + _authorizations.Enqueue(observation); + + var separator = redirectUri.Contains('?', StringComparison.Ordinal) ? '&' : '?'; + var location = $"{redirectUri}{separator}code={Uri.EscapeDataString(code)}"; + if (!string.IsNullOrEmpty(observation.State)) + location += $"&state={Uri.EscapeDataString(observation.State)}"; + + return Results.Redirect(location); + } + + public async Task HandleTokenAsync(HttpContext context) + { + var form = await context.Request.ReadFormAsync(context.RequestAborted); + var grantType = form["grant_type"].ToString(); + if (!string.Equals(grantType, "authorization_code", StringComparison.Ordinal)) + return Results.BadRequest("Only authorization_code is supported by the fake server."); + + var code = form["code"].ToString(); + if (!_authorizationCodes.TryGetValue(code, out var authorizationCode)) + return Results.BadRequest("Unknown code."); + + var clientId = form["client_id"].ToString(); + if (_rejectedClients.ContainsKey(clientId)) + return Results.BadRequest(new { error = "invalid_client" }); + if (!_clients.TryGetValue(clientId, out var client)) + return Results.BadRequest("Unknown client_id."); + + var clientSecret = form["client_secret"].ToString(); + if (!string.Equals(clientSecret, client.ClientSecret, StringComparison.Ordinal)) + return Results.BadRequest("Invalid client_secret."); + + var redirectUri = form["redirect_uri"].ToString(); + var codeVerifier = form["code_verifier"].ToString(); + var pkceVerified = string.Equals( + authorizationCode.CodeChallenge, + ComputeCodeChallenge(codeVerifier), + StringComparison.Ordinal); + var issuedAccessToken = $"access-{Interlocked.Increment(ref _tokenSequence)}"; + var issuedRefreshToken = $"refresh-{_tokenSequence}"; + + var observation = new TokenRequestObservation( + ClientId: clientId, + ClientSecret: clientSecret, + Code: code, + RedirectUri: redirectUri, + CodeVerifier: codeVerifier, + Resource: form["resource"].ToString(), + PkceVerified: pkceVerified, + IssuedAccessToken: issuedAccessToken, + IssuedRefreshToken: issuedRefreshToken); + _tokenRequests.Enqueue(observation); + + if (!string.Equals(clientId, authorizationCode.ClientId, StringComparison.Ordinal) + || !string.Equals(redirectUri, authorizationCode.RedirectUri, StringComparison.Ordinal) + || !pkceVerified + || !authorizationCode.TryUse()) + { + return Results.BadRequest("Invalid authorization code exchange."); + } + + if (Interlocked.Exchange(ref _failNextTokenExchange, 0) == 1) + return Results.StatusCode(StatusCodes.Status500InternalServerError); + + _acceptedAccessTokens[issuedAccessToken] = 0; + return Results.Json(new + { + access_token = issuedAccessToken, + refresh_token = issuedRefreshToken, + token_type = "Bearer", + expires_in = 3600, + scope = authorizationCode.Scope, + }); + } + + public bool TryAcceptBearer(string authorizationHeader) + { + const string prefix = "Bearer "; + if (!authorizationHeader.StartsWith(prefix, StringComparison.Ordinal)) + return false; + + var token = authorizationHeader[prefix.Length..]; + return _acceptedAccessTokens.ContainsKey(token); + } + + public void AcceptBearer(string token) => _acceptedAccessTokens[token] = 0; + + public void RejectClient(string clientId) => _rejectedClients[clientId] = 0; + + public void FailNextTokenExchange() => Interlocked.Exchange(ref _failNextTokenExchange, 1); + + public void RecordMcpHeaders(IHeaderDictionary headers) + => _lastMcpHeaders = headers.ToDictionary( + pair => pair.Key, + pair => pair.Value.ToString(), + StringComparer.OrdinalIgnoreCase); + + public void RecordUnauthorizedMcpChallenge() + => Interlocked.Increment(ref _unauthorizedMcpChallengeCount); + + public void RecordAuthorizedMcpRequest() + => Interlocked.Increment(ref _authorizedMcpRequestCount); + + private static IReadOnlyList ReadStringArray(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var property) || property.ValueKind is not JsonValueKind.Array) + return []; + + return property.EnumerateArray() + .Select(value => value.GetString()) + .Where(value => value is not null) + .Select(value => value!) + .ToList(); + } + + private static string? ReadOptionalString(JsonElement root, string propertyName) + => root.TryGetProperty(propertyName, out var property) && property.ValueKind is JsonValueKind.String + ? property.GetString() + : null; + + private static string Required(IQueryCollection query, string name) + { + var value = Optional(query, name); + if (string.IsNullOrEmpty(value)) + throw new InvalidOperationException($"Missing required query parameter '{name}'."); + + return value; + } + + private static string? Optional(IQueryCollection query, string name) + => query.TryGetValue(name, out var values) ? values.ToString() : null; + + private static string ComputeCodeChallenge(string codeVerifier) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier)); + return Convert.ToBase64String(hash) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + } + + private sealed record RegisteredClient( + string ClientId, + string ClientSecret, + IReadOnlyList RedirectUris); + + private sealed class AuthorizationCodeRecord( + string clientId, + string redirectUri, + string codeChallenge, + string codeChallengeMethod, + string? resource, + string? scope) + { + private int _used; + + public string ClientId { get; } = clientId; + + public string RedirectUri { get; } = redirectUri; + + public string CodeChallenge { get; } = codeChallenge; + + public string CodeChallengeMethod { get; } = codeChallengeMethod; + + public string? Resource { get; } = resource; + + public string? Scope { get; } = scope; + + public bool TryUse() => Interlocked.CompareExchange(ref _used, 1, 0) == 0; + } + + private sealed record BrowserAuthorizationResult(string Code, string? State); + + private sealed record DynamicClientRegistrationObservation( + string ClientId, + string ClientSecret, + IReadOnlyList RedirectUris, + IReadOnlyList GrantTypes, + IReadOnlyList ResponseTypes, + string? RequestedTokenEndpointAuthMethod, + string? Scope); + + private sealed record AuthorizationObservation( + string ClientId, + string RedirectUri, + string ResponseType, + string CodeChallenge, + string CodeChallengeMethod, + string? Resource, + string? Scope, + string? State, + string Code); + + private sealed record TokenRequestObservation( + string ClientId, + string ClientSecret, + string Code, + string RedirectUri, + string CodeVerifier, + string Resource, + bool PkceVerified, + string IssuedAccessToken, + string IssuedRefreshToken); +} diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpSmokeHarness.cs b/src/Netclaw.Daemon.Tests/Mcp/McpSmokeHarness.cs index 75760d66d..b627d4421 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpSmokeHarness.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpSmokeHarness.cs @@ -6,8 +6,8 @@ using Microsoft.Extensions.Logging.Abstractions; using Netclaw.Actors.Tools; using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; using Netclaw.Daemon.Mcp; -using Netclaw.Providers.OAuth; using Netclaw.Tools; namespace Netclaw.Daemon.Tests.Mcp; @@ -21,14 +21,12 @@ namespace Netclaw.Daemon.Tests.Mcp; /// internal sealed class McpSmokeHarness : IAsyncDisposable { - private readonly HttpClient _pkceHttp; - private readonly HttpClient _oauthHttp; + private readonly McpOAuthFlowBroker _flowBroker; - private McpSmokeHarness(McpClientManager manager, HttpClient pkceHttp, HttpClient oauthHttp) + private McpSmokeHarness(McpClientManager manager, McpOAuthFlowBroker flowBroker) { Manager = manager; - _pkceHttp = pkceHttp; - _oauthHttp = oauthHttp; + _flowBroker = flowBroker; } public McpClientManager Manager { get; } @@ -37,31 +35,33 @@ public static McpSmokeHarness Create( Dictionary serverEntries, ToolRegistry registry) { - var pkceHttp = new HttpClient(); - var oauthHttp = new HttpClient(); - var oauthService = new McpOAuthService( - oauthHttp, - new NetclawPaths(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())), + var paths = new NetclawPaths(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); + paths.EnsureDirectoriesExist(); + var credentials = new McpOAuthCredentialStore( + paths, TimeProvider.System, - NullLogger.Instance, - new OAuthPkceService(pkceHttp), - NullNotificationSink.Instance); + new NullSecretsProtector(), + NullLogger.Instance); + var flowBroker = new McpOAuthFlowBroker(TimeProvider.System, CancellationToken.None); var manager = new McpClientManager( serverEntries, registry, new ToolConfig(), - oauthService, + credentials, + flowBroker, + new DaemonConfig(), NullNotificationSink.Instance, TimeProvider.System, - NullLogger.Instance); - return new McpSmokeHarness(manager, pkceHttp, oauthHttp); + new McpClientRuntime(), + NullLogger.Instance, + new SessionConfig()); + return new McpSmokeHarness(manager, flowBroker); } public async ValueTask DisposeAsync() { await Manager.StopAsync(CancellationToken.None); Manager.Dispose(); - _pkceHttp.Dispose(); - _oauthHttp.Dispose(); + _flowBroker.Dispose(); } } diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpTokenCacheAdapterTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpTokenCacheAdapterTests.cs deleted file mode 100644 index 864639b35..000000000 --- a/src/Netclaw.Daemon.Tests/Mcp/McpTokenCacheAdapterTests.cs +++ /dev/null @@ -1,126 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Collections.Concurrent; -using ModelContextProtocol.Authentication; -using Netclaw.Configuration; -using Netclaw.Daemon.Mcp; -using Netclaw.Tools; -using Xunit; - -namespace Netclaw.Daemon.Tests.Mcp; - -public sealed class McpTokenCacheAdapterTests -{ - private readonly ConcurrentDictionary _tokens = new(); - private int _persistCallCount; - - private McpTokenCacheAdapter CreateAdapter(string serverName = "test-server") - { - return new McpTokenCacheAdapter( - new McpServerName(serverName), - _tokens, - () => Interlocked.Increment(ref _persistCallCount), - TimeProvider.System); - } - - [Fact] - public async Task StoreAndRetrieve_RoundTrips() - { - var adapter = CreateAdapter(); - - var stored = new TokenContainer - { - TokenType = "Bearer", - AccessToken = "access-123", - RefreshToken = "refresh-456", - ExpiresIn = 3600, - ObtainedAt = DateTimeOffset.UtcNow, - }; - - await adapter.StoreTokensAsync(stored, CancellationToken.None); - var retrieved = await adapter.GetTokensAsync(CancellationToken.None); - - Assert.NotNull(retrieved); - Assert.Equal("Bearer", retrieved.TokenType); - Assert.Equal("access-123", retrieved.AccessToken); - Assert.Equal("refresh-456", retrieved.RefreshToken); - Assert.NotNull(retrieved.ExpiresIn); - Assert.True(retrieved.ExpiresIn > 0); - } - - [Fact] - public async Task GetTokensAsync_WhenEmpty_ReturnsNull() - { - var adapter = CreateAdapter(); - - var result = await adapter.GetTokensAsync(CancellationToken.None); - - Assert.Null(result); - } - - [Fact] - public async Task StoreTokensAsync_CallsPersist() - { - var adapter = CreateAdapter(); - - await adapter.StoreTokensAsync(new TokenContainer - { - TokenType = "Bearer", - AccessToken = "tok", - ObtainedAt = DateTimeOffset.UtcNow, - }, CancellationToken.None); - - Assert.Equal(1, Volatile.Read(ref _persistCallCount)); - } - - [Fact] - public async Task StoreTokensAsync_PreservesExistingClientIdAndUrl() - { - _tokens[new McpServerName("test-server")] = new McpOAuthTokenSet - { - AccessToken = new SensitiveString("old-token"), - ClientId = "my-client-id", - McpServerUrl = "https://mcp.example.com", - }; - - var adapter = CreateAdapter(); - - await adapter.StoreTokensAsync(new TokenContainer - { - TokenType = "Bearer", - AccessToken = "new-token", - ObtainedAt = DateTimeOffset.UtcNow, - }, CancellationToken.None); - - var tokenSet = _tokens[new McpServerName("test-server")]; - Assert.Equal("new-token", tokenSet.AccessToken.Value); - Assert.Equal("my-client-id", tokenSet.ClientId); - Assert.Equal("https://mcp.example.com", tokenSet.McpServerUrl); - } - - [Fact] - public async Task StoreTokensAsync_PreservesExistingRefreshTokenWhenResponseOmitsIt() - { - _tokens[new McpServerName("test-server")] = new McpOAuthTokenSet - { - AccessToken = new SensitiveString("old-token"), - RefreshToken = new SensitiveString("existing-refresh"), - }; - - var adapter = CreateAdapter(); - - await adapter.StoreTokensAsync(new TokenContainer - { - TokenType = "Bearer", - AccessToken = "new-token", - ObtainedAt = DateTimeOffset.UtcNow, - }, CancellationToken.None); - - var tokenSet = _tokens[new McpServerName("test-server")]; - Assert.Equal("new-token", tokenSet.AccessToken.Value); - Assert.Equal("existing-refresh", tokenSet.RefreshToken?.Value); - } -} diff --git a/src/Netclaw.Daemon.Tests/Netclaw.Daemon.Tests.csproj b/src/Netclaw.Daemon.Tests/Netclaw.Daemon.Tests.csproj index 37f0164d5..05dec6c04 100644 --- a/src/Netclaw.Daemon.Tests/Netclaw.Daemon.Tests.csproj +++ b/src/Netclaw.Daemon.Tests/Netclaw.Daemon.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Netclaw.Daemon.Tests/Security/BootstrapDeviceSeederTests.cs b/src/Netclaw.Daemon.Tests/Security/BootstrapDeviceSeederTests.cs index 6019c647c..b68dec90c 100644 --- a/src/Netclaw.Daemon.Tests/Security/BootstrapDeviceSeederTests.cs +++ b/src/Netclaw.Daemon.Tests/Security/BootstrapDeviceSeederTests.cs @@ -96,4 +96,79 @@ public async Task EnsureSeededAsync_skips_for_local_mode() Assert.False(seeded); Assert.Empty(await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken)); } + + [Fact] + public async Task EnsureSeededAsync_rolls_back_device_when_token_transaction_does_not_commit() + { + File.WriteAllText(_paths.SecretsPath, """{"Other":"ENC:trigger"}"""); + var seeder = new BootstrapDeviceSeeder( + _paths, + _deviceRegistry, + _bootstrapStateStore, + _time, + NullLogger.Instance, + new DeviceTokenAppearsDuringPrecheckProtector(_paths)); + + var seeded = await seeder.EnsureSeededAsync( + new DaemonConfig { ExposureMode = ExposureMode.ReverseProxy }, + TestContext.Current.CancellationToken); + + Assert.False(seeded); + Assert.Empty(await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken)); + + File.Delete(_paths.SecretsPath); + var retrySeeded = await _sut.EnsureSeededAsync( + new DaemonConfig { ExposureMode = ExposureMode.ReverseProxy }, + TestContext.Current.CancellationToken); + + Assert.True(retrySeeded); + Assert.Single(await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EnsureSeededAsync_rolls_back_device_when_token_persistence_throws() + { + var seeder = new BootstrapDeviceSeeder( + _paths, + _deviceRegistry, + _bootstrapStateStore, + _time, + NullLogger.Instance, + new ThrowingProtectSecretsProtector()); + + await Assert.ThrowsAsync(() => seeder.EnsureSeededAsync( + new DaemonConfig { ExposureMode = ExposureMode.ReverseProxy }, + TestContext.Current.CancellationToken)); + + Assert.Empty(await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken)); + + var retrySeeded = await _sut.EnsureSeededAsync( + new DaemonConfig { ExposureMode = ExposureMode.ReverseProxy }, + TestContext.Current.CancellationToken); + + Assert.True(retrySeeded); + Assert.Single(await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken)); + } + + private sealed class DeviceTokenAppearsDuringPrecheckProtector(NetclawPaths paths) : ISecretsProtector + { + private int _writesRemaining = 1; + + public string Protect(string plaintext) => $"ENC:{plaintext}"; + + public string Unprotect(string ciphertext) + { + if (Interlocked.Exchange(ref _writesRemaining, 0) == 1) + File.WriteAllText(paths.SecretsPath, """{"DeviceToken":"winner"}"""); + + return ciphertext; + } + } + + private sealed class ThrowingProtectSecretsProtector : ISecretsProtector + { + public string Protect(string plaintext) => throw new InvalidOperationException("secret persistence failed"); + + public string Unprotect(string ciphertext) => ciphertext; + } } diff --git a/src/Netclaw.Daemon/Mcp/McpClientManager.cs b/src/Netclaw.Daemon/Mcp/McpClientManager.cs index 7f0e3c08e..3131c57f5 100644 --- a/src/Netclaw.Daemon/Mcp/McpClientManager.cs +++ b/src/Netclaw.Daemon/Mcp/McpClientManager.cs @@ -4,9 +4,13 @@ // // ----------------------------------------------------------------------- using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Net; +using System.Runtime.ExceptionServices; using Microsoft.Extensions.AI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using ModelContextProtocol; using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using Netclaw.Actors.Tools; @@ -17,41 +21,70 @@ namespace Netclaw.Daemon.Mcp; internal sealed class McpClientManager : IHostedService, IDisposable, IMcpToolInvoker, IMcpReconnectable { + internal static readonly TimeSpan ShutdownDrainTimeout = TimeSpan.FromSeconds(10); + private readonly Dictionary _serverEntries; private readonly ToolRegistry _toolRegistry; private readonly ToolConfig _toolConfig; - private readonly McpOAuthService _oauthService; + private readonly McpOAuthCredentialStore _credentialStore; + private readonly McpOAuthFlowBroker _flowBroker; + private readonly DaemonConfig _daemonConfig; private readonly IOperationalNotificationSink _notificationSink; private readonly TimeProvider _timeProvider; + private readonly IMcpClientRuntime _clientRuntime; private readonly ILogger _logger; private readonly int _maxToolDescriptionChars; private readonly int _maxToolSchemaWarnChars; - - private readonly ConcurrentDictionary _clients = new(); - - private readonly ConcurrentDictionary> _sharedToolFunctions = new(); - - private readonly ConcurrentDictionary _statuses = new(); + private readonly ConcurrentDictionary _servers = new(); + private readonly CancellationTokenSource _lifetimeCancellation = new(); + private readonly object _shutdownSync = new(); + private bool _stopping; + private bool _disposed; + private Task? _stopTask; public McpClientManager( Dictionary serverEntries, ToolRegistry toolRegistry, ToolConfig toolConfig, - McpOAuthService oauthService, + McpOAuthCredentialStore credentialStore, + McpOAuthFlowBroker flowBroker, + DaemonConfig daemonConfig, IOperationalNotificationSink notificationSink, TimeProvider timeProvider, + IMcpClientRuntime clientRuntime, ILogger logger, - SessionConfig? sessionConfig = null) + SessionConfig sessionConfig) { _serverEntries = serverEntries; _toolRegistry = toolRegistry; _toolConfig = toolConfig; - _oauthService = oauthService; + _credentialStore = credentialStore; + _flowBroker = flowBroker; + _daemonConfig = daemonConfig; _notificationSink = notificationSink; _timeProvider = timeProvider; + _clientRuntime = clientRuntime; _logger = logger; - _maxToolDescriptionChars = sessionConfig?.Tuning.MaxToolDescriptionChars ?? 0; - _maxToolSchemaWarnChars = sessionConfig?.Tuning.MaxToolSchemaWarnChars ?? 0; + _maxToolDescriptionChars = sessionConfig.Tuning.MaxToolDescriptionChars; + _maxToolSchemaWarnChars = sessionConfig.Tuning.MaxToolSchemaWarnChars; + + foreach (var (name, entry) in serverEntries) + { + var serverName = new McpServerName(name); + var status = entry.Enabled + ? new McpServerStatus(serverName, McpConnectionState.Unreachable, 0, "Not connected.", null) + : new McpServerStatus(serverName, McpConnectionState.Disabled, 0, null, null); + _servers[serverName] = new McpServerLifecycle(McpServerSnapshot.WithoutConnection(status)); + } + } + + internal bool IsStopping + { + get + { + lock (_shutdownSync) + return _stopping; + } } public async Task StartAsync(CancellationToken cancellationToken) @@ -59,68 +92,136 @@ public async Task StartAsync(CancellationToken cancellationToken) foreach (var (name, entry) in _serverEntries) { var serverName = new McpServerName(name); + var lifecycle = _servers[serverName]; if (!entry.Enabled) { - _statuses[serverName] = new McpServerStatus(serverName, McpConnectionState.Disabled, 0, null); _logger.LogInformation("MCP server '{Name}' is disabled, skipping", name); continue; } - await ConnectAsync(serverName, entry, cancellationToken); + var observed = lifecycle.Snapshot; + if (observed is not null) + await ReconnectAsync(lifecycle, entry, observed, cancellationToken, null); } } - public async Task StopAsync(CancellationToken cancellationToken) + public Task StopAsync(CancellationToken cancellationToken) { - foreach (var (name, client) in _clients) + lock (_shutdownSync) + { + if (_stopTask is not null) + return _stopTask; + + _stopping = true; + _lifetimeCancellation.Cancel(); + _stopTask = StopCoreAsync(cancellationToken); + return _stopTask; + } + } + + private async Task StopCoreAsync(CancellationToken cancellationToken) + { + var failures = new List(); + foreach (var (serverName, lifecycle) in _servers) { try { - await client.DisposeAsync(); - _logger.LogInformation("MCP client '{Name}' shut down", name.Value); + await StopServerAsync(serverName, lifecycle, cancellationToken); } catch (Exception ex) { - _logger.LogWarning(ex, "Error shutting down MCP client '{Name}'", name.Value); + failures.Add(ex); } } - _clients.Clear(); - _sharedToolFunctions.Clear(); + if (failures.Count == 1) + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + if (failures.Count > 1) + throw new AggregateException("One or more MCP clients failed during shutdown.", failures); } public McpClient? GetClient(McpServerName serverName) + => _servers.GetValueOrDefault(serverName)?.Snapshot?.Client; + + public IReadOnlyDictionary GetServerStatuses() { - return _clients.GetValueOrDefault(serverName); + var statuses = _servers + .Select(pair => (pair.Key, Snapshot: pair.Value.Snapshot)) + .Where(pair => pair.Snapshot is not null) + .ToDictionary(pair => pair.Key, pair => pair.Snapshot!.Status); + return new ReadOnlyDictionary(statuses); } - public IReadOnlyDictionary GetServerStatuses() => _statuses; - - /// - /// Returns discovered tool names for a connected MCP server. - /// public IReadOnlyList GetToolNames(McpServerName serverName) { - if (!_sharedToolFunctions.TryGetValue(serverName, out var tools)) - return []; - - return tools.Keys.Order(StringComparer.Ordinal).ToList(); + var snapshot = _servers.GetValueOrDefault(serverName)?.Snapshot; + return snapshot is null + ? [] + : snapshot.ToolFunctions.Keys.Order(StringComparer.Ordinal).ToList(); } + internal McpServerSnapshot? GetSnapshot(McpServerName serverName) + => _servers.GetValueOrDefault(serverName)?.Snapshot; + + internal int GetTrackedOwnerCount(McpServerName serverName) + => _servers.GetValueOrDefault(serverName)?.TrackedOwnerCount ?? 0; + + internal Task? GetInteractiveAuthorizationTask(McpServerName serverName) + => _servers.GetValueOrDefault(serverName)?.InteractiveAuthorization; + public async Task TryReconnectAsync(McpServerName serverName, CancellationToken ct = default) { if (!_serverEntries.TryGetValue(serverName.Value, out var entry) || !entry.Enabled) return false; - if (_clients.TryRemove(serverName, out var existing)) + if (!_servers.TryGetValue(serverName, out var lifecycle) || lifecycle.Snapshot is not { } observed) + return false; + + return await ReconnectAsync(lifecycle, entry, observed, ct, null); + } + + internal async Task StartAuthorizationAsync( + McpServerName serverName, + CancellationToken requestCancellation) + { + if (!_serverEntries.TryGetValue(serverName.Value, out var entry)) + throw new KeyNotFoundException($"MCP server '{serverName.Value}' not found."); + if (!entry.Enabled) { - try { await existing.DisposeAsync(); } - catch (Exception ex) { _logger.LogDebug(ex, "Error disposing MCP client '{Name}' during reconnect", serverName.Value); } + throw new McpOAuthOperationException(new McpErrorResponse( + $"MCP server '{serverName.Value}' is disabled. Enable it before starting OAuth.", + "authorization start")); } + if (entry.Transport is "stdio" || string.IsNullOrWhiteSpace(entry.Url)) + throw new InvalidOperationException( + $"MCP server '{serverName.Value}' has no URL (OAuth requires HTTP transport)."); + if (HasConfiguredAuthorizationHeader(entry)) + { + throw new McpOAuthOperationException(new McpErrorResponse( + $"MCP server '{serverName.Value}' uses an operator-configured Authorization header. Remove it before starting OAuth.", + "authorization start")); + } + if (!_servers.TryGetValue(serverName, out var lifecycle)) + throw new InvalidOperationException($"MCP server '{serverName.Value}' is not tracked by the daemon."); - _sharedToolFunctions.TryRemove(serverName, out _); + if (lifecycle.InteractiveAuthorization is { IsCompleted: false } + && !_flowBroker.TryGetActive(serverName, out _)) + { + throw new McpOAuthOperationException(new McpErrorResponse( + "The previous authorization attempt is finishing. Retry after its terminal status is available.", + "authorization start")); + } + + var started = _flowBroker.StartOrJoin(serverName); + if (started.Created) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + lifecycle.SetInteractiveAuthorization(completion.Task); + _ = RunExplicitAuthorizationAsync(lifecycle, entry, started.Flow, completion); + } - return await ConnectAsync(serverName, entry, ct); + var authorizationUrl = await started.Flow.WaitForAuthorizationUrlAsync(requestCancellation); + return new McpOAuthStartResponse(authorizationUrl.ToString(), started.Flow.State); } public async Task InvokeAsync( @@ -132,7 +233,6 @@ public async Task InvokeAsync( { var server = new McpServerName(serverName); var tool = new ToolName(toolName); - return await InvokeSharedAsync(server, tool, arguments, ct); } @@ -142,195 +242,557 @@ private async Task InvokeSharedAsync( IDictionary? arguments, CancellationToken ct) { - if (!TryGetSharedFunction(serverName, toolName.Value, out var function) || function is null) + var lease = TryAcquireInvocationLease(serverName, out var snapshot); + if (lease is null || snapshot is null) { var reconnected = await TryReconnectAsync(serverName, ct); - if (!reconnected - || !TryGetSharedFunction(serverName, toolName.Value, out function) - || function is null) - { - throw new InvalidOperationException( - $"MCP server '{serverName.Value}' is unavailable or tool '{toolName.Value}' is not registered."); - } + if (!reconnected) + throw CreateUnavailableException(serverName, toolName); + + lease = TryAcquireInvocationLease(serverName, out snapshot); + if (lease is null || snapshot is null) + throw CreateUnavailableException(serverName, toolName); } + Exception? transportFailure = null; try { - return await InvokeFunctionAsync(function, $"{serverName.Value}/{toolName.Value}", arguments, ct); + if (!snapshot.ToolFunctions.TryGetValue(toolName.Value, out var function)) + throw CreateUnavailableException(serverName, toolName); + + using var invocationCancellation = CancellationTokenSource.CreateLinkedTokenSource( + ct, lease.InvocationCancellation); + return await InvokeFunctionAsync( + function, + $"{serverName.Value}/{toolName.Value}", + arguments, + invocationCancellation.Token); } - catch (Exception ex) + catch (OperationCanceledException) { - _logger.LogDebug(ex, - "MCP tool '{ToolName}' failed on shared client '{ServerName}', attempting reconnect", - toolName.Value, serverName.Value); - - var reconnected = await TryReconnectAsync(serverName, ct); - if (!reconnected - || !TryGetSharedFunction(serverName, toolName.Value, out var retryFunction) - || retryFunction is null) - throw; - - return await InvokeFunctionAsync(retryFunction, $"{serverName.Value}/{toolName.Value}", arguments, ct); + throw; + } + catch (McpException ex) when (!IsTransportOrSessionFailure(ex)) + { + return $"Error: MCP tool '{serverName.Value}/{toolName.Value}' failed: {ex.Message}"; + } + catch (Exception ex) when (IsTransportOrSessionFailure(ex)) + { + transportFailure = ex; + } + finally + { + lease.Dispose(); } + + // The failed call may have completed remotely. Release its lease first, + // reconnect only for later calls, and never replay this invocation. + await ReconnectAfterTransportFailureAsync(serverName, snapshot, transportFailure!); + ExceptionDispatchInfo.Capture(transportFailure!).Throw(); + throw new InvalidOperationException("Unreachable exception propagation path."); } - // qualifiedToolName is the server-qualified "server/tool" name (not the bare - // function.Name, which omits the server) so MCP error attribution matches the - // bound-tool path (McpToolAdapter.Name) — otherwise the same error renders - // differently depending on which invocation path produced it. - private static async Task InvokeFunctionAsync( - AIFunction function, - string qualifiedToolName, - IDictionary? arguments, - CancellationToken ct) + private McpInvocationLease? TryAcquireInvocationLease( + McpServerName serverName, + out McpServerSnapshot? snapshot) { - var aiArgs = arguments is { Count: > 0 } - ? new AIFunctionArguments(arguments) - : null; + lock (_shutdownSync) + { + snapshot = null; + if (_stopping || !_servers.TryGetValue(serverName, out var lifecycle)) + return null; - var result = await function.InvokeAsync(aiArgs, ct); - // The SDK returns the whole CallToolResult as raw JSON on isError; surface - // a clean, attributed error instead of a blob the model can't classify (#1495). - return McpToolResultFormatter.Format(result, qualifiedToolName); + var current = lifecycle.Snapshot; + if (current is null || !current.IsConnected || current.LeaseOwner is null) + return null; + + if (!current.LeaseOwner.TryAcquire(out var lease)) + return null; + + snapshot = current; + return lease; + } } - private bool TryGetSharedFunction(McpServerName serverName, string toolName, out AIFunction? function) + private async Task ReconnectAfterTransportFailureAsync( + McpServerName serverName, + McpServerSnapshot observed, + Exception failure) { - function = null; + if (!_serverEntries.TryGetValue(serverName.Value, out var entry) + || !_servers.TryGetValue(serverName, out var lifecycle)) + return; - if (!_sharedToolFunctions.TryGetValue(serverName, out var serverTools)) - return false; + _logger.LogDebug(failure, + "MCP transport/session failure on '{ServerName}'; reconnecting for later calls without replay", + serverName.Value); - return serverTools.TryGetValue(toolName, out function); + try + { + await ReconnectAsync( + lifecycle, + entry, + observed, + CancellationToken.None, + _timeProvider.GetUtcNow()); + } + catch (Exception reconnectError) + { + _logger.LogError(reconnectError, + "MCP server '{ServerName}' failed to reconnect after an invocation failure", + serverName.Value); + throw new AggregateException( + $"MCP invocation on '{serverName.Value}' and its recovery both failed.", + failure, + reconnectError); + } } - private async Task ConnectAsync(McpServerName name, McpServerEntry entry, CancellationToken ct) + private async Task ReconnectAsync( + McpServerLifecycle lifecycle, + McpServerEntry entry, + McpServerSnapshot observed, + CancellationToken callerCancellation, + DateTimeOffset? triggeringFailureAt) { - // Holds the client until ownership passes to _clients. If the connect - // fails after the client — and its child process — is created but - // before that handoff (e.g. ListToolsAsync throws), the finally - // disposes it so the process is not orphaned. - McpClient? client = null; + if (IsStopping) + return false; + + if (lifecycle.InteractiveAuthorization is { IsCompleted: false }) + return observed.IsConnected; + + using var candidateCancellation = CancellationTokenSource.CreateLinkedTokenSource( + callerCancellation, _lifetimeCancellation.Token); + try { - client = await CreateClientAsync(name, entry, ct, updateStatusOnAuthFailure: true); - if (client is null) - return false; - - var tools = await client.ListToolsAsync(cancellationToken: ct); - var sharedFunctions = CreateFunctionMap(tools); - LogToolDrift(name, tools); + await lifecycle.Gate.WaitAsync(candidateCancellation.Token); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + return false; + } - _toolRegistry.WithMcpTools(name.Value, tools, entry.GrantCategory, this, - _maxToolDescriptionChars, _maxToolSchemaWarnChars, _logger); + try + { + if (IsStopping) + return false; - _sharedToolFunctions[name] = sharedFunctions; - _clients[name] = client; - client = null; - _statuses[name] = new McpServerStatus(name, McpConnectionState.Connected, tools.Count, null); + var current = lifecycle.Snapshot; + if (current is null) + return false; - _logger.LogInformation("MCP server '{Name}' connected ({ToolCount} tools)", name.Value, tools.Count); - return true; + // A waiter that observed the same publication reuses the one winning + // generation. A changed same-generation publication means that the + // coalesced attempt failed and recorded its failure already. + if (!ReferenceEquals(current, observed)) + return current.Generation > observed.Generation && current.IsConnected; + + if (lifecycle.InteractiveAuthorization is { IsCompleted: false }) + return current.IsConnected; + + return await BuildAndPublishCandidateAsync( + lifecycle, + entry, + current, + candidateCancellation.Token, + triggeringFailureAt, + null); } - catch (Exception ex) + finally { - if (_clients.TryRemove(name, out var existing)) + lifecycle.Gate.Release(); + } + } + + private async Task BuildAndPublishCandidateAsync( + McpServerLifecycle lifecycle, + McpServerEntry entry, + McpServerSnapshot current, + CancellationToken ct, + DateTimeOffset? triggeringFailureAt, + McpOAuthFlow? authorizationFlow) + { + McpClient? candidate = null; + McpOAuthClientContext? oauthContext = null; + Exception? primaryFailure = null; + try + { + var created = await CreateClientAsync(current.Name, entry, authorizationFlow, ct); + candidate = created.Client; + oauthContext = created.OAuthContext; + + var initialization = await _clientRuntime.InitializeAsync(candidate, ct); + var tools = initialization.Tools; + var functions = CreateFunctionMap(tools); + var publishedTools = ToolRegistrationExtensions.PrepareMcpTools( + current.Name.Value, + tools, + entry.GrantCategory, + this, + _maxToolDescriptionChars, + _maxToolSchemaWarnChars, + _logger); + LogToolDrift(current.Name, tools); + + var lastErrorAt = triggeringFailureAt ?? current.Status.LastErrorAt; + var connectedStatus = new McpServerStatus( + current.Name, + McpConnectionState.Connected, + functions.Count, + null, + lastErrorAt); + McpServerSnapshot replacement; + + lock (_shutdownSync) { - try + if (_stopping) + return false; + if (authorizationFlow is null + && lifecycle.InteractiveAuthorization is { IsCompleted: false }) + return current.IsConnected; + + if (authorizationFlow is not null) { - await existing.DisposeAsync(); + _flowBroker.BeginCommit(authorizationFlow); + if (oauthContext is null) + throw new InvalidOperationException("Interactive OAuth candidate has no credential context."); + _credentialStore.PromotePending( + current.Name, + oauthContext, + authorizationFlow.State, + CancellationToken.None); } - catch (Exception disposeEx) + else if (oauthContext is not null) { - _logger.LogDebug(disposeEx, - "Error disposing MCP client '{Name}' after failed connect rollback", name.Value); + _credentialStore.ClaimActiveEpoch(current.Name, oauthContext, ct); } - } - - _sharedToolFunctions.TryRemove(name, out _); - var hasCachedTokens = _oauthService.GetTokenSet(name) is not null; - var hasOAuthRuntimeHints = HasOAuthRuntimeHints(name, entry); - var failureStatus = BuildConnectionFailureStatus(name, entry, ex, hasCachedTokens, hasOAuthRuntimeHints); - _statuses[name] = failureStatus; + var leaseOwner = new McpInvocationLeaseOwner( + candidate, + _clientRuntime, + current.Name, + _logger); + replacement = new McpServerSnapshot( + current.Name, + candidate, + functions, + checked(current.Generation + 1), + connectedStatus, + leaseOwner); + _toolRegistry.PublishMcpServerTools( + current.Name.Value, + publishedTools, + () => + { + lifecycle.Track(leaseOwner); + lifecycle.Publish(replacement); + }); + candidate = null; + } - if (failureStatus.State is McpConnectionState.AwaitingAuth) + if (current.LeaseOwner is not null) + _ = lifecycle.Retire(current.LeaseOwner); + _logger.LogInformation( + "MCP server '{Name}' connected as generation {Generation} ({ToolCount} tools)", + current.Name.Value, + replacement.Generation, + tools.Count); + if (authorizationFlow is not null) + _flowBroker.Complete(authorizationFlow); + return true; + } + catch (OperationCanceledException ex) when (_lifetimeCancellation.IsCancellationRequested) + { + primaryFailure = ex; + return false; + } + catch (OperationCanceledException ex) + { + primaryFailure = ex; + throw; + } + catch (Exception ex) + { + primaryFailure = ex; + var now = _timeProvider.GetUtcNow(); + var hasOAuthRuntimeHints = HasOAuthRuntimeHints(current.Name, entry); + var credentialStateRequiresAuthorization = hasOAuthRuntimeHints + && entry.Url is not null + && _credentialStore.HasAnyActive(current.Name) + && _credentialStore.RequiresAuthorization(current.Name, entry.Url); + var hasCachedTokens = entry.Url is not null + && !credentialStateRequiresAuthorization + && !_credentialStore.RequiresAuthorization(current.Name, entry.Url); + var failureStatus = credentialStateRequiresAuthorization + ? CreateAwaitingAuthStatus(current.Name, now) + : BuildConnectionFailureStatus( + current.Name, + entry, + ex, + hasCachedTokens, + hasOAuthRuntimeHints, + now); + lifecycle.Publish(WithFailureStatus(current, failureStatus)); + if (current.IsConnected) { - _logger.LogWarning(ex, "MCP server '{Name}' requires OAuth authorization", name.Value); - EmitAuthAlert(name, $"MCP server '{name.Value}' requires OAuth authorization. Run: netclaw mcp auth {name.Value}", "authorization_required"); + _logger.LogWarning(ex, + "MCP server '{Name}' replacement failed; generation {Generation} remains connected", + current.Name.Value, + current.Generation); } - else if (failureStatus.State is McpConnectionState.AuthFailed) + else { - _logger.LogWarning(ex, "MCP server '{Name}' authentication failed", name.Value); + ReportConnectionFailure(current.Name, failureStatus, ex, hasCachedTokens, hasOAuthRuntimeHints); + } - if (hasOAuthRuntimeHints || hasCachedTokens) - { - EmitAuthAlert(name, - $"MCP server '{name.Value}' authentication failed. Run: netclaw mcp auth {name.Value}", - hasCachedTokens ? "token_rejected" : "credentials_rejected"); - } - else + if (authorizationFlow is not null) + { + if (IsInvalidClientFailure(ex)) { - EmitDisconnectedAlert(name, $"MCP server '{name.Value}' authentication failed: {failureStatus.ErrorMessage}"); + _credentialStore.MarkDynamicIdentityRejected( + current.Name, + entry.Url!, + entry.OAuthClientId, + CancellationToken.None); } - } - else - { - _logger.LogWarning(ex, "Failed to connect to MCP server '{Name}'", name.Value); - EmitDisconnectedAlert(name, $"MCP server '{name.Value}' connection failed: {failureStatus.ErrorMessage}"); + + var error = CreateSafeOAuthError(ex, "connection initialization"); + _logger.LogError(ex, + "Explicit MCP OAuth candidate failed for server '{Name}' during {Operation} (provider status {ProviderStatus})", + current.Name.Value, + error.Operation, + error.Status); + _credentialStore.RemovePending( + current.Name, + authorizationFlow.State, + CancellationToken.None); + _flowBroker.Fail(authorizationFlow, error); } return false; } finally { - if (client is not null) + if (candidate is not null) { try { - await client.DisposeAsync(); + await DisposeCandidateAsync(current.Name, candidate); } - catch (Exception disposeEx) + catch (Exception disposalFailure) { - _logger.LogDebug(disposeEx, - "Error disposing MCP client '{Name}' after failed connect", name.Value); + var surfacedFailure = primaryFailure is null + ? disposalFailure + : new AggregateException( + $"MCP candidate '{current.Name.Value}' initialization and disposal both failed.", + primaryFailure, + disposalFailure); + if (!IsStopping) + { + var failureStatus = CreateUnreachableStatus( + current.Name, + surfacedFailure, + _timeProvider.GetUtcNow()); + lifecycle.Publish(WithFailureStatus(current, failureStatus)); + } + + _logger.LogError(disposalFailure, + "Error disposing unpublished MCP client '{Name}'", + current.Name.Value); + ExceptionDispatchInfo.Capture(surfacedFailure).Throw(); + throw new InvalidOperationException("Unreachable exception propagation path."); + } + } + + if (authorizationFlow is not null + && _flowBroker.GetStatusByState(authorizationFlow.State).Status is not McpOAuthFlowStatus.Completed) + { + try + { + _credentialStore.RemovePending( + current.Name, + authorizationFlow.State, + CancellationToken.None); + } + catch (Exception cleanupFailure) + { + _logger.LogError(cleanupFailure, + "Failed to remove pending OAuth credentials for MCP server '{Name}'", + current.Name.Value); } } } } - private async Task CreateClientAsync( - McpServerName name, + private async Task RunExplicitAuthorizationAsync( + McpServerLifecycle lifecycle, McpServerEntry entry, - CancellationToken ct, - bool updateStatusOnAuthFailure) + McpOAuthFlow flow, + TaskCompletionSource completion) { - // For HTTP transports without cached tokens, check if OAuth is needed - // before attempting a connection that would fail with 401. - if (entry.Transport is not "stdio" && updateStatusOnAuthFailure && entry.Url is not null) + try { - var hasTokens = _oauthService.GetTokenSet(name) is not null; - if (!hasTokens) + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + flow.CancellationToken, + _lifetimeCancellation.Token); + await lifecycle.Gate.WaitAsync(cancellation.Token); + try { - // Always probe so metadata is cached for the runtime fallback - // in BuildConnectionFailureStatus. Only block the connection - // when no static headers are configured — if the user supplied - // headers, let the real connection attempt decide. See #1350. - var metadata = await _oauthService.TryDiscoverMetadataAsync(name, entry.Url, ct); - if (metadata is not null && entry.Headers is not { Count: > 0 }) + if (IsStopping || lifecycle.Snapshot is not { } current) { - _statuses[name] = CreateAwaitingAuthStatus(name); - _logger.LogWarning("MCP server '{Name}' requires OAuth authorization", name.Value); - EmitAuthAlert(name, $"MCP server '{name.Value}' requires OAuth authorization. Run: netclaw mcp auth {name.Value}", "authorization_required"); + completion.TrySetResult(false); + return; + } + + var result = await BuildAndPublishCandidateAsync( + lifecycle, + entry, + current, + cancellation.Token, + null, + flow); + completion.TrySetResult(result); + } + finally + { + lifecycle.Gate.Release(); + } + } + catch (OperationCanceledException ex) + { + var error = new McpErrorResponse( + "Authorization was cancelled or expired. Start a new MCP authorization attempt.", + "authorization exchange"); + _logger.LogWarning(ex, "Explicit MCP OAuth flow was cancelled for '{Name}'", flow.ServerName.Value); + _credentialStore.RemovePending(flow.ServerName, flow.State, CancellationToken.None); + _flowBroker.Fail(flow, error); + completion.TrySetResult(false); + } + catch (Exception ex) + { + var error = CreateSafeOAuthError(ex, "connection initialization"); + _logger.LogError(ex, "Explicit MCP OAuth flow failed for '{Name}'", flow.ServerName.Value); + _flowBroker.Fail(flow, error); + completion.TrySetResult(false); + } + finally + { + lifecycle.ClearInteractiveAuthorization(completion.Task); + } + } - return null; + private McpServerSnapshot WithFailureStatus(McpServerSnapshot current, McpServerStatus failure) + { + if (!current.IsConnected) + return McpServerSnapshot.WithoutConnection(failure, current.Generation); + + var connectedStatus = new McpServerStatus( + current.Name, + McpConnectionState.Connected, + current.ToolFunctions.Count, + failure.ErrorMessage, + failure.LastErrorAt); + return current with { Status = connectedStatus }; + } + + private async Task StopServerAsync( + McpServerName serverName, + McpServerLifecycle lifecycle, + CancellationToken shutdownCancellation) + { + await lifecycle.Gate.WaitAsync(CancellationToken.None); + try + { + var snapshot = lifecycle.Snapshot; + var owners = lifecycle.RetireAll(snapshot?.LeaseOwner); + _toolRegistry.PublishMcpServerTools( + serverName.Value, + [], + () => lifecycle.Publish(null)); + + if (owners.Count == 0) + return; + + using var cancellationRegistration = shutdownCancellation.Register( + static state => + { + foreach (var owner in (IReadOnlyList)state!) + owner.CancelInvocations(); + }, + owners); + + var drained = Task.WhenAll(owners.Select(owner => owner.Drained)); + if (!drained.IsCompleted) + { + var timeout = Task.Delay(ShutdownDrainTimeout, _timeProvider, CancellationToken.None); + if (await Task.WhenAny(drained, timeout) != drained) + { + foreach (var owner in owners) + owner.CancelInvocations(); } } + + await drained; + try + { + await Task.WhenAll(owners.Select(owner => owner.Disposal)); + } + finally + { + lifecycle.Forget(owners); + } + + _logger.LogInformation("MCP client '{Name}' shut down", serverName.Value); + } + finally + { + lifecycle.Gate.Release(); } + } + + private async Task DisposeCandidateAsync(McpServerName name, McpClient candidate) + { + await _clientRuntime.DisposeAsync(candidate); + _logger.LogDebug("Unpublished MCP client '{Name}' disposed", name.Value); + } + + private async Task InvokeFunctionAsync( + AIFunction function, + string qualifiedToolName, + IDictionary? arguments, + CancellationToken ct) + { + var aiArgs = arguments is { Count: > 0 } + ? new AIFunctionArguments(arguments) + : null; + var result = await _clientRuntime.InvokeAsync(function, aiArgs, ct); + return McpToolResultFormatter.Format(result, qualifiedToolName); + } - var transport = CreateTransport(name, entry); + private static InvalidOperationException CreateUnavailableException( + McpServerName serverName, + ToolName toolName) + => new($"MCP server '{serverName.Value}' is unavailable or tool '{toolName.Value}' is not registered."); - return await McpClient.CreateAsync(transport, new McpClientOptions + private async Task CreateClientAsync( + McpServerName name, + McpServerEntry entry, + McpOAuthFlow? authorizationFlow, + CancellationToken ct) + { + McpOAuthClientContext? oauthContext = null; + if (entry.Transport is not "stdio" && !HasConfiguredAuthorizationHeader(entry)) + { + oauthContext = _credentialStore.CreateContext( + name, + entry.Url!, + entry.OAuthClientId, + authorizationFlow is not null); + } + + var transport = CreateTransport(name, entry, oauthContext, authorizationFlow); + var client = await _clientRuntime.CreateAsync(transport, new McpClientOptions { ClientInfo = new() { @@ -340,14 +802,19 @@ private async Task ConnectAsync(McpServerName name, McpServerEntry entry, WebsiteUrl = "https://netclaw.dev", Description = "Open-source autonomous operations agent built on Akka.NET", }, - }, cancellationToken: ct); + }, ct); + return new McpClientCandidate(client, oauthContext); } - private IClientTransport CreateTransport(McpServerName serverName, McpServerEntry entry) + private IClientTransport CreateTransport( + McpServerName serverName, + McpServerEntry entry, + McpOAuthClientContext? oauthContext, + McpOAuthFlow? authorizationFlow) { if (entry.Transport is "stdio") { - return new StdioClientTransport(new StdioClientTransportOptions + return _clientRuntime.CreateStdioTransport(new StdioClientTransportOptions { Command = entry.Command!, Arguments = entry.Arguments ?? [], @@ -357,58 +824,71 @@ private IClientTransport CreateTransport(McpServerName serverName, McpServerEntr }); } - // Unwrap SensitiveString here at the transport boundary so the SDK - // sees the actual credential, not SensitiveString.ToString()'s - // redacted sentinel. var headers = entry.Headers.ToRawValues(StringComparer.OrdinalIgnoreCase) ?? new Dictionary(StringComparer.OrdinalIgnoreCase); - // Identify Netclaw to the remote MCP server. The SDK's HttpClientTransport - // builds its own HttpClient internally, so this header dictionary is the - // only seam — DelegatingHandlers can't reach it. User-configured headers - // win: if an operator already sets User-Agent or X-Netclaw-Component, - // we leave them alone. if (!headers.ContainsKey("User-Agent")) headers["User-Agent"] = NetclawUserAgent.Value; if (!headers.ContainsKey(NetclawUserAgent.ComponentHeader)) headers[NetclawUserAgent.ComponentHeader] = "mcp"; - return new HttpClientTransport(new HttpClientTransportOptions + var oauth = HasConfiguredAuthorizationHeader(entry) + ? null + : BuildOAuthOptions(serverName, entry, oauthContext!, authorizationFlow); + return _clientRuntime.CreateHttpTransport(new HttpClientTransportOptions { Endpoint = new Uri(entry.Url!), Name = serverName.Value, AdditionalHeaders = headers, TransportMode = entry.Transport is "sse" ? HttpTransportMode.Sse - : HttpTransportMode.AutoDetect, - OAuth = BuildOAuthOptions(serverName, entry), + : HttpTransportMode.StreamableHttp, + OAuth = oauth, }); } - private ClientOAuthOptions? BuildOAuthOptions(McpServerName serverName, McpServerEntry entry) + private ClientOAuthOptions BuildOAuthOptions( + McpServerName serverName, + McpServerEntry entry, + McpOAuthClientContext context, + McpOAuthFlow? authorizationFlow) { - var metadata = _oauthService.GetCachedMetadata(serverName); - - // Only wire OAuth if server is known to need it (has metadata or static config) - if (metadata is null && string.IsNullOrWhiteSpace(entry.OAuthClientId)) - return null; - - var serverNameCapture = serverName; + var identity = context.SnapshotIdentity(); + var redirectUri = new Uri( + $"http://127.0.0.1:{_daemonConfig.Port}/api/mcp/oauth/callback"); + var target = authorizationFlow is null + ? McpOAuthCredentialTarget.Active + : McpOAuthCredentialTarget.Pending; return new ClientOAuthOptions { - RedirectUri = new Uri("http://127.0.0.1:5199/api/mcp/oauth/callback"), - ClientId = metadata?.ClientId ?? entry.OAuthClientId, + RedirectUri = redirectUri, + ClientId = entry.OAuthClientId ?? identity.ClientId, + ClientSecret = entry.OAuthClientId is null ? identity.ClientSecret : null, Scopes = ParseScopes(entry.OAuthScope), - TokenCache = _oauthService.CreateTokenCache(serverName), - // Return null to suppress the SDK's default browser-open behavior; - // Netclaw handles interactive auth via `netclaw mcp auth`. - AuthorizationRedirectDelegate = static (_, _, _) => Task.FromResult(null), + TokenCache = _credentialStore.CreateTokenCache( + serverName, + entry.Url!, + context, + target, + authorizationFlow?.State, + authorizationFlow?.ExpiresAt, + withholdAccessToken: authorizationFlow is not null), + AuthorizationRedirectDelegate = authorizationFlow is null + ? static (_, _, _) => Task.FromResult(null) + : authorizationFlow.HandleAuthorizationRedirectAsync, + AdditionalAuthorizationParameters = authorizationFlow is null + ? new Dictionary() + : new Dictionary { ["state"] = authorizationFlow.State }, DynamicClientRegistration = new DynamicClientRegistrationOptions { ClientName = "netclaw", - ResponseDelegate = (response, _) => + ResponseDelegate = (response, cancellationToken) => { - _oauthService.UpdateMetadataClientId(serverNameCapture, response.ClientId); + _credentialStore.CaptureDynamicRegistration( + serverName, + context, + response, + cancellationToken); return Task.CompletedTask; }, }, @@ -416,33 +896,50 @@ private IClientTransport CreateTransport(McpServerName serverName, McpServerEntr } private bool HasOAuthRuntimeHints(McpServerName serverName, McpServerEntry entry) - => !string.IsNullOrWhiteSpace(entry.OAuthClientId) - || !string.IsNullOrWhiteSpace(entry.OAuthScope) - || _oauthService.GetCachedMetadata(serverName) is not null; + => entry.Transport is not "stdio" && !HasConfiguredAuthorizationHeader(entry); + + private static bool HasConfiguredAuthorizationHeader(McpServerEntry entry) + => entry.Headers?.Keys.Any(key => + string.Equals(key, "Authorization", StringComparison.OrdinalIgnoreCase)) == true; internal static McpServerStatus BuildConnectionFailureStatus( McpServerName serverName, McpServerEntry entry, Exception ex, bool hasCachedTokens, - bool hasOAuthRuntimeHints) + bool hasOAuthRuntimeHints, + DateTimeOffset errorAt) { if (IsAuthFailure(ex)) { if (!hasCachedTokens && entry.Transport is not "stdio" && hasOAuthRuntimeHints) - return CreateAwaitingAuthStatus(serverName); + return CreateAwaitingAuthStatus(serverName, errorAt); - return CreateAuthFailedStatus(serverName, ex, oauthManaged: hasCachedTokens || hasOAuthRuntimeHints); + return CreateAuthFailedStatus( + serverName, + ex, + oauthManaged: hasCachedTokens || hasOAuthRuntimeHints, + errorAt); } - return CreateUnreachableStatus(serverName, ex); + return CreateUnreachableStatus(serverName, ex, errorAt); } - internal static McpServerStatus CreateAwaitingAuthStatus(McpServerName serverName) - => new(serverName, McpConnectionState.AwaitingAuth, 0, - $"OAuth authorization required. Run: netclaw mcp auth {serverName.Value}"); - - internal static McpServerStatus CreateAuthFailedStatus(McpServerName serverName, Exception ex, bool oauthManaged) + internal static McpServerStatus CreateAwaitingAuthStatus( + McpServerName serverName, + DateTimeOffset errorAt) + => new( + serverName, + McpConnectionState.AwaitingAuth, + 0, + $"OAuth authorization required. Run: netclaw mcp auth {serverName.Value}", + errorAt); + + internal static McpServerStatus CreateAuthFailedStatus( + McpServerName serverName, + Exception ex, + bool oauthManaged, + DateTimeOffset errorAt) { var statusText = GetHttpStatusText(ex); var detail = string.IsNullOrWhiteSpace(statusText) @@ -451,12 +948,73 @@ internal static McpServerStatus CreateAuthFailedStatus(McpServerName serverName, var guidance = oauthManaged ? $" Run: netclaw mcp auth {serverName.Value}" : " Check configured credentials or headers."; - return new(serverName, McpConnectionState.AuthFailed, 0, detail + guidance); + return new McpServerStatus( + serverName, + McpConnectionState.AuthFailed, + 0, + detail + guidance, + errorAt); } - internal static McpServerStatus CreateUnreachableStatus(McpServerName serverName, Exception ex) - => new(serverName, McpConnectionState.Unreachable, 0, - string.IsNullOrWhiteSpace(ex.Message) ? "Failed to reach MCP server." : ex.Message); + internal static McpServerStatus CreateUnreachableStatus( + McpServerName serverName, + Exception ex, + DateTimeOffset errorAt) + => new( + serverName, + McpConnectionState.Unreachable, + 0, + GetSafeConnectionFailure(ex), + errorAt); + + private static string GetSafeConnectionFailure(Exception ex) + { + var status = FindHttpStatus(ex); + if (status is not null) + return $"MCP server request failed (HTTP {(int)status.Value} {status.Value})."; + if (ex is TimeoutException or TaskCanceledException) + return "MCP server connection timed out."; + return "Failed to reach MCP server. Check daemon logs for details."; + } + + private void ReportConnectionFailure( + McpServerName name, + McpServerStatus failureStatus, + Exception ex, + bool hasCachedTokens, + bool hasOAuthRuntimeHints) + { + if (failureStatus.State is McpConnectionState.AwaitingAuth) + { + _logger.LogWarning(ex, "MCP server '{Name}' requires OAuth authorization", name.Value); + EmitAuthAlert(name, + $"MCP server '{name.Value}' requires OAuth authorization. Run: netclaw mcp auth {name.Value}", + "authorization_required"); + return; + } + + if (failureStatus.State is McpConnectionState.AuthFailed) + { + _logger.LogWarning(ex, "MCP server '{Name}' authentication failed", name.Value); + if (hasOAuthRuntimeHints || hasCachedTokens) + { + EmitAuthAlert(name, + $"MCP server '{name.Value}' authentication failed. Run: netclaw mcp auth {name.Value}", + hasCachedTokens ? "token_rejected" : "credentials_rejected"); + } + else + { + EmitDisconnectedAlert(name, + $"MCP server '{name.Value}' authentication failed: {failureStatus.ErrorMessage}"); + } + + return; + } + + _logger.LogWarning(ex, "Failed to connect to MCP server '{Name}'", name.Value); + EmitDisconnectedAlert(name, + $"MCP server '{name.Value}' connection failed: {failureStatus.ErrorMessage}"); + } private void EmitAuthAlert(McpServerName serverName, string summary, string reason) { @@ -496,20 +1054,103 @@ private void EmitDisconnectedAlert(McpServerName serverName, string summary) private static bool IsAuthFailure(Exception ex) { - // HttpRequestException with 401/403 - if (ex is HttpRequestException { StatusCode: System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden }) + if (ex is HttpRequestException { StatusCode: HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden }) return true; if (ex.Message.Contains("unauthorized", StringComparison.OrdinalIgnoreCase) || ex.Message.Contains("forbidden", StringComparison.OrdinalIgnoreCase) - || ex.Message.Contains("invalid_grant", StringComparison.OrdinalIgnoreCase)) + || ex.Message.Contains("invalid_grant", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("invalid_client", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("AuthorizationRedirectDelegate", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("authorization code", StringComparison.OrdinalIgnoreCase)) return true; - // Check inner exceptions for auth failures - if (ex.InnerException is not null) - return IsAuthFailure(ex.InnerException); + return ex.InnerException is not null && IsAuthFailure(ex.InnerException); + } + + private static bool IsInvalidClientFailure(Exception ex) + => ex.Message.Contains("invalid_client", StringComparison.OrdinalIgnoreCase) + || ex.InnerException is not null && IsInvalidClientFailure(ex.InnerException); - return false; + internal static McpErrorResponse CreateSafeOAuthError(Exception ex, string fallbackOperation) + { + var status = FindHttpStatus(ex); + var exceptions = EnumerateExceptionTree(ex).ToArray(); + var operation = exceptions.Any(candidate => + candidate is McpOAuthStaleCredentialEpochException + or IOException + or UnauthorizedAccessException + || candidate.Message.Contains("secrets", StringComparison.OrdinalIgnoreCase)) + ? "credential persistence" + : exceptions.Any(candidate => + candidate.Message.Contains("registration", StringComparison.OrdinalIgnoreCase) + || candidate.Message.Contains("register", StringComparison.OrdinalIgnoreCase)) + ? "dynamic client registration" + : exceptions.Any(candidate => + candidate.Message.Contains("token", StringComparison.OrdinalIgnoreCase) + || candidate.Message.Contains("authorization code", StringComparison.OrdinalIgnoreCase)) + ? "authorization code exchange" + : fallbackOperation; + var statusText = status is null + ? null + : $"HTTP {(int)status.Value} {status.Value}"; + var message = statusText is null + ? $"MCP OAuth {operation} failed. Check daemon logs for details." + : $"MCP OAuth {operation} failed: {statusText}."; + return new McpErrorResponse(message, operation, status is null ? null : (int)status.Value); + } + + private static IEnumerable EnumerateExceptionTree(Exception root) + { + var pending = new Stack(); + pending.Push(root); + while (pending.Count > 0) + { + var current = pending.Pop(); + yield return current; + if (current is AggregateException aggregate) + { + foreach (var inner in aggregate.InnerExceptions) + pending.Push(inner); + } + else if (current.InnerException is not null) + { + pending.Push(current.InnerException); + } + } + } + + private static HttpStatusCode? FindHttpStatus(Exception ex) + { + if (ex is HttpRequestException { StatusCode: { } status }) + return status; + if (ex.Message.Contains("403", StringComparison.Ordinal) + || ex.Message.Contains("Forbidden", StringComparison.OrdinalIgnoreCase)) + return HttpStatusCode.Forbidden; + if (ex.Message.Contains("401", StringComparison.Ordinal) + || ex.Message.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase)) + return HttpStatusCode.Unauthorized; + return ex.InnerException is null ? null : FindHttpStatus(ex.InnerException); + } + + internal static bool IsTransportOrSessionFailure(Exception ex) + { + if (ex is HttpRequestException + or IOException + or EndOfStreamException + or TimeoutException + or ObjectDisposedException) + return true; + + if (ex is not McpException) + return false; + + return ex.Message.Contains("transport", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("connection closed", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("disconnected", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("session closed", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("stream ended", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("broken pipe", StringComparison.OrdinalIgnoreCase); } private static string? GetHttpStatusText(Exception ex) @@ -518,33 +1159,24 @@ private static bool IsAuthFailure(Exception ex) { return statusCode switch { - System.Net.HttpStatusCode.Unauthorized => "401 Unauthorized", - System.Net.HttpStatusCode.Forbidden => "403 Forbidden", + HttpStatusCode.Unauthorized => "401 Unauthorized", + HttpStatusCode.Forbidden => "403 Forbidden", _ => $"{(int)statusCode} {statusCode}" }; } - if (ex.InnerException is not null) - return GetHttpStatusText(ex.InnerException); - - return null; + return ex.InnerException is null ? null : GetHttpStatusText(ex.InnerException); } - private static Dictionary CreateFunctionMap(IList tools) + internal static IReadOnlyDictionary CreateFunctionMap(IReadOnlyList tools) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var tool in tools) map[tool.Name] = tool; - - return map; + return new ReadOnlyDictionary(map); } - /// - /// Compares discovered tools against - /// across all audience profiles and logs warnings for drift. - /// - private void LogToolDrift(McpServerName serverName, IList discoveredTools) + private void LogToolDrift(McpServerName serverName, IReadOnlyList discoveredTools) { var profiles = _toolConfig.AudienceProfiles; var allGrantedTools = new HashSet(StringComparer.Ordinal); @@ -552,10 +1184,8 @@ private void LogToolDrift(McpServerName serverName, IList discove foreach (var profile in profiles.GetAllProfiles()) { - if (profile.McpServerToolGrants is not { } grants) - continue; - - if (!grants.TryGetValue(serverName.Value, out var tools)) + if (profile.McpServerToolGrants is not { } grants + || !grants.TryGetValue(serverName.Value, out var tools)) continue; hasAnyGrants = true; @@ -568,7 +1198,6 @@ private void LogToolDrift(McpServerName serverName, IList discove var discoveredNames = new HashSet( discoveredTools.Select(t => t.Name), StringComparer.Ordinal); - var ungranted = discoveredNames.Except(allGrantedTools).ToList(); var stale = allGrantedTools.Except(discoveredNames).ToList(); @@ -591,15 +1220,380 @@ private void LogToolDrift(McpServerName serverName, IList discove public void Dispose() { - foreach (var client in _clients.Values) + var emergencyCleanup = false; + lock (_shutdownSync) + { + if (_disposed) + return; + + _disposed = true; + _stopping = true; + _lifetimeCancellation.Cancel(); + emergencyCleanup = _stopTask is null; + } + + if (emergencyCleanup) + { + foreach (var (serverName, lifecycle) in _servers) + { + var owners = lifecycle.RetireAll(lifecycle.Snapshot?.LeaseOwner); + _toolRegistry.PublishMcpServerTools( + serverName.Value, + [], + () => lifecycle.Publish(null)); + foreach (var owner in owners) + owner.CancelInvocations(); + } + } + + _lifetimeCancellation.Dispose(); + } +} + +internal interface IMcpClientRuntime +{ + IClientTransport CreateStdioTransport(StdioClientTransportOptions options) + => new StdioClientTransport(options); + + IClientTransport CreateHttpTransport(HttpClientTransportOptions options) + => new HttpClientTransport(options); + + Task CreateAsync( + IClientTransport transport, + McpClientOptions options, + CancellationToken cancellationToken); + + ValueTask InitializeAsync( + McpClient client, + CancellationToken cancellationToken); + + ValueTask InvokeAsync( + AIFunction function, + AIFunctionArguments? arguments, + CancellationToken cancellationToken); + + ValueTask DisposeAsync(McpClient client); +} + +internal sealed class McpClientRuntime : IMcpClientRuntime +{ + public Task CreateAsync( + IClientTransport transport, + McpClientOptions options, + CancellationToken cancellationToken) + => McpClient.CreateAsync(transport, options, cancellationToken: cancellationToken); + + public async ValueTask InitializeAsync( + McpClient client, + CancellationToken cancellationToken) + { + var tools = await client.ListToolsAsync(cancellationToken: cancellationToken); + return new McpClientInitialization(tools.Cast().ToList()); + } + + public ValueTask InvokeAsync( + AIFunction function, + AIFunctionArguments? arguments, + CancellationToken cancellationToken) + => function.InvokeAsync(arguments, cancellationToken); + + public ValueTask DisposeAsync(McpClient client) => client.DisposeAsync(); +} + +internal sealed record McpClientInitialization( + IReadOnlyList Tools); + +internal sealed record McpClientCandidate( + McpClient Client, + McpOAuthClientContext? OAuthContext); + +internal sealed class McpServerLifecycle(McpServerSnapshot initialSnapshot) +{ + private McpServerSnapshot? _snapshot = initialSnapshot; + private Task? _interactiveAuthorization; + private readonly List _owners = []; + private readonly object _ownersSync = new(); + + public SemaphoreSlim Gate { get; } = new(1, 1); + + public McpServerSnapshot? Snapshot => Volatile.Read(ref _snapshot); + + public Task? InteractiveAuthorization => Volatile.Read(ref _interactiveAuthorization); + + public int TrackedOwnerCount + { + get + { + lock (_ownersSync) + return _owners.Count; + } + } + + public void Publish(McpServerSnapshot? snapshot) => Volatile.Write(ref _snapshot, snapshot); + + public void SetInteractiveAuthorization(Task authorization) + { + while (true) + { + var current = Volatile.Read(ref _interactiveAuthorization); + if (current is not null && !current.IsCompleted) + throw new InvalidOperationException("An interactive authorization operation is already active."); + if (Interlocked.CompareExchange(ref _interactiveAuthorization, authorization, current) == current) + return; + } + } + + public void ClearInteractiveAuthorization(Task authorization) + => Interlocked.CompareExchange(ref _interactiveAuthorization, null, authorization); + + public void Track(McpInvocationLeaseOwner owner) + { + var added = false; + lock (_ownersSync) + { + if (!_owners.Contains(owner)) + { + _owners.Add(owner); + added = true; + } + } + + if (!added) + return; + + owner.RegisterSuccessfulDisposal(RemoveSuccessfullyDisposed); + } + + public Task Retire(McpInvocationLeaseOwner owner) + { + Track(owner); + return owner.Retire(); + } + + public IReadOnlyList RetireAll(McpInvocationLeaseOwner? current) + { + List owners; + lock (_ownersSync) { - try { (client as IDisposable)?.Dispose(); } - catch (Exception ex) { _logger.LogDebug(ex, "Error disposing MCP client during shutdown"); } + if (current is not null && !_owners.Contains(current)) + _owners.Add(current); + + owners = _owners.ToList(); } - _clients.Clear(); - _sharedToolFunctions.Clear(); + foreach (var owner in owners) + _ = owner.Retire(); + + return owners; } + + private void RemoveSuccessfullyDisposed(McpInvocationLeaseOwner owner) + { + lock (_ownersSync) + _owners.Remove(owner); + } + + public void Forget(IReadOnlyList owners) + { + lock (_ownersSync) + { + foreach (var owner in owners) + _owners.Remove(owner); + } + } + +} + +internal sealed record McpServerSnapshot( + McpServerName Name, + McpClient? Client, + IReadOnlyDictionary ToolFunctions, + long Generation, + McpServerStatus Status, + McpInvocationLeaseOwner? LeaseOwner) +{ + private static readonly IReadOnlyDictionary EmptyFunctions = + new ReadOnlyDictionary(new Dictionary()); + + public bool IsConnected + => Client is not null + && LeaseOwner is not null + && Status.State is McpConnectionState.Connected; + + public static McpServerSnapshot WithoutConnection(McpServerStatus status, long generation = 0) + => new(status.Name, null, EmptyFunctions, generation, status, null); +} + +internal sealed class McpInvocationLeaseOwner +{ + private readonly object _sync = new(); + private readonly McpClient _client; + private readonly IMcpClientRuntime _runtime; + private readonly McpServerName _serverName; + private readonly ILogger _logger; + private readonly CancellationTokenSource _invocationCancellation = new(); + private readonly TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _disposed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _activeLeases; + private bool _retired; + private bool _disposeStarted; + private bool _disposedSuccessfully; + private Action? _successfulDisposal; + + public McpInvocationLeaseOwner( + McpClient client, + IMcpClientRuntime runtime, + McpServerName serverName, + ILogger logger) + { + _client = client; + _runtime = runtime; + _serverName = serverName; + _logger = logger; + } + + public Task Drained => _drained.Task; + + public Task Disposal => _disposed.Task; + + public void RegisterSuccessfulDisposal(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + var invokeNow = false; + lock (_sync) + { + if (_disposedSuccessfully) + invokeNow = true; + else + _successfulDisposal += callback; + } + + if (invokeNow) + callback(this); + } + + public bool TryAcquire(out McpInvocationLease? lease) + { + lock (_sync) + { + if (_retired) + { + lease = null; + return false; + } + + checked { _activeLeases++; } + lease = new McpInvocationLease(this, _invocationCancellation.Token); + return true; + } + } + + public Task Retire() + { + var startDispose = false; + lock (_sync) + { + if (!_retired) + _retired = true; + + if (_activeLeases == 0) + { + _drained.TrySetResult(); + if (!_disposeStarted) + { + _disposeStarted = true; + startDispose = true; + } + } + } + + if (startDispose) + _ = DisposeClientAsync(); + + return _disposed.Task; + } + + public void CancelInvocations() + { + try + { + _invocationCancellation.Cancel(); + } + catch (ObjectDisposedException) + { + _logger.LogDebug( + "Invocation cancellation for retired MCP client '{Name}' raced completed disposal", + _serverName.Value); + } + } + + internal void Release() + { + var startDispose = false; + lock (_sync) + { + if (_activeLeases <= 0) + throw new InvalidOperationException("MCP invocation lease released more than once."); + + _activeLeases--; + if (_retired && _activeLeases == 0) + { + _drained.TrySetResult(); + if (!_disposeStarted) + { + _disposeStarted = true; + startDispose = true; + } + } + } + + if (startDispose) + _ = DisposeClientAsync(); + } + + private async Task DisposeClientAsync() + { + try + { + await _runtime.DisposeAsync(_client); + Action? successfulDisposal; + lock (_sync) + { + _disposedSuccessfully = true; + successfulDisposal = _successfulDisposal; + _successfulDisposal = null; + } + + successfulDisposal?.Invoke(this); + _disposed.TrySetResult(); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error disposing retired MCP client '{Name}'", + _serverName.Value); + _disposed.TrySetException(ex); + } + finally + { + _invocationCancellation.Dispose(); + } + } +} + +internal sealed class McpInvocationLease : IDisposable +{ + private McpInvocationLeaseOwner? _owner; + + public McpInvocationLease(McpInvocationLeaseOwner owner, CancellationToken invocationCancellation) + { + _owner = owner; + InvocationCancellation = invocationCancellation; + } + + public CancellationToken InvocationCancellation { get; } + + public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Release(); } internal enum McpConnectionState @@ -615,4 +1609,5 @@ internal sealed record McpServerStatus( McpServerName Name, McpConnectionState State, int ToolCount, - string? ErrorMessage); + string? ErrorMessage, + DateTimeOffset? LastErrorAt); diff --git a/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs index 90a176cc9..c25c146df 100644 --- a/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs +++ b/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs @@ -4,7 +4,6 @@ // // ----------------------------------------------------------------------- using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; @@ -22,81 +21,113 @@ public sealed record McpOAuthCallbackQuery( public sealed record McpOAuthStartResponse(string AuthorizationUrl, string State); /// Connection status for a single MCP server. -public sealed record McpServerStatusDto(string State, int ToolCount, string? Error); +public sealed record McpServerStatusDto( + string State, + int ToolCount, + string? Error, + DateTimeOffset? LastErrorAt); /// OAuth flow status for an MCP server or pending state token. -public sealed record McpOAuthStatusResponse(string Status); +public sealed record McpOAuthStatusResponse(string Status, McpErrorResponse? Error = null); /// Error payload returned when an MCP request is malformed or unknown. -public sealed record McpErrorResponse(string Error); +public sealed record McpErrorResponse( + string Error, + string? Operation = null, + int? Status = null); public static class McpEndpointRouteBuilderExtensions { public static IEndpointRouteBuilder MapMcpEndpoints(this IEndpointRouteBuilder app) { // MCP OAuth 2.1 endpoints - app.MapPost("/api/mcp/oauth/start/{name}", async ValueTask, NotFound, BadRequest>> ( + app.MapPost("/api/mcp/oauth/start/{name}", async Task ( string name, - McpOAuthService oauthService, + McpClientManager mcpManager, Dictionary mcpServers, - CancellationToken ct) => + ILogger logger, + CancellationToken requestCancellation) => { if (!mcpServers.TryGetValue(name, out var entry)) - return TypedResults.NotFound(new McpErrorResponse($"MCP server '{name}' not found")); + return Results.NotFound(new McpErrorResponse($"MCP server '{name}' not found.")); if (string.IsNullOrWhiteSpace(entry.Url)) - return TypedResults.BadRequest(new McpErrorResponse($"MCP server '{name}' has no URL (OAuth requires HTTP transport)")); + return Results.BadRequest(new McpErrorResponse( + $"MCP server '{name}' has no URL (OAuth requires HTTP transport).")); - var (authUrl, state) = await oauthService.StartAuthorizationFlowAsync(new McpServerName(name), entry, ct); - return TypedResults.Ok(new McpOAuthStartResponse(authUrl, state)); + try + { + var started = await mcpManager.StartAuthorizationAsync( + new McpServerName(name), + requestCancellation); + return Results.Ok(started); + } + catch (OperationCanceledException) when (requestCancellation.IsCancellationRequested) + { + throw; + } + catch (McpOAuthOperationException ex) + { + logger.LogError(ex, "MCP OAuth start failed for server '{Name}'", name); + return Results.Json(ex.Error, statusCode: StatusCodes.Status502BadGateway); + } + catch (Exception ex) + { + logger.LogError(ex, "MCP OAuth start failed for server '{Name}'", name); + var error = McpClientManager.CreateSafeOAuthError(ex, "authorization start"); + return Results.Json(error, statusCode: StatusCodes.Status502BadGateway); + } }) .WithName("StartMcpOAuth") .WithSummary("Start an OAuth 2.1 authorization flow for an MCP server.") .WithTags("MCP") .RequireAuthorization(); - app.MapGet("/api/mcp/oauth/callback", async ValueTask ( + app.MapGet("/api/mcp/oauth/callback", async Task ( [AsParameters] McpOAuthCallbackQuery query, - McpOAuthService oauthService, - IMcpReconnectable mcpManager, - ILogger reconnectLogger, - CancellationToken ct) => + McpOAuthFlowBroker flowBroker, + ILogger logger, + CancellationToken requestCancellation) => { if (string.IsNullOrEmpty(query.Code) || string.IsNullOrEmpty(query.State)) { return TypedResults.Content( "

Authorization failed

Missing code or state parameter.

", - "text/html"); + "text/html", + statusCode: StatusCodes.Status400BadRequest); } try { - await oauthService.CompleteAuthorizationAsync(query.Code, query.State, ct); - - // Auto-reconnect the MCP server now that we have a valid token. - // Resolved as IMcpReconnectable so the callback is not coupled to the - // concrete McpClientManager type, which is heavyweight and hard to stub in tests. - var serverName = oauthService.GetServerNameForState(query.State); - if (serverName is not null) + var flow = flowBroker.GetForCallback(query.State); + flow.DeliverCode(query.Code); + var terminal = await flow.WaitForTerminalAsync(requestCancellation); + if (terminal.Status is McpOAuthFlowStatus.Failed) { - _ = Task.Run(async () => - { - try { await mcpManager.TryReconnectAsync(serverName.Value, CancellationToken.None); } - catch (Exception ex) { reconnectLogger.LogWarning(ex, "Post-OAuth reconnect failed for MCP server '{Name}'", serverName.Value.Value); } - }, CancellationToken.None); + var message = terminal.Error?.Error + ?? "Authorization failed. Check daemon logs and start a new attempt."; + return TypedResults.Content( + $"

Authorization failed

{System.Net.WebUtility.HtmlEncode(message)}

", + "text/html", + statusCode: StatusCodes.Status500InternalServerError); } return TypedResults.Content( "

Authorization complete

You may close this tab.

", "text/html"); } - catch (Exception ex) + catch (OperationCanceledException) when (requestCancellation.IsCancellationRequested) + { + throw; + } + catch (McpOAuthCallbackException ex) { + logger.LogWarning("Rejected MCP OAuth callback: {Reason}", ex.Message); return TypedResults.Content( $"

Authorization failed

{System.Net.WebUtility.HtmlEncode(ex.Message)}

", "text/html", contentEncoding: null, - statusCode: StatusCodes.Status500InternalServerError); + statusCode: StatusCodes.Status400BadRequest); } }) .WithName("McpOAuthCallback") @@ -112,7 +143,8 @@ public static IEndpointRouteBuilder MapMcpEndpoints(this IEndpointRouteBuilder a kvp => new McpServerStatusDto( kvp.Value.State.ToString(), kvp.Value.ToolCount, - kvp.Value.ErrorMessage)); + kvp.Value.ErrorMessage, + kvp.Value.LastErrorAt)); return TypedResults.Ok(result); }) .WithName("GetMcpServerStatuses") @@ -130,21 +162,25 @@ public static IEndpointRouteBuilder MapMcpEndpoints(this IEndpointRouteBuilder a .WithTags("MCP") .RequireAuthorization(); - app.MapGet("/api/mcp/oauth/status/{name}", (string name, McpOAuthService oauthService) => + app.MapGet("/api/mcp/oauth/status/{name}", (string name, McpOAuthFlowBroker flowBroker) => { - var status = oauthService.GetFlowStatus(new McpServerName(name)); - return TypedResults.Ok(new McpOAuthStatusResponse(status.ToString())); + var terminal = flowBroker.GetLatestStatus(new McpServerName(name)); + return TypedResults.Ok(new McpOAuthStatusResponse( + terminal.Status.ToString(), + terminal.Error)); }) .WithName("GetMcpOAuthStatus") .WithSummary("Get the OAuth flow status for an MCP server by name.") .WithTags("MCP") .RequireAuthorization(); - app.MapGet("/api/mcp/oauth/status-by-state/{state}", (string state, McpOAuthService oauthService) => + app.MapGet("/api/mcp/oauth/status-by-state/{state}", (string state, McpOAuthFlowBroker flowBroker) => { - var status = oauthService.GetFlowStatusByState(state); + var terminal = flowBroker.GetStatusByState(state); // Tokens are persisted daemon-side — never expose them over HTTP. - return TypedResults.Ok(new McpOAuthStatusResponse(status.ToString())); + return TypedResults.Ok(new McpOAuthStatusResponse( + terminal.Status.ToString(), + terminal.Error)); }) .WithName("GetMcpOAuthStatusByState") .WithSummary("Get the OAuth flow status for an MCP server by state token.") diff --git a/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs b/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs new file mode 100644 index 000000000..2edaaf009 --- /dev/null +++ b/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs @@ -0,0 +1,737 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Authentication; +using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; +using Netclaw.Tools; + +namespace Netclaw.Daemon.Mcp; + +internal enum McpOAuthCredentialTarget +{ + Active, + Pending, +} + +internal sealed class McpOAuthStaleCredentialEpochException(string message) : InvalidOperationException(message); + +internal sealed class McpOAuthClientContext( + string? clientId, + string? clientSecret, + bool dynamicClientRegistration, + string canonicalResource, + string? baseActiveEpoch) +{ + private readonly object _sync = new(); + private string? _clientId = clientId; + private string? _clientSecret = clientSecret; + private bool _dynamicClientRegistration = dynamicClientRegistration; + private McpOAuthCredentialTarget _target = McpOAuthCredentialTarget.Active; + private string? _flowId; + private DateTimeOffset? _pendingExpiresAt; + private string? _expectedActiveEpoch = baseActiveEpoch; + private bool _withholdAccessToken; + private bool _publishedOwner; + private string _ownerEpoch = Guid.NewGuid().ToString("N"); + + public string CanonicalResource { get; } = canonicalResource; + + public string? BaseActiveEpoch { get; } = baseActiveEpoch; + + public string OwnerEpoch + { + get + { + lock (_sync) + return _ownerEpoch; + } + } + + public McpOAuthClientIdentity SnapshotIdentity() + { + lock (_sync) + return new McpOAuthClientIdentity(_clientId, _clientSecret, _dynamicClientRegistration); + } + + public McpOAuthCredentialView SnapshotCredentialView() + { + lock (_sync) + { + return new McpOAuthCredentialView( + _target, + _flowId, + _pendingExpiresAt, + _expectedActiveEpoch, + _withholdAccessToken, + _publishedOwner, + _ownerEpoch); + } + } + + public void ConfigureCredentialView( + McpOAuthCredentialTarget target, + string? flowId, + DateTimeOffset? pendingExpiresAt, + bool withholdAccessToken) + { + lock (_sync) + { + _target = target; + _flowId = flowId; + _pendingExpiresAt = pendingExpiresAt; + _withholdAccessToken = withholdAccessToken; + } + } + + public void CaptureDynamicRegistration(DynamicClientRegistrationResponse response) + { + lock (_sync) + { + _clientId = response.ClientId; + _clientSecret = response.ClientSecret; + _dynamicClientRegistration = true; + } + } + + public void MarkTokensStored(McpOAuthCredentialTarget target, string committedEpoch) + { + lock (_sync) + { + _withholdAccessToken = false; + if (target is McpOAuthCredentialTarget.Active) + { + _expectedActiveEpoch = committedEpoch; + if (_publishedOwner) + _ownerEpoch = committedEpoch; + } + } + } + + public void Activate() + { + lock (_sync) + { + _target = McpOAuthCredentialTarget.Active; + _flowId = null; + _pendingExpiresAt = null; + _expectedActiveEpoch = _ownerEpoch; + _withholdAccessToken = false; + _publishedOwner = true; + } + } +} + +internal sealed record McpOAuthClientIdentity( + string? ClientId, + string? ClientSecret, + bool DynamicClientRegistration); + +internal sealed record McpOAuthCredentialView( + McpOAuthCredentialTarget Target, + string? FlowId, + DateTimeOffset? PendingExpiresAt, + string? ExpectedActiveEpoch, + bool WithholdAccessToken, + bool PublishedOwner, + string OwnerEpoch); + +/// +/// Shared durable authority for every MCP SDK token cache. Epoch-conditional +/// transactions reject writes from retired connections and stale processes. +/// +internal sealed class McpOAuthCredentialStore +{ + internal const string SectionKey = "McpOAuthTokens"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly NetclawPaths _paths; + private readonly TimeProvider _timeProvider; + private readonly ISecretsProtector _protector; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _credentials = new(); + private readonly ConcurrentDictionary _serverLocks = new(); + + public McpOAuthCredentialStore( + NetclawPaths paths, + TimeProvider timeProvider, + ISecretsProtector protector, + ILogger logger) + { + _paths = paths; + _timeProvider = timeProvider; + _protector = protector ?? throw new ArgumentNullException(nameof(protector)); + _logger = logger; + LoadAndRecoverDurableState(); + } + + public static string CanonicalizeResource(string endpoint) + { + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + throw new ArgumentException("MCP OAuth resource must be an absolute URI.", nameof(endpoint)); + + var builder = new UriBuilder(uri) { Fragment = string.Empty }; + if (builder.Uri.IsDefaultPort) + builder.Port = -1; + return builder.Uri.AbsoluteUri; + } + + public McpOAuthClientContext CreateContext( + McpServerName serverName, + string resourceIdentity, + string? configuredClientId, + bool explicitAuthorization) + { + var canonicalResource = CanonicalizeResource(resourceIdentity); + lock (GetServerLock(serverName)) + { + var envelope = GetEnvelope(serverName); + var active = IsBound(envelope.Active, canonicalResource) ? envelope.Active : null; + var baseActiveEpoch = envelope.Active?.CredentialEpoch; + if (!string.IsNullOrWhiteSpace(configuredClientId)) + { + return new McpOAuthClientContext( + configuredClientId, + null, + false, + canonicalResource, + baseActiveEpoch); + } + + var clientId = active is { DynamicClientRegistration: true } ? active.ClientId : null; + var clientSecret = active is { DynamicClientRegistration: true } ? active.ClientSecret?.Value : null; + var dynamic = !string.IsNullOrWhiteSpace(clientId); + if (explicitAuthorization + && dynamic + && string.Equals(envelope.RejectedDynamicClientId, clientId, StringComparison.Ordinal)) + { + clientId = null; + clientSecret = null; + dynamic = false; + } + + return new McpOAuthClientContext( + clientId, + clientSecret, + dynamic, + canonicalResource, + baseActiveEpoch); + } + } + + public ITokenCache CreateTokenCache( + McpServerName serverName, + string resourceIdentity, + McpOAuthClientContext context, + McpOAuthCredentialTarget target, + string? flowId, + DateTimeOffset? pendingExpiresAt, + bool withholdAccessToken) + { + var canonical = CanonicalizeResource(resourceIdentity); + if (!string.Equals(canonical, context.CanonicalResource, StringComparison.Ordinal)) + throw new InvalidOperationException("OAuth context resource does not match the token-cache resource."); + if (target is McpOAuthCredentialTarget.Pending + && (string.IsNullOrWhiteSpace(flowId) || pendingExpiresAt is null)) + throw new InvalidOperationException("Pending OAuth credentials require a flow identity and expiry."); + + context.ConfigureCredentialView(target, flowId, pendingExpiresAt, withholdAccessToken); + return new CredentialTokenCache(this, serverName, context); + } + + public void CaptureDynamicRegistration( + McpServerName serverName, + McpOAuthClientContext context, + DynamicClientRegistrationResponse response, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(response); + cancellationToken.ThrowIfCancellationRequested(); + lock (GetServerLock(serverName)) + context.CaptureDynamicRegistration(response); + } + + public McpOAuthTokenSet? GetBoundActive(McpServerName serverName, string resourceIdentity) + { + var canonical = CanonicalizeResource(resourceIdentity); + lock (GetServerLock(serverName)) + { + var active = GetEnvelope(serverName).Active; + return IsBound(active, canonical) ? active : null; + } + } + + public bool HasAnyActive(McpServerName serverName) + { + lock (GetServerLock(serverName)) + return GetEnvelope(serverName).Active is not null; + } + + public bool RequiresAuthorization(McpServerName serverName, string resourceIdentity) + { + var active = GetBoundActive(serverName, resourceIdentity); + return active is null + || active.ExpiresAt is { } expiresAt + && expiresAt <= _timeProvider.GetUtcNow() + && active.RefreshToken is null; + } + + /// + /// Transfers active credential ownership to a newly published ordinary + /// connection. No record is created for a server that has no OAuth credentials. + /// + public void ClaimActiveEpoch( + McpServerName serverName, + McpOAuthClientContext context, + CancellationToken cancellationToken) + { + lock (GetServerLock(serverName)) + { + var current = GetEnvelope(serverName); + var view = context.SnapshotCredentialView(); + if (view.Target is not McpOAuthCredentialTarget.Active) + throw new InvalidOperationException("A pending OAuth view cannot claim the active epoch."); + + var committed = CommitEnvelope( + serverName, + current, + latest => + { + if (latest.Active is null) + return view.ExpectedActiveEpoch is null + ? EnvelopeMutation.Unchanged + : EnvelopeMutation.Stale("Active OAuth credentials were removed before publication."); + if (!IsBound(latest.Active, context.CanonicalResource)) + return EnvelopeMutation.Unchanged; + if (string.Equals(latest.Active.CredentialEpoch, view.OwnerEpoch, StringComparison.Ordinal)) + return EnvelopeMutation.Unchanged; + if (!EpochEquals(latest.Active.CredentialEpoch, view.ExpectedActiveEpoch)) + return EnvelopeMutation.Stale("Active OAuth credentials changed before candidate publication."); + + latest.Active.CredentialEpoch = view.OwnerEpoch; + return EnvelopeMutation.Changed; + }, + cancellationToken); + + if (IsBound(committed.Active, context.CanonicalResource) + && string.Equals(committed.Active!.CredentialEpoch, view.OwnerEpoch, StringComparison.Ordinal)) + context.Activate(); + } + } + + public void PromotePending( + McpServerName serverName, + McpOAuthClientContext context, + string flowId, + CancellationToken cancellationToken) + { + lock (GetServerLock(serverName)) + { + var current = GetEnvelope(serverName); + var view = context.SnapshotCredentialView(); + CommitEnvelope( + serverName, + current, + latest => + { + var pending = latest.Pending; + if (pending is null + || !string.Equals(pending.FlowId, flowId, StringComparison.Ordinal) + || !string.Equals(pending.Credentials.CredentialEpoch, view.OwnerEpoch, StringComparison.Ordinal)) + { + return EnvelopeMutation.Stale("Pending OAuth credentials no longer belong to this flow epoch."); + } + if (!EpochEquals(latest.Active?.CredentialEpoch, context.BaseActiveEpoch)) + return EnvelopeMutation.Stale("Active OAuth credentials changed while authorization was pending."); + + latest.Active = pending.Credentials; + latest.Pending = null; + latest.RejectedDynamicClientId = null; + return EnvelopeMutation.Changed; + }, + cancellationToken); + context.Activate(); + } + } + + public void RemovePending(McpServerName serverName, string flowId, CancellationToken cancellationToken) + { + lock (GetServerLock(serverName)) + { + var current = GetEnvelope(serverName); + CommitEnvelope( + serverName, + current, + latest => + { + if (latest.Pending is null + || !string.Equals(latest.Pending.FlowId, flowId, StringComparison.Ordinal)) + return EnvelopeMutation.Unchanged; + latest.Pending = null; + return EnvelopeMutation.Changed; + }, + cancellationToken); + } + } + + public void MarkDynamicIdentityRejected( + McpServerName serverName, + string resourceIdentity, + string? configuredClientId, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(configuredClientId)) + return; + + var canonical = CanonicalizeResource(resourceIdentity); + lock (GetServerLock(serverName)) + { + var current = GetEnvelope(serverName); + CommitEnvelope( + serverName, + current, + latest => + { + var active = latest.Active; + if (!IsBound(active, canonical) + || active is not { DynamicClientRegistration: true } + || string.IsNullOrWhiteSpace(active.ClientId)) + return EnvelopeMutation.Unchanged; + + latest.RejectedDynamicClientId = active.ClientId; + return EnvelopeMutation.Changed; + }, + cancellationToken); + } + } + + internal McpOAuthCredentialEnvelope GetEnvelopeForTests(McpServerName serverName) + { + lock (GetServerLock(serverName)) + return Clone(GetEnvelope(serverName)); + } + + private TokenContainer? ReadTokens( + McpServerName serverName, + McpOAuthClientContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (GetServerLock(serverName)) + { + var view = context.SnapshotCredentialView(); + if (view.WithholdAccessToken) + return null; + var envelope = GetEnvelope(serverName); + McpOAuthTokenSet? credentials; + if (view.Target is McpOAuthCredentialTarget.Pending) + { + var pending = envelope.Pending; + credentials = pending is not null + && string.Equals(pending.FlowId, view.FlowId, StringComparison.Ordinal) + && string.Equals(pending.Credentials.CredentialEpoch, view.OwnerEpoch, StringComparison.Ordinal) + ? pending.Credentials + : null; + } + else + { + credentials = envelope.Active is not null + && EpochEquals(envelope.Active.CredentialEpoch, view.ExpectedActiveEpoch) + ? envelope.Active + : null; + } + + if (!IsBound(credentials, context.CanonicalResource)) + return null; + return ToTokenContainer(credentials!); + } + } + + private void StoreTokens( + McpServerName serverName, + McpOAuthClientContext context, + TokenContainer tokens, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (GetServerLock(serverName)) + { + var current = GetEnvelope(serverName); + var view = context.SnapshotCredentialView(); + var identity = context.SnapshotIdentity(); + string? committedEpoch = null; + CommitEnvelope( + serverName, + current, + latest => + { + McpOAuthTokenSet? retainedFrom; + if (view.Target is McpOAuthCredentialTarget.Active) + { + if (!EpochEquals(latest.Active?.CredentialEpoch, view.ExpectedActiveEpoch)) + return EnvelopeMutation.Stale("A retired OAuth connection attempted to replace active credentials."); + retainedFrom = IsBound(latest.Active, context.CanonicalResource) + ? latest.Active + : null; + if (latest.Active is not null && retainedFrom is null) + return EnvelopeMutation.Stale("Active OAuth credentials are bound to another resource."); + } + else + { + if (string.IsNullOrWhiteSpace(view.FlowId) || view.PendingExpiresAt is null) + return EnvelopeMutation.Stale("Pending OAuth credential context is incomplete."); + if (!EpochEquals(latest.Active?.CredentialEpoch, context.BaseActiveEpoch)) + return EnvelopeMutation.Stale("Active OAuth credentials changed while authorization was pending."); + + if (latest.Pending is null) + { + retainedFrom = IsBound(latest.Active, context.CanonicalResource) + ? latest.Active + : null; + } + else if (string.Equals(latest.Pending.FlowId, view.FlowId, StringComparison.Ordinal) + && string.Equals( + latest.Pending.Credentials.CredentialEpoch, + view.OwnerEpoch, + StringComparison.Ordinal) + && IsBound(latest.Pending.Credentials, context.CanonicalResource)) + { + retainedFrom = latest.Pending.Credentials; + } + else + { + return EnvelopeMutation.Stale("Another OAuth flow owns the pending credential record."); + } + } + + var replacementEpoch = view.Target is McpOAuthCredentialTarget.Pending + ? view.OwnerEpoch + : view.PublishedOwner + ? Guid.NewGuid().ToString("N") + : view.ExpectedActiveEpoch ?? view.OwnerEpoch; + committedEpoch = replacementEpoch; + var replacement = CreateReplacement( + tokens, + retainedFrom, + identity, + context, + replacementEpoch); + if (view.Target is McpOAuthCredentialTarget.Active) + { + latest.Active = replacement; + } + else + { + latest.Pending = new McpOAuthPendingCredential + { + FlowId = view.FlowId!, + ExpiresAt = view.PendingExpiresAt!.Value, + Credentials = replacement, + }; + } + + return EnvelopeMutation.Changed; + }, + cancellationToken); + context.MarkTokensStored(view.Target, committedEpoch!); + } + } + + private McpOAuthTokenSet CreateReplacement( + TokenContainer tokens, + McpOAuthTokenSet? retainedFrom, + McpOAuthClientIdentity identity, + McpOAuthClientContext context, + string credentialEpoch) + { + var obtainedAt = tokens.ObtainedAt == default ? _timeProvider.GetUtcNow() : tokens.ObtainedAt; + return new McpOAuthTokenSet + { + AccessToken = new SensitiveString(tokens.AccessToken), + RefreshToken = tokens.RefreshToken is not null + ? new SensitiveString(tokens.RefreshToken) + : retainedFrom?.RefreshToken, + ExpiresAt = tokens.ExpiresIn is { } expiresIn + ? obtainedAt.AddSeconds(expiresIn) + : null, + TokenType = string.IsNullOrWhiteSpace(tokens.TokenType) ? "Bearer" : tokens.TokenType, + Scope = tokens.Scope, + ObtainedAt = obtainedAt, + ClientId = identity.ClientId, + ClientSecret = identity.ClientSecret is null ? null : new SensitiveString(identity.ClientSecret), + DynamicClientRegistration = identity.DynamicClientRegistration, + ResourceIdentity = context.CanonicalResource, + CredentialEpoch = credentialEpoch, + }; + } + + private static TokenContainer ToTokenContainer(McpOAuthTokenSet credentials) + { + var expiresIn = credentials.ExpiresAt is { } expiresAt + ? (int)Math.Max(0, (expiresAt - credentials.ObtainedAt).TotalSeconds) + : (int?)null; + return new TokenContainer + { + TokenType = credentials.TokenType, + AccessToken = credentials.AccessToken.Value, + RefreshToken = credentials.RefreshToken?.Value, + ExpiresIn = expiresIn, + ObtainedAt = credentials.ObtainedAt, + Scope = credentials.Scope, + }; + } + + private McpOAuthCredentialEnvelope CommitEnvelope( + McpServerName serverName, + McpOAuthCredentialEnvelope current, + Func mutate, + CancellationToken cancellationToken) + { + var outcome = SecretsFileWriter.Update( + _paths.SecretsPath, + (root, _) => + { + var section = root[SectionKey]?.AsObject() ?? []; + root[SectionKey] = section; + var latest = ReadEnvelope(section[serverName.Value]) ?? Clone(current); + var mutation = mutate(latest); + if (mutation.StaleReason is not null) + return (null, new EnvelopeCommitResult(latest, mutation.StaleReason)); + if (!mutation.HasChanges) + return (null, new EnvelopeCommitResult(latest, null)); + + section[serverName.Value] = JsonSerializer.SerializeToNode(latest, JsonOptions); + return (root, new EnvelopeCommitResult(latest, null)); + }, + JsonOptions, + _protector, + cancellationToken); + + _credentials[serverName] = outcome.Envelope; + if (outcome.StaleReason is not null) + throw new McpOAuthStaleCredentialEpochException(outcome.StaleReason); + return outcome.Envelope; + } + + private void LoadAndRecoverDurableState() + { + if (!File.Exists(_paths.SecretsPath)) + return; + + var loaded = SecretsFileWriter.Update>( + _paths.SecretsPath, + (root, _) => + { + var result = new Dictionary(); + var changed = false; + if (root[SectionKey] is not JsonObject section) + return (null, result); + + foreach (var (name, node) in section) + { + if (node is null) + continue; + var envelope = ReadEnvelope(node); + if (envelope is null) + continue; + + // A pending record cannot resume without its broker flow. A + // promoted active record is complete durable state and remains + // usable after a crash between promotion and publication. + if (envelope.Pending is not null) + { + envelope.Pending = null; + changed = true; + } + if (envelope.Active is { ResourceIdentity: not null, CredentialEpoch: null }) + { + envelope.Active.CredentialEpoch = Guid.NewGuid().ToString("N"); + changed = true; + } + + if (changed) + section[name] = JsonSerializer.SerializeToNode(envelope, JsonOptions); + result[new McpServerName(name)] = envelope; + } + + return (changed ? root : null, result); + }, + JsonOptions, + _protector); + + foreach (var (name, envelope) in loaded) + _credentials[name] = envelope; + if (loaded.Count > 0) + _logger.LogDebug("Loaded MCP OAuth credentials for {Count} server(s)", loaded.Count); + } + + private object GetServerLock(McpServerName serverName) + => _serverLocks.GetOrAdd(serverName, static _ => new object()); + + private McpOAuthCredentialEnvelope GetEnvelope(McpServerName serverName) + => _credentials.GetOrAdd(serverName, static _ => new McpOAuthCredentialEnvelope()); + + private static bool IsBound(McpOAuthTokenSet? credentials, string canonicalResource) + => credentials?.ResourceIdentity is { } binding + && string.Equals(binding, canonicalResource, StringComparison.Ordinal); + + private static bool EpochEquals(string? left, string? right) + => string.Equals(left, right, StringComparison.Ordinal); + + private static McpOAuthCredentialEnvelope? ReadEnvelope(JsonNode? node) + { + if (node is not JsonObject obj) + return null; + if (obj.ContainsKey(nameof(McpOAuthCredentialEnvelope.Active)) + || obj.ContainsKey(nameof(McpOAuthCredentialEnvelope.Pending)) + || obj.ContainsKey(nameof(McpOAuthCredentialEnvelope.RejectedDynamicClientId))) + return obj.Deserialize(JsonOptions); + + var legacy = obj.Deserialize(JsonOptions); + return legacy is null ? null : new McpOAuthCredentialEnvelope { Active = legacy }; + } + + private static McpOAuthCredentialEnvelope Clone(McpOAuthCredentialEnvelope envelope) + => JsonSerializer.Deserialize( + JsonSerializer.Serialize(envelope, JsonOptions), JsonOptions)!; + + private sealed record EnvelopeCommitResult( + McpOAuthCredentialEnvelope Envelope, + string? StaleReason); + + private readonly record struct EnvelopeMutation(bool HasChanges, string? StaleReason) + { + public static EnvelopeMutation Changed => new(true, null); + + public static EnvelopeMutation Unchanged => new(false, null); + + public static EnvelopeMutation Stale(string reason) => new(false, reason); + } + + private sealed class CredentialTokenCache( + McpOAuthCredentialStore store, + McpServerName serverName, + McpOAuthClientContext context) : ITokenCache + { + public ValueTask GetTokensAsync(CancellationToken cancellationToken) + => new(store.ReadTokens(serverName, context, cancellationToken)); + + public ValueTask StoreTokensAsync(TokenContainer tokens, CancellationToken cancellationToken) + { + store.StoreTokens(serverName, context, tokens, cancellationToken); + return default; + } + } +} diff --git a/src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs b/src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs new file mode 100644 index 000000000..c9fa20448 --- /dev/null +++ b/src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs @@ -0,0 +1,402 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Security.Cryptography; +using Netclaw.Tools; + +namespace Netclaw.Daemon.Mcp; + +internal sealed record McpOAuthFlowStart( + McpOAuthFlow Flow, + bool Created); + +internal sealed record McpOAuthFlowTerminal( + McpOAuthFlowStatus Status, + McpErrorResponse? Error); + +internal sealed class McpOAuthFlowBroker : IDisposable +{ + internal static readonly TimeSpan FlowLifetime = TimeSpan.FromMinutes(5); + + private readonly object _sync = new(); + private readonly Dictionary _activeByServer = []; + private readonly Dictionary _latestByServer = []; + private readonly Dictionary _byState = new(StringComparer.Ordinal); + private readonly TimeProvider _timeProvider; + private readonly CancellationToken _daemonCancellation; + private bool _disposed; + + public McpOAuthFlowBroker(TimeProvider timeProvider, CancellationToken daemonCancellation) + { + _timeProvider = timeProvider; + _daemonCancellation = daemonCancellation; + } + + public McpOAuthFlowStart StartOrJoin(McpServerName serverName) + { + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + PruneTombstones(); + if (_activeByServer.TryGetValue(serverName, out var active) && !active.IsTerminal) + return new McpOAuthFlowStart(active, false); + + var state = CreateOpaqueState(); + var flow = new McpOAuthFlow( + serverName, + state, + _timeProvider.GetUtcNow().Add(FlowLifetime), + _timeProvider, + _daemonCancellation, + OnFlowExpired); + _activeByServer[serverName] = flow; + _latestByServer[serverName] = flow; + _byState[state] = flow; + return new McpOAuthFlowStart(flow, true); + } + } + + public bool TryGetActive(McpServerName serverName, out McpOAuthFlow? flow) + { + lock (_sync) + return _activeByServer.TryGetValue(serverName, out flow); + } + + public McpOAuthFlowStatus GetStatus(McpServerName serverName) + => GetLatestStatus(serverName).Status; + + public McpOAuthFlowTerminal GetLatestStatus(McpServerName serverName) + { + lock (_sync) + { + PruneTombstones(); + return _latestByServer.TryGetValue(serverName, out var flow) + ? flow.Terminal + : new McpOAuthFlowTerminal(McpOAuthFlowStatus.NotStarted, null); + } + } + + public McpOAuthFlowTerminal GetStatusByState(string state) + { + lock (_sync) + { + PruneTombstones(); + return _byState.TryGetValue(state, out var flow) + ? flow.Terminal + : new McpOAuthFlowTerminal(McpOAuthFlowStatus.NotStarted, null); + } + } + + public McpOAuthFlow GetForCallback(string state) + { + lock (_sync) + { + PruneTombstones(); + if (!_byState.TryGetValue(state, out var flow)) + throw new McpOAuthCallbackException("The authorization state is unknown or no longer valid."); + var now = _timeProvider.GetUtcNow(); + if (flow.ExpiresAt <= now && flow.ShouldExpire(now)) + { + flow.Fail(new McpErrorResponse( + "Authorization expired. Start a new MCP authorization attempt.", + "callback validation")); + RemoveActive(flow); + throw new McpOAuthCallbackException("The authorization state has expired."); + } + + if (flow.IsTerminal) + throw new McpOAuthCallbackException("The authorization state has already been used."); + return flow; + } + } + + public void Complete(McpOAuthFlow flow) + { + flow.Complete(); + lock (_sync) + RemoveActive(flow); + } + + /// + /// Claims the unexpired flow for the non-cancellable commit sequence: + /// durable promotion, cache activation, publication, then completion. + /// + public void BeginCommit(McpOAuthFlow flow) + { + lock (_sync) + { + var now = _timeProvider.GetUtcNow(); + if (!_activeByServer.TryGetValue(flow.ServerName, out var active) + || !ReferenceEquals(active, flow) + || !flow.TryBeginCommit(now)) + { + if (flow.ShouldExpire(now)) + { + flow.Fail(new McpErrorResponse( + "Authorization expired before credentials could publish. Start a new attempt.", + "credential commit")); + RemoveActive(flow); + } + + throw new McpOAuthOperationException(new McpErrorResponse( + "Authorization can no longer publish. Start a new MCP authorization attempt.", + "credential commit")); + } + } + } + + public void Fail(McpOAuthFlow flow, McpErrorResponse error) + { + flow.Fail(error); + lock (_sync) + RemoveActive(flow); + } + + public void Dispose() + { + List flows; + lock (_sync) + { + if (_disposed) + return; + _disposed = true; + flows = _byState.Values.Distinct().ToList(); + _activeByServer.Clear(); + _latestByServer.Clear(); + _byState.Clear(); + } + + foreach (var flow in flows) + { + flow.Fail(new McpErrorResponse("The daemon stopped the authorization flow.", "daemon shutdown")); + flow.Dispose(); + } + } + + private void OnFlowExpired(McpOAuthFlow flow) + { + lock (_sync) + { + if (!flow.ShouldExpire(_timeProvider.GetUtcNow())) + return; + flow.Fail(new McpErrorResponse( + "Authorization expired. Start a new MCP authorization attempt.", + "authorization timeout")); + RemoveActive(flow); + } + } + + private void RemoveActive(McpOAuthFlow flow) + { + if (_activeByServer.TryGetValue(flow.ServerName, out var current) + && ReferenceEquals(current, flow)) + _activeByServer.Remove(flow.ServerName); + } + + private void PruneTombstones() + { + var cutoff = _timeProvider.GetUtcNow() - FlowLifetime; + foreach (var (state, flow) in _byState.ToArray()) + { + if (!flow.IsTerminal || flow.ExpiresAt > cutoff) + continue; + + _byState.Remove(state); + if (_latestByServer.TryGetValue(flow.ServerName, out var latest) + && ReferenceEquals(latest, flow)) + _latestByServer.Remove(flow.ServerName); + flow.Dispose(); + } + } + + private static string CreateOpaqueState() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } +} + +internal sealed class McpOAuthFlow : IDisposable +{ + private readonly object _sync = new(); + private readonly TaskCompletionSource _authorizationUrl = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _authorizationCode = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _terminal = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly CancellationTokenSource _lifetimeCancellation; + private readonly ITimer _expiryTimer; + private int _delegateOwner; + private bool _codeDelivered; + private bool _commitClaimed; + private McpOAuthFlowTerminal _status = new(McpOAuthFlowStatus.Pending, null); + + public McpOAuthFlow( + McpServerName serverName, + string state, + DateTimeOffset expiresAt, + TimeProvider timeProvider, + CancellationToken daemonCancellation, + Action expired) + { + ServerName = serverName; + State = state; + ExpiresAt = expiresAt; + _lifetimeCancellation = CancellationTokenSource.CreateLinkedTokenSource(daemonCancellation); + _expiryTimer = timeProvider.CreateTimer( + static state => + { + var callback = (ExpiryCallback)state!; + callback.Expired(callback.Flow); + }, + new ExpiryCallback(this, expired), + McpOAuthFlowBroker.FlowLifetime, + Timeout.InfiniteTimeSpan); + } + + public McpServerName ServerName { get; } + + public string State { get; } + + public DateTimeOffset ExpiresAt { get; } + + public CancellationToken CancellationToken => _lifetimeCancellation.Token; + + public bool IsTerminal => _terminal.Task.IsCompleted; + + public McpOAuthFlowTerminal Terminal + { + get + { + lock (_sync) + return _status; + } + } + + public async Task WaitForAuthorizationUrlAsync(CancellationToken requestCancellation) + => await _authorizationUrl.Task.WaitAsync(requestCancellation); + + public async Task WaitForTerminalAsync(CancellationToken requestCancellation) + => await _terminal.Task.WaitAsync(requestCancellation); + + public async Task HandleAuthorizationRedirectAsync( + Uri authorizationUri, + Uri redirectUri, + CancellationToken sdkCancellation) + { + if (Interlocked.CompareExchange(ref _delegateOwner, 1, 0) != 0) + throw new McpOAuthAuthorizationInProgressException(ServerName); + + _authorizationUrl.TrySetResult(authorizationUri); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + sdkCancellation, + _lifetimeCancellation.Token); + return await _authorizationCode.Task.WaitAsync(cancellation.Token); + } + + public void DeliverCode(string code) + { + lock (_sync) + { + if (_terminal.Task.IsCompleted || _codeDelivered) + throw new McpOAuthCallbackException("The authorization state has already been used."); + _codeDelivered = true; + } + + _authorizationCode.TrySetResult(code); + } + + public bool TryBeginCommit(DateTimeOffset now) + { + lock (_sync) + { + if (_terminal.Task.IsCompleted + || _commitClaimed + || !_codeDelivered + || now >= ExpiresAt) + return false; + _commitClaimed = true; + _expiryTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + return true; + } + } + + public bool ShouldExpire(DateTimeOffset now) + { + lock (_sync) + return !_terminal.Task.IsCompleted && !_commitClaimed && now >= ExpiresAt; + } + + public void Complete() + { + var terminal = new McpOAuthFlowTerminal(McpOAuthFlowStatus.Completed, null); + lock (_sync) + { + if (_terminal.Task.IsCompleted) + return; + if (!_commitClaimed) + throw new InvalidOperationException("OAuth flow completed without claiming its commit transition."); + _status = terminal; + _terminal.TrySetResult(terminal); + } + EndLifetime(); + } + + public void Fail(McpErrorResponse error) + { + var terminal = new McpOAuthFlowTerminal(McpOAuthFlowStatus.Failed, error); + lock (_sync) + { + if (_terminal.Task.IsCompleted) + return; + _status = terminal; + _terminal.TrySetResult(terminal); + } + + _authorizationUrl.TrySetException(new McpOAuthOperationException(error)); + _authorizationCode.TrySetCanceled(_lifetimeCancellation.Token); + EndLifetime(); + } + + public void Dispose() + { + _expiryTimer.Dispose(); + _lifetimeCancellation.Dispose(); + } + + private void EndLifetime() + { + _expiryTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + try + { + _lifetimeCancellation.Cancel(); + } + catch (ObjectDisposedException) + { + return; + } + } + + private sealed record ExpiryCallback(McpOAuthFlow Flow, Action Expired); +} + +internal sealed class McpOAuthOperationException(McpErrorResponse error) : Exception(error.Error) +{ + public McpErrorResponse Error { get; } = error; +} + +internal sealed class McpOAuthCallbackException(string message) : Exception(message); + +internal sealed class McpOAuthAuthorizationInProgressException(McpServerName serverName) + : Exception($"OAuth authorization is already in progress for MCP server '{serverName.Value}'."); + +internal enum McpOAuthFlowStatus +{ + NotStarted, + Pending, + Completed, + Failed, +} diff --git a/src/Netclaw.Daemon/Mcp/McpOAuthService.cs b/src/Netclaw.Daemon/Mcp/McpOAuthService.cs deleted file mode 100644 index 96a2b6580..000000000 --- a/src/Netclaw.Daemon/Mcp/McpOAuthService.cs +++ /dev/null @@ -1,663 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Collections.Concurrent; -using System.Net.Http.Json; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Authentication; -using Netclaw.Configuration; -using Netclaw.Configuration.Secrets; -using Netclaw.Providers.OAuth; -using Netclaw.Tools; - -namespace Netclaw.Daemon.Mcp; - -/// -/// Orchestrates the OAuth 2.1 Authorization Code + PKCE lifecycle for MCP servers. -/// Handles discovery, DCR, token persistence, and automatic token refresh. -/// Delegates PKCE generation, authorization URL construction, and token exchange -/// to . -/// -internal sealed class McpOAuthService -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly HttpClient _httpClient; - private readonly NetclawPaths _paths; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - private readonly OAuthPkceService _pkceService; - private readonly IOperationalNotificationSink _notificationSink; - private readonly ISecretsProtector? _protector; - - // In-memory caches, loaded from disk at construction - private readonly ConcurrentDictionary _tokens = new(); - private readonly ConcurrentDictionary _metadata = new(); - - // Maps PKCE state → flow context for token persistence after callback - private readonly ConcurrentDictionary _stateToContext = new(); - - // Maps PKCE state → server name for recently completed flows (for auto-reconnect) - private readonly ConcurrentDictionary _lastCompletedServerName = new(); - - public McpOAuthService( - HttpClient httpClient, - NetclawPaths paths, - TimeProvider timeProvider, - ILogger logger, - OAuthPkceService pkceService, - IOperationalNotificationSink notificationSink, - ISecretsProtector? protector = null) - { - _httpClient = httpClient; - _paths = paths; - _timeProvider = timeProvider; - _logger = logger; - _pkceService = pkceService; - _notificationSink = notificationSink; - _protector = protector; - - LoadTokensFromDisk(); - LoadMetadataFromDisk(); - } - - // ── Discovery ────────────────────────────────────────────────────── - - /// - /// Auto-detect whether an MCP server requires OAuth by probing its - /// well-known metadata endpoints. Returns null if no OAuth is needed. - /// - public async Task TryDiscoverMetadataAsync( - McpServerName serverName, string serverUrl, CancellationToken ct) - { - serverUrl = serverUrl.TrimEnd('/'); - - // Check cache (1-hour TTL) - if (_metadata.TryGetValue(serverName, out var cached) - && (_timeProvider.GetUtcNow() - cached.CachedAt).TotalHours < 1) - { - return cached; - } - - // 1. Probe the MCP server URL for a 401 with WWW-Authenticate - string? resourceMetadataUri = null; - try - { - using var probeRequest = new HttpRequestMessage(HttpMethod.Get, serverUrl); - using var probeResponse = await _httpClient.SendAsync(probeRequest, ct); - - if (probeResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized - && probeResponse.Headers.WwwAuthenticate.Count > 0) - { - foreach (var auth in probeResponse.Headers.WwwAuthenticate) - { - if (auth.Parameter is not null - && auth.Parameter.Contains("resource_metadata", StringComparison.Ordinal)) - { - resourceMetadataUri = ExtractQuotedParam(auth.Parameter, "resource_metadata"); - } - } - } - } - catch (HttpRequestException ex) - { - _logger.LogDebug(ex, "Probe of {ServerUrl} failed (expected for OAuth servers)", serverUrl); - } - - // 2. Resolve protected resource metadata - string? authServerUrl; - string? resourceIndicator = null; - - if (resourceMetadataUri is not null) - { - var resourceMeta = await _httpClient.GetFromJsonAsync(resourceMetadataUri, ct); - authServerUrl = resourceMeta.GetProperty("authorization_servers")[0].GetString(); - if (resourceMeta.TryGetProperty("resource", out var resProp)) - resourceIndicator = resProp.GetString(); - } - else - { - // Fallback: try well-known path on the MCP server origin - var origin = new Uri(serverUrl).GetLeftPart(UriPartial.Authority); - var wellKnownUrl = $"{origin}/.well-known/oauth-protected-resource"; - try - { - var resourceMeta = await _httpClient.GetFromJsonAsync(wellKnownUrl, ct); - authServerUrl = resourceMeta.GetProperty("authorization_servers")[0].GetString(); - if (resourceMeta.TryGetProperty("resource", out var resProp)) - resourceIndicator = resProp.GetString(); - } - catch (Exception ex) - { - // No OAuth metadata found — server doesn't require OAuth - _logger.LogDebug(ex, "No OAuth metadata at {Url} — server does not require OAuth", wellKnownUrl); - return null; - } - } - - if (string.IsNullOrEmpty(authServerUrl)) - return null; - - // 3. Resolve auth server metadata - var authMetaUrl = $"{authServerUrl.TrimEnd('/')}/.well-known/oauth-authorization-server"; - var authMeta = await _httpClient.GetFromJsonAsync(authMetaUrl, ct); - - var metadata = new McpOAuthServerMetadata - { - McpServerUrl = serverUrl, - AuthorizationEndpoint = authMeta.GetProperty("authorization_endpoint").GetString()!, - TokenEndpoint = authMeta.GetProperty("token_endpoint").GetString()!, - RegistrationEndpoint = authMeta.TryGetProperty("registration_endpoint", out var regProp) - ? regProp.GetString() : null, - ResourceIndicator = resourceIndicator ?? serverUrl, - CachedAt = _timeProvider.GetUtcNow(), - }; - - _metadata[serverName] = metadata; - PersistMetadata(); - - return metadata; - } - - // ── Client Registration (RFC 7591 DCR) ───────────────────────────── - - /// - /// Ensure a client_id is available for this server. Uses the static - /// if set, otherwise attempts - /// dynamic client registration. - /// - public async Task EnsureClientRegisteredAsync( - McpServerName serverName, McpServerEntry entry, McpOAuthServerMetadata metadata, CancellationToken ct) - { - // Static client ID takes precedence - if (!string.IsNullOrWhiteSpace(entry.OAuthClientId)) - { - metadata.ClientId = entry.OAuthClientId; - _metadata[serverName] = metadata; - PersistMetadata(); - return entry.OAuthClientId; - } - - // Already registered? - if (!string.IsNullOrWhiteSpace(metadata.ClientId)) - return metadata.ClientId; - - // Dynamic client registration - if (string.IsNullOrWhiteSpace(metadata.RegistrationEndpoint)) - throw new InvalidOperationException( - $"MCP server '{serverName.Value}' requires OAuth but no client_id is configured " + - "and the auth server doesn't support dynamic client registration. " + - $"Set OAuthClientId in the MCP server config."); - - var dcrPayload = new - { - client_name = "netclaw", - redirect_uris = new[] { "http://127.0.0.1:5199/api/mcp/oauth/callback" }, - grant_types = new[] { "authorization_code", "refresh_token" }, - response_types = new[] { "code" }, - token_endpoint_auth_method = "none", - }; - - var response = await _httpClient.PostAsJsonAsync(metadata.RegistrationEndpoint, dcrPayload, ct); - response.EnsureSuccessStatusCode(); - - var result = await response.Content.ReadFromJsonAsync(ct); - var clientId = result.GetProperty("client_id").GetString() - ?? throw new InvalidOperationException("DCR response missing client_id"); - - metadata.ClientId = clientId; - _metadata[serverName] = metadata; - PersistMetadata(); - - _logger.LogInformation("Registered OAuth client for MCP server '{Name}': {ClientId}", serverName.Value, clientId); - return clientId; - } - - // ── Start Authorization Flow ─────────────────────────────────────── - - /// - /// Discover metadata, ensure client registration, then delegate to - /// for PKCE generation and URL construction. - /// - public async Task<(string AuthorizationUrl, string State)> StartAuthorizationFlowAsync( - McpServerName serverName, McpServerEntry entry, CancellationToken ct) - { - var metadata = await TryDiscoverMetadataAsync(serverName, entry.Url!, ct) - ?? throw new InvalidOperationException( - $"MCP server '{serverName.Value}' does not advertise OAuth metadata. " + - "No /.well-known/oauth-protected-resource was found."); - var clientId = await EnsureClientRegisteredAsync(serverName, entry, metadata, ct); - - var redirectUri = "http://127.0.0.1:5199/api/mcp/oauth/callback"; - - // Build extra params for authorization URL (resource indicator) - Dictionary? extraAuthParams = null; - if (!string.IsNullOrWhiteSpace(metadata.ResourceIndicator)) - { - extraAuthParams = new Dictionary - { - ["resource"] = metadata.ResourceIndicator, - }; - } - - // Build extra params for token exchange/refresh (resource indicator per RFC 8707) - Dictionary? extraTokenParams = null; - if (!string.IsNullOrWhiteSpace(metadata.ResourceIndicator)) - { - extraTokenParams = new Dictionary - { - ["resource"] = metadata.ResourceIndicator, - }; - } - - var (authUrl, state) = _pkceService.StartAuthorizationFlow( - metadata.AuthorizationEndpoint, - metadata.TokenEndpoint, - clientId, - redirectUri, - entry.OAuthScope, - extraAuthParams, - extraTokenParams); - - // Store context so we can persist tokens when the callback arrives - _stateToContext[state] = new McpOAuthFlowContext( - serverName, clientId, metadata.ResourceIndicator, _timeProvider.GetUtcNow()); - - // Prune abandoned flows older than 10 minutes - PruneStaleFlowContexts(); - - _logger.LogInformation("Started OAuth flow for MCP server '{Name}' (state={State})", serverName.Value, state); - return (authUrl, state); - } - - // ── Complete Authorization Flow (callback) ───────────────────────── - - /// - /// Exchange the authorization code for tokens via , - /// then persist the result as an . - /// - public async Task CompleteAuthorizationAsync(string code, string state, CancellationToken ct) - { - if (!_stateToContext.TryGetValue(state, out var context)) - throw new InvalidOperationException($"Unknown OAuth state: {state}"); - - try - { - var result = await _pkceService.CompleteAuthorizationAsync(code, state, ct); - - // Convert OAuthDeviceFlowResult → McpOAuthTokenSet for persistence - var tokenSet = new McpOAuthTokenSet - { - AccessToken = result.AccessToken, - RefreshToken = result.RefreshToken, - ExpiresAt = result.ExpiresAt, - ClientId = context.ClientId, - McpServerUrl = context.ResourceIndicator, - }; - - _tokens[context.ServerName] = tokenSet; - PersistTokens(); - - // Cache the server name so callers can trigger reconnect after cleanup - _lastCompletedServerName[state] = context.ServerName; - - _logger.LogInformation("OAuth flow completed for MCP server '{Name}'", context.ServerName.Value); - } - finally - { - // Always clean up context — whether success or failure - _stateToContext.TryRemove(state, out _); - } - } - - /// - /// Returns the server name for a recently completed OAuth flow (by state). - /// Used to trigger auto-reconnect after token acquisition. - /// - public McpServerName? GetServerNameForState(string state) - { - if (_lastCompletedServerName.TryRemove(state, out var name)) - return name; - return null; - } - - // ── Token Access ─────────────────────────────────────────────────── - - /// - /// Get a valid access token for the given MCP server. Refreshes automatically - /// if the token is expired and a refresh token is available. Returns null - /// if no token is available or refresh fails. - /// - public async Task GetValidTokenAsync( - McpServerName serverName, McpServerEntry entry, CancellationToken ct) - { - if (!_tokens.TryGetValue(serverName, out var tokenSet)) - return null; - - // If token hasn't expired, use it - if (tokenSet.ExpiresAt is null || tokenSet.ExpiresAt > _timeProvider.GetUtcNow()) - return tokenSet.AccessToken.Value; - - // Attempt refresh - if (tokenSet.RefreshToken is null) - { - _logger.LogWarning("Access token expired for MCP server '{Name}' with no refresh token", serverName.Value); - - _notificationSink.Emit(OperationalAlert.Create( - _timeProvider, - "mcp.auth.expired", - AlertType.McpAuthExpired, - $"MCP server '{serverName.Value}' access token expired with no refresh token. Run: netclaw mcp auth {serverName.Value}", - AlertSeverity.Warning, - source: serverName.Value, - context: new Dictionary - { - ["serverName"] = serverName.Value, - ["reason"] = "no_refresh_token", - })); - - return null; - } - - return await RefreshTokenAsync(serverName, entry, tokenSet, ct); - } - - // ── Token Refresh ────────────────────────────────────────────────── - - private async Task RefreshTokenAsync( - McpServerName serverName, McpServerEntry entry, McpOAuthTokenSet tokenSet, CancellationToken ct) - { - if (!_metadata.TryGetValue(serverName, out var metadata)) - { - _logger.LogWarning("No cached metadata for MCP server '{Name}', cannot refresh token", serverName.Value); - return null; - } - - try - { - // Build extra params for resource indicator - Dictionary? extraParams = null; - if (!string.IsNullOrWhiteSpace(metadata.ResourceIndicator)) - { - extraParams = new Dictionary - { - ["resource"] = metadata.ResourceIndicator, - }; - } - - var result = await _pkceService.RefreshTokenAsync( - metadata.TokenEndpoint, - tokenSet.ClientId ?? entry.OAuthClientId ?? "", - tokenSet.RefreshToken!, - extraParams, - ct); - - if (result is null) - { - _logger.LogWarning("Refresh token rejected for MCP server '{Name}' (invalid_grant). Re-authorization required.", serverName.Value); - _tokens.TryRemove(serverName, out _); - PersistTokens(); - - _notificationSink.Emit(OperationalAlert.Create( - _timeProvider, - "mcp.auth.expired", - AlertType.McpAuthExpired, - $"MCP server '{serverName.Value}' refresh token rejected (invalid_grant). Run: netclaw mcp auth {serverName.Value}", - AlertSeverity.Warning, - source: serverName.Value, - context: new Dictionary - { - ["serverName"] = serverName.Value, - ["reason"] = "invalid_grant", - })); - - return null; - } - - var newTokenSet = new McpOAuthTokenSet - { - AccessToken = result.AccessToken, - RefreshToken = result.RefreshToken ?? tokenSet.RefreshToken, - ExpiresAt = result.ExpiresAt, - ClientId = tokenSet.ClientId, - McpServerUrl = tokenSet.McpServerUrl, - }; - - _tokens[serverName] = newTokenSet; - PersistTokens(); - - _logger.LogInformation("Token refreshed for MCP server '{Name}'", serverName.Value); - return newTokenSet.AccessToken.Value; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to refresh token for MCP server '{Name}'", serverName.Value); - return null; - } - } - - // ── SDK Token Cache Bridge ────────────────────────────────────────── - - /// - /// Creates an adapter that bridges the SDK's - /// token management to Netclaw's existing - /// persistence for the given server. - /// - internal ITokenCache CreateTokenCache(McpServerName serverName) - => new McpTokenCacheAdapter(serverName, _tokens, PersistTokens, _timeProvider); - - /// Returns the cached token set for the given server, or null. - internal McpOAuthTokenSet? GetTokenSet(McpServerName serverName) - => _tokens.TryGetValue(serverName, out var ts) ? ts : null; - - /// Returns the cached OAuth metadata for the given server, or null. - internal McpOAuthServerMetadata? GetCachedMetadata(McpServerName serverName) - => _metadata.TryGetValue(serverName, out var m) ? m : null; - - /// Persists a DCR-issued client_id into the metadata cache. - internal void UpdateMetadataClientId(McpServerName serverName, string clientId) - { - if (_metadata.TryGetValue(serverName, out var meta)) - { - meta.ClientId = clientId; - _metadata[serverName] = meta; - PersistMetadata(); - } - } - - // ── Flow Status (for CLI polling) ────────────────────────────────── - - public McpOAuthFlowStatus GetFlowStatus(McpServerName serverName) - { - // An active flow takes precedence over any previously stored token. - foreach (var ctx in _stateToContext.Values) - { - if (ctx.ServerName == serverName) - return McpOAuthFlowStatus.Pending; - } - - // Check if there's a completed token - if (_tokens.ContainsKey(serverName)) - return McpOAuthFlowStatus.Completed; - - return McpOAuthFlowStatus.NotStarted; - } - - /// - /// Get flow status by PKCE state value. Delegates to . - /// - public McpOAuthFlowStatus GetFlowStatusByState(string state) - { - var pkceStatus = _pkceService.GetFlowStatus(state); - return pkceStatus switch - { - OAuthPkceFlowStatus.Completed => McpOAuthFlowStatus.Completed, - OAuthPkceFlowStatus.Pending => McpOAuthFlowStatus.Pending, - OAuthPkceFlowStatus.Failed => McpOAuthFlowStatus.Failed, - _ => McpOAuthFlowStatus.NotStarted, - }; - } - - // ── Flow Cleanup ─────────────────────────────────────────────────── - - private static readonly TimeSpan FlowContextTtl = TimeSpan.FromMinutes(10); - - private void PruneStaleFlowContexts() - { - var cutoff = _timeProvider.GetUtcNow() - FlowContextTtl; - foreach (var (state, ctx) in _stateToContext) - { - if (ctx.CreatedAt < cutoff) - { - if (_stateToContext.TryRemove(state, out _)) - _logger.LogDebug("Pruned abandoned OAuth flow context (state={State}, server={Server})", state, ctx.ServerName.Value); - } - } - } - - // ── Private Helpers ──────────────────────────────────────────────── - - private static string? ExtractQuotedParam(string headerValue, string paramName) - { - var key = paramName + "=\""; - var start = headerValue.IndexOf(key, StringComparison.OrdinalIgnoreCase); - if (start < 0) return null; - - start += key.Length; - var end = headerValue.IndexOf('"', start); - if (end < 0) return null; - - return headerValue[start..end]; - } - - // ── Persistence ──────────────────────────────────────────────────── - // Tokens go into secrets.json under "McpOAuthTokens". - // Metadata stays in its own file (non-secret, cache only). - - private const string TokensSectionKey = "McpOAuthTokens"; - - private void LoadTokensFromDisk() - { - if (!File.Exists(_paths.SecretsPath)) return; - - try - { - var json = File.ReadAllText(_paths.SecretsPath); - using var doc = JsonDocument.Parse(json); - if (!doc.RootElement.TryGetProperty(TokensSectionKey, out var section)) - return; - - var sectionJson = section.GetRawText(); - - // Pre-decrypt all ENC: leaves so STJ can parse non-SensitiveString - // types (DateTimeOffset?, string?) that were blanket-encrypted by - // SecretsFileWriter. SensitiveStringJsonConverter already checks for - // ENC: prefix and skips decryption on plaintext — no double-decrypt. - if (_protector is not null) - sectionJson = SecretsFileWriter.DecryptJsonLeaves(sectionJson, _protector); - - var tokens = JsonSerializer.Deserialize>( - sectionJson, JsonOptions); - if (tokens is not null) - { - foreach (var (key, value) in tokens) - _tokens[new McpServerName(key)] = value; - - _logger.LogDebug("Loaded OAuth tokens for {Count} server(s) from {Path}", tokens.Count, _paths.SecretsPath); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load OAuth tokens from {Path}", _paths.SecretsPath); - } - } - - private void LoadMetadataFromDisk() - { - if (!File.Exists(_paths.McpOAuthMetadataPath)) return; - - try - { - var json = File.ReadAllText(_paths.McpOAuthMetadataPath); - var metadata = JsonSerializer.Deserialize>(json, JsonOptions); - if (metadata is not null) - { - foreach (var (key, value) in metadata) - _metadata[new McpServerName(key)] = value; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load OAuth metadata from {Path}", _paths.McpOAuthMetadataPath); - } - } - - internal void PersistTokens() - { - try - { - // Read existing secrets.json, merge in our tokens section, write back - Dictionary secrets; - if (File.Exists(_paths.SecretsPath)) - { - var existing = File.ReadAllText(_paths.SecretsPath); - secrets = JsonSerializer.Deserialize>(existing, JsonOptions) - ?? []; - } - else - { - secrets = []; - } - - secrets[TokensSectionKey] = JsonSerializer.SerializeToElement( - _tokens.ToDictionary(kvp => kvp.Key.Value, kvp => kvp.Value), JsonOptions); - - SecretsFileWriter.Write(_paths.SecretsPath, secrets, - options: JsonOptions, protector: _protector); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to persist OAuth tokens to {Path}", _paths.SecretsPath); - } - } - - private void PersistMetadata() - { - try - { - var json = JsonSerializer.Serialize( - _metadata.ToDictionary(kvp => kvp.Key.Value, kvp => kvp.Value), JsonOptions); - File.WriteAllText(_paths.McpOAuthMetadataPath, json); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to persist OAuth metadata to {Path}", _paths.McpOAuthMetadataPath); - } - } -} - -internal enum McpOAuthFlowStatus -{ - NotStarted, - Pending, - Completed, - Failed, -} - -/// -/// Tracks per-flow context needed to convert -/// into a persistable after callback. -/// -internal sealed record McpOAuthFlowContext( - McpServerName ServerName, - string ClientId, - string? ResourceIndicator, - DateTimeOffset CreatedAt); diff --git a/src/Netclaw.Daemon/Mcp/McpTokenCacheAdapter.cs b/src/Netclaw.Daemon/Mcp/McpTokenCacheAdapter.cs deleted file mode 100644 index 2949ff9e7..000000000 --- a/src/Netclaw.Daemon/Mcp/McpTokenCacheAdapter.cs +++ /dev/null @@ -1,89 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Collections.Concurrent; -using ModelContextProtocol.Authentication; -using Netclaw.Configuration; -using Netclaw.Tools; - -namespace Netclaw.Daemon.Mcp; - -/// -/// Bridges the MCP SDK's to Netclaw's existing -/// persistence. One instance per MCP server. -/// -internal sealed class McpTokenCacheAdapter : ITokenCache -{ - private readonly McpServerName _serverName; - private readonly ConcurrentDictionary _tokens; - private readonly Action _persistTokens; - private readonly TimeProvider _timeProvider; - - public McpTokenCacheAdapter( - McpServerName serverName, - ConcurrentDictionary tokens, - Action persistTokens, - TimeProvider timeProvider) - { - _serverName = serverName; - _tokens = tokens; - _persistTokens = persistTokens; - _timeProvider = timeProvider; - } - - public ValueTask StoreTokensAsync(TokenContainer tokens, CancellationToken cancellationToken) - { - var now = _timeProvider.GetUtcNow(); - var expiresAt = tokens.ExpiresIn is { } expiresIn - ? now.AddSeconds(expiresIn) - : (DateTimeOffset?)null; - - var tokenSet = new McpOAuthTokenSet - { - AccessToken = new SensitiveString(tokens.AccessToken), - RefreshToken = tokens.RefreshToken is not null - ? new SensitiveString(tokens.RefreshToken) - : null, - ExpiresAt = expiresAt, - }; - - // Preserve existing metadata and refresh token when the auth server omits it. - if (_tokens.TryGetValue(_serverName, out var existing)) - { - tokenSet.ClientId = existing.ClientId; - tokenSet.McpServerUrl = existing.McpServerUrl; - tokenSet.RefreshToken ??= existing.RefreshToken; - } - - _tokens[_serverName] = tokenSet; - _persistTokens(); - - return default; - } - - public ValueTask GetTokensAsync(CancellationToken cancellationToken) - { - if (!_tokens.TryGetValue(_serverName, out var tokenSet)) - return new ValueTask((TokenContainer?)null); - - var now = _timeProvider.GetUtcNow(); - var expiresIn = tokenSet.ExpiresAt is { } expiresAt - ? (int)Math.Max(0, (expiresAt - now).TotalSeconds) - : (int?)null; - - var container = new TokenContainer - { - TokenType = "Bearer", - AccessToken = tokenSet.AccessToken.Value, - RefreshToken = tokenSet.RefreshToken?.Value, - ExpiresIn = expiresIn, - ObtainedAt = tokenSet.ExpiresAt is { } ea && expiresIn is { } ei - ? ea.AddSeconds(-ei) - : now, - }; - - return new ValueTask(container); - } -} diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index 35ccbf7bf..297e8b178 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -787,15 +787,15 @@ static void ConfigureDaemonServices( return new OAuthPkceService(httpClient); }); services.AddSingleton(); - services.AddHttpClient(nameof(McpOAuthService)).AddNetclawHeaders("mcp-oauth"); - services.AddSingleton(sp => new McpOAuthService( - sp.GetRequiredService().CreateClient(nameof(McpOAuthService)), + services.AddSingleton(sp => new McpOAuthCredentialStore( paths, sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetService())); + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddSingleton(sp => new McpOAuthFlowBroker( + sp.GetRequiredService(), + sp.GetRequiredService().ApplicationStopping)); + services.AddSingleton(); services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/Netclaw.Daemon/Properties/AssemblyInfo.cs b/src/Netclaw.Daemon/Properties/AssemblyInfo.cs index 30c4ae9db..2d387cf49 100644 --- a/src/Netclaw.Daemon/Properties/AssemblyInfo.cs +++ b/src/Netclaw.Daemon/Properties/AssemblyInfo.cs @@ -8,3 +8,4 @@ [assembly: InternalsVisibleTo("Netclaw.Actors.Tests")] [assembly: InternalsVisibleTo("Netclaw.Daemon.IntegrationTests")] [assembly: InternalsVisibleTo("Netclaw.Daemon.Tests")] +[assembly: InternalsVisibleTo("Netclaw.Cli.Tests")] diff --git a/src/Netclaw.Daemon/Security/BootstrapDeviceSeeder.cs b/src/Netclaw.Daemon/Security/BootstrapDeviceSeeder.cs index ba715ce8c..7a1d2f5a1 100644 --- a/src/Netclaw.Daemon/Security/BootstrapDeviceSeeder.cs +++ b/src/Netclaw.Daemon/Security/BootstrapDeviceSeeder.cs @@ -5,7 +5,6 @@ // ----------------------------------------------------------------------- using System.Buffers.Text; using System.Security.Cryptography; -using System.Text.Json; using Microsoft.Extensions.Logging; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; @@ -53,8 +52,7 @@ public async Task EnsureSeededAsync(DaemonConfig config, CancellationToken if (devices.Count > 0) return false; - var existingSecrets = LoadSecrets(); - if (HasDeviceToken(existingSecrets)) + if (HasDeviceToken()) return false; var tokenBytes = RandomNumberGenerator.GetBytes(32); @@ -75,8 +73,36 @@ public async Task EnsureSeededAsync(DaemonConfig config, CancellationToken }; await _deviceRegistry.AddAsync(device, cancellationToken); - existingSecrets["DeviceToken"] = rawToken; - SecretsFileWriter.Write(_paths.SecretsPath, existingSecrets, protector: _protector); + var committed = false; + try + { + committed = SecretsFileWriter.Update( + _paths.SecretsPath, + (root, _) => + { + if (root.TryGetPropertyValue("DeviceToken", out var existing) + && existing?.ToString() is { Length: > 0 }) + return (null, false); + + root["configVersion"] ??= 1; + root["DeviceToken"] = rawToken; + return (root, true); + }, + protector: _protector, + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + await RollBackDeviceAsync(device.Name, ex); + throw; + } + + if (!committed) + { + await _deviceRegistry.RemoveAsync(device.Name, CancellationToken.None); + return false; + } + _logger.LogInformation( "Seeded bootstrap paired device '{DeviceName}' for first non-local daemon start.", device.Name); @@ -86,29 +112,35 @@ public async Task EnsureSeededAsync(DaemonConfig config, CancellationToken public void MarkCompleted() => _bootstrapStateStore.MarkCompleted(_timeProvider); - private Dictionary LoadSecrets() + private async Task RollBackDeviceAsync(string deviceName, Exception persistenceException) { - if (!File.Exists(_paths.SecretsPath)) - return new Dictionary { ["configVersion"] = 1 }; - try { - var text = File.ReadAllText(_paths.SecretsPath); - var decrypted = _protector is not null - ? SecretsFileWriter.DecryptJsonLeaves(text, _protector) - : text; - return JsonSerializer.Deserialize>(decrypted) - ?? new Dictionary { ["configVersion"] = 1 }; + await _deviceRegistry.RemoveAsync(deviceName, CancellationToken.None); } - catch (JsonException) + catch (Exception rollbackException) { - return new Dictionary { ["configVersion"] = 1 }; + throw new InvalidOperationException( + "Failed to persist bootstrap device token and failed to roll back the paired device.", + new AggregateException(persistenceException, rollbackException)); } } - private static bool HasDeviceToken(Dictionary secrets) + private bool HasDeviceToken() { - return secrets.TryGetValue("DeviceToken", out var existing) - && existing?.ToString() is { Length: > 0 }; + if (!File.Exists(_paths.SecretsPath)) + return false; + + var tokenFound = SecretsFileWriter.Update( + _paths.SecretsPath, + (root, _) => + { + var found = root.TryGetPropertyValue("DeviceToken", out var existing) + && existing?.ToString() is { Length: > 0 }; + return (null, found); + }, + protector: _protector); + + return tokenFound; } } diff --git a/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs b/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs index 11f1a3468..ee1bea0db 100644 --- a/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs +++ b/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs @@ -29,38 +29,34 @@ public static void PersistTokens( { paths.EnsureDirectoriesExist(); - // Load existing secrets as a JSON object tree to preserve other entries - var existingJson = File.Exists(paths.SecretsPath) - ? File.ReadAllText(paths.SecretsPath) - : "{}"; - - var root = JsonNode.Parse(existingJson)?.AsObject() ?? []; - var providers = root["Providers"]?.AsObject() ?? []; - root["Providers"] = providers; - - var providerNode = providers[providerName]?.AsObject() ?? []; - providers[providerName] = providerNode; - - providerNode["OAuthAccessToken"] = result.AccessToken.Value; - - // Preserve any previously-stored refresh token / account id when the new - // result omits them. An OAuth response that doesn't echo refresh_token means - // "keep using the existing one" (RFC 6749 §5.1), and a partial refresh that - // lacks the ChatGPT account id must not wipe a value the Codex backend still - // requires. (OAuthTokenExpiry below is still cleared on null — a stale expiry - // is worse than an absent one.) - if (result.RefreshToken is not null) - providerNode["OAuthRefreshToken"] = result.RefreshToken.Value; - - if (result.AccountId is not null) - providerNode["OAuthAccountId"] = result.AccountId.Value; - - // OAuthTokenExpiry is NOT a secret and must NOT go in secrets.json. - // SecretsFileWriter encrypts the entire file, and encrypted DateTimeOffset - // values break IConfiguration binding (silently drops the provider entry). - // Write expiry to netclaw.json instead. - var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); - SecretsFileWriter.Write(paths.SecretsPath, json, protector); + SecretsFileWriter.Update( + paths.SecretsPath, + (root, _) => + { + var providers = root["Providers"]?.AsObject() ?? []; + root["Providers"] = providers; + + var providerNode = providers[providerName]?.AsObject() ?? []; + providers[providerName] = providerNode; + + providerNode["OAuthAccessToken"] = result.AccessToken.Value; + + // Preserve any previously-stored refresh token / account id when the new + // result omits them. An OAuth response that doesn't echo refresh_token means + // "keep using the existing one" (RFC 6749 §5.1), and a partial refresh that + // lacks the ChatGPT account id must not wipe a value the Codex backend still + // requires. (OAuthTokenExpiry below is still cleared on null — a stale expiry + // is worse than an absent one.) + if (result.RefreshToken is not null) + providerNode["OAuthRefreshToken"] = result.RefreshToken.Value; + + if (result.AccountId is not null) + providerNode["OAuthAccountId"] = result.AccountId.Value; + + return (root, true); + }, + new JsonSerializerOptions { WriteIndented = true }, + protector); PersistTokenExpiry(paths, providerName, result.ExpiresAt); } diff --git a/tests/Netclaw.SecretsLockProbe/Netclaw.SecretsLockProbe.csproj b/tests/Netclaw.SecretsLockProbe/Netclaw.SecretsLockProbe.csproj new file mode 100644 index 000000000..ffa811758 --- /dev/null +++ b/tests/Netclaw.SecretsLockProbe/Netclaw.SecretsLockProbe.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + diff --git a/tests/Netclaw.SecretsLockProbe/Program.cs b/tests/Netclaw.SecretsLockProbe/Program.cs new file mode 100644 index 000000000..e0f0eb410 --- /dev/null +++ b/tests/Netclaw.SecretsLockProbe/Program.cs @@ -0,0 +1,38 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; + +if (args.Length != 2) +{ + Console.Error.WriteLine("Usage: Netclaw.SecretsLockProbe "); + return 2; +} + +try +{ + var committed = SecretsFileWriter.Update( + args[0], + (root, _) => + { + Console.Out.WriteLine("entered"); + Console.Out.Flush(); + + var command = Console.In.ReadLine(); + if (!string.Equals(command, "release", StringComparison.Ordinal)) + throw new InvalidOperationException($"Unexpected command '{command}'."); + + root["Child"] = args[1]; + return (root, true); + }); + + Console.Out.WriteLine(committed ? "committed" : "not-committed"); + return committed ? 0 : 3; +} +catch (Exception ex) +{ + Console.Error.WriteLine(ex); + return 1; +} From 97e7bdd92917c7cbb1086c72857f6d9d84c2f5ba Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 27 Jul 2026 18:42:51 +0000 Subject: [PATCH 03/13] Take back MCP OAuth client registration and fix the upgrade path Swapping Netclaw's OAuth implementation for the MCP C# SDK's invalidated two credentials that the previous release was using successfully. Both notion and textforge were connected minutes before the swap and reported "requires OAuth authorization" minutes after. Notion recovered by reauthorizing; textforge could not, because re-registration is impossible against it. Registration returns to Netclaw. ClientOAuthProvider hard-codes token_endpoint_auth_method: "client_secret_post" in its RFC 7591 request and only reads the server's advertised token_endpoint_auth_methods_supported afterwards, for the token request. A server accepting public clients only -- textforge advertises ["none"] -- rejects that with 400 invalid_client_metadata, and 1.4.1 exposes no hook to change it: DynamicClientRegistrationOptions has four properties and its ResponseDelegate fires only on success. This is csharp-sdk#1611, open since May and unfixed in 1.4.1, every 2.0 prerelease, and main; PR #1615 covers only the token request. Netclaw's own registration sent "none" and worked, as does the TypeScript SDK, which negotiates from the advertised list. McpOAuthClientRegistrar registers with the same method the SDK will later select, so the two cannot diverge, and seeds ClientOAuthOptions.ClientId so the SDK's registration path never runs. A missing or failing registration_endpoint names OAuthClientId as the remedy, restoring the message removed with McpOAuthService. Legacy credentials now migrate instead of failing closed: - ObtainedAt is stamped on migration. Absent from pre-existing records, it deserialized to 0001-01-01, and ExpiresAt - ObtainedAt saturated the int conversion to int.MaxValue, so the SDK treated every migrated credential as permanently expired. No test caught this because no fixture set ExpiresAt. - McpServerUrl is retained rather than nulled, so migration can be repeated if an endpoint is corrected later and a rollback keeps its binding. - Resource equivalence tolerates a trailing slash, path case, and a bare-origin indicator. Scheme, host, port and query must agree; origin-to-path narrowing is accepted, a path-scoped credential is never widened to a sibling path. Rejections log both bindings instead of returning null silently. - LoadDurableState no longer throws from a singleton constructor, where one unreadable secrets leaf took down daemon startup. RejectedDynamicClientId is replaced by discarding the rejected identity. A persisted marker is a latch: it survives restart and, against a server that cannot complete registration, makes every later attempt fail identically. Discarding keeps the tokens, needs no extra field, and self-heals. Deferred to their own changes, with the deletions recorded in the proposal: - The invocation lease and drain layer. A replaced or shutting-down client is disposed once its replacement publishes, so an in-flight call is cancelled rather than drained. The pre-existing behavior was the same, so this is a non-improvement rather than a regression. - Migrating the remaining secrets.json callers and transactional rollback for ProviderRenamer and BootstrapDeviceSeeder. SecretsFileWriter.Update stays, with an in-process lock whose key resolves a symlinked config directory. The two TUI save paths keep their fix because they were overwriting daemon-written McpOAuthTokens. MCP production code is 2488 lines against 1370 before the branch. The increase is the diagnostics taxonomy for #1475, durable client identity, resource binding with migration, and candidate-then-publish. Net-negative production LOC was predicted in the proposal and not achieved; the proposal now records the measured figure. Verified live: a credential stored 2026-05-13 reconnected with 23 tools after a binary swap, with no reauthorization and with the temporary OAuthClientId override removed. 6068 tests pass, slopwatch and header checks are clean. Refs netclaw-dev/netclaw#1475, #1696, #297 --- Netclaw.slnx | 1 - .../.system/files/netclaw-operations/SKILL.md | 51 +- .../simplify-mcp-oauth-lifecycle/design.md | 112 ++- .../simplify-mcp-oauth-lifecycle/proposal.md | 108 ++- .../specs/mcp-oauth/spec.md | 96 +- .../simplify-mcp-oauth-lifecycle/tasks.md | 8 +- src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs | 28 - .../Mcp/McpOAuthEndToEndTests.cs | 3 + .../Provider/ProviderRenamerTests.cs | 484 ---------- .../Config/ProviderCredentialWriter.cs | 16 +- src/Netclaw.Cli/Daemon/PairCommand.cs | 8 +- src/Netclaw.Cli/Provider/ProviderCommand.cs | 13 +- src/Netclaw.Cli/Provider/ProviderRenamer.cs | 95 +- src/Netclaw.Cli/Secrets/SecretsCommand.cs | 26 +- .../Tui/ProviderManagerViewModel.cs | 24 +- .../Wizard/Steps/ExposureModeStepViewModel.cs | 12 +- .../Netclaw.Configuration.Tests.csproj | 2 - .../SecretsFileWriterTests.cs | 93 -- src/Netclaw.Configuration/McpOAuthTokenSet.cs | 30 - .../SecretsFileWriter.cs | 131 +-- .../DaemonRuntimeStatusServiceTests.cs | 2 + .../Mcp/McpClientManagerLifecycleTests.cs | 249 +---- .../Mcp/McpClientManagerStatusTests.cs | 20 +- .../McpEndpointRouteBuilderExtensionsTests.cs | 78 +- .../Mcp/McpOAuthCredentialStoreTests.cs | 745 ++++++--------- .../Mcp/McpOAuthTestDoubles.cs | 32 + .../Mcp/McpSdkOAuthFlowIntegrationTests.cs | 509 ++-------- .../Mcp/McpSmokeHarness.cs | 1 + .../Security/BootstrapDeviceSeederTests.cs | 174 ---- src/Netclaw.Daemon/Mcp/McpClientManager.cs | 621 +++--------- .../Mcp/McpOAuthClientRegistrar.cs | 242 +++++ .../Mcp/McpOAuthCredentialStore.cs | 889 ++++++++---------- src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs | 48 +- src/Netclaw.Daemon/Program.cs | 4 + .../Security/BootstrapDeviceSeeder.cs | 72 +- .../OAuth/OAuthTokenPersistence.cs | 60 +- .../Netclaw.SecretsLockProbe.csproj | 15 - tests/Netclaw.SecretsLockProbe/Program.cs | 38 - 38 files changed, 1574 insertions(+), 3566 deletions(-) delete mode 100644 src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs create mode 100644 src/Netclaw.Daemon.Tests/Mcp/McpOAuthTestDoubles.cs delete mode 100644 src/Netclaw.Daemon.Tests/Security/BootstrapDeviceSeederTests.cs create mode 100644 src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs delete mode 100644 tests/Netclaw.SecretsLockProbe/Netclaw.SecretsLockProbe.csproj delete mode 100644 tests/Netclaw.SecretsLockProbe/Program.cs diff --git a/Netclaw.slnx b/Netclaw.slnx index 6ff86e027..a7cf41e75 100644 --- a/Netclaw.slnx +++ b/Netclaw.slnx @@ -39,7 +39,6 @@ - diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 998c29ad5..31bbef5cf 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.36.0" + version: "2.38.0" --- # Netclaw Operations @@ -116,12 +116,20 @@ or MCP tools by capability before concluding a tool doesn't exist. Full guidance ## MCP OAuth -For HTTP/SSE MCP servers, the Model Context Protocol .NET SDK owns OAuth -discovery, dynamic client registration (DCR), PKCE, authorization-code exchange, -token refresh, and the related HTTP calls. Netclaw presents the SDK's -authorization URL, brokers the browser callback, and durably stores the SDK token -cache. Do not fetch metadata or token endpoints by hand, build PKCE requests, or -create or repair `mcp-oauth-metadata.json`; legacy metadata files are ignored. +For HTTP/SSE MCP servers, the Model Context Protocol .NET SDK owns PKCE, +authorization-code exchange, token refresh, and the related HTTP calls. Netclaw +owns protected-resource discovery and dynamic client registration (DCR), +presents the authorization URL, brokers the browser callback, and durably stores +active credentials. Do not fetch metadata or token endpoints by hand, build PKCE +requests, or create or repair `mcp-oauth-metadata.json`; legacy metadata files +are ignored. + +Netclaw registers rather than letting the SDK do it because the SDK hard-codes +`token_endpoint_auth_method: "client_secret_post"` and ignores what the +authorization server advertises, which fails against servers that accept public +clients only. Netclaw registers with the method the server advertises first. +Registration happens only during `netclaw mcp auth `, never on a +background reconnect. ### Authorize a server @@ -134,9 +142,10 @@ netclaw mcp auth The command starts an unpublished client candidate, opens the authorization URL when possible, always prints it, and waits up to five minutes. Complete the browser flow normally. If the callback cannot reach this machine, paste the full -redirect URL into the command. Netclaw publishes the candidate only after the SDK -exchanges the code, credentials persist, the client connects, and tool discovery -succeeds. A failed replacement does not displace an existing healthy connection. +redirect URL into the command. Netclaw keeps exchanged credentials local to the +candidate, then commits them once and publishes the client only after tool +discovery succeeds. A failed replacement does not alter durable credentials or +displace an existing healthy connection. The SDK redirect URI is `http://127.0.0.1:{Daemon.Port}/api/mcp/oauth/callback`. If the provider requires @@ -152,9 +161,23 @@ OAuth credentials are bound to the server's canonical configured resource identity. If the same profile name is pointed at another resource, Netclaw withholds its old tokens and dynamically registered client credentials, reports `AwaitingAuth`, and preserves the old durable record until replacement succeeds. -A legacy token record without a resource binding is also preserved but never -silently bound; reauthorize it. An explicitly configured static OAuth client ID -remains authoritative. + +A token record written before resource binding existed is migrated in place when +its legacy resource describes the configured endpoint, so upgrading does not +force reauthorization. A trailing slash, path case, and a bare-origin resource +indicator all still match; a different scheme, host, port, query, or sibling path +does not, and those report `AwaitingAuth` with both bindings written to the +daemon log. An explicitly configured static OAuth client ID remains +authoritative. + +If a server rejects the stored client identity as `invalid_client` — usually +because the registration was deleted on their side — Netclaw discards that +identity, keeps the tokens, and registers a new client on the next +`netclaw mcp auth `. No manual cleanup is needed. + +If a server's authorization server publishes no `registration_endpoint`, or +rejects registration, the error names the remedy: register a client manually +with that provider and set it with `netclaw mcp add --client-id ...`. ### Read connection states @@ -186,7 +209,7 @@ logs for full server context; operator-facing errors omit authorization codes, tokens, PKCE data, and client secrets. Credential persistence fails loudly. If the durable secrets write fails, -authorization fails, the shared cache does not advance, and the candidate is not +authorization fails, active credentials do not change, and the candidate is not published. Fix the filesystem or secrets-store error shown in daemon logs, then run `netclaw mcp auth ` again; browser success alone does not mean the MCP connection is ready. diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/design.md b/openspec/changes/simplify-mcp-oauth-lifecycle/design.md index d55d24b09..12369cd88 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/design.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/design.md @@ -87,13 +87,27 @@ cannot delegate: client lifecycle and durable local state. ### D1. The SDK owns the OAuth protocol; Netclaw stops re-implementing it -**Decision:** OAuth discovery, authorization-server selection, DCR, PKCE, -authorization-code exchange, bearer injection, and refresh delegate entirely to -the SDK via `ClientOAuthOptions`, `ITokenCache`, -`AuthorizationRedirectDelegate`, and `DynamicClientRegistrationOptions.ResponseDelegate`. -`McpOAuthService`'s protocol code — metadata probing/parsing, DCR HTTP, -authorization-URL construction, code exchange, refresh redemption, -`GetValidTokenAsync`, and the metadata cache — is deleted. +**Decision:** Authorization-server selection, PKCE, authorization-code exchange, +bearer injection, and refresh delegate entirely to the SDK via +`ClientOAuthOptions`, `ITokenCache`, and `AuthorizationRedirectDelegate`. +`McpOAuthService`'s protocol code — authorization-URL construction, code +exchange, refresh redemption, `GetValidTokenAsync`, and the metadata cache — is +deleted. + +**Amended during implementation:** DCR does *not* delegate to the SDK. +`ClientOAuthProvider.PerformDynamicClientRegistrationAsync` hard-codes +`TokenEndpointAuthMethod = "client_secret_post"` and reads +`token_endpoint_auth_methods_supported` only afterwards, for the token request. +Against a server advertising `["none"]` the registration is rejected with +`400 invalid_client_metadata`, and 1.4.1 exposes no hook to change the body — +`DynamicClientRegistrationOptions` has four properties and its `ResponseDelegate` +fires only on success. This is csharp-sdk#1611, open since 2026-05-29 and unfixed +in 1.4.1, every 2.0 prerelease, and `main`; PR #1615 addresses only the token +request. `dev`'s own 40-line registration sent `"none"` and worked, as does the +TypeScript SDK, which negotiates from the advertised list. So +`McpOAuthClientRegistrar` retains protected-resource discovery and registration, +registering with the same method the SDK will later select, and seeds +`ClientOAuthOptions.ClientId` so the SDK's registration path never runs. **Rationale:** One protocol implementation cannot drift from itself. 1.4.1 already exposes every hook the interactive and non-interactive flows need, and @@ -173,11 +187,19 @@ an MCP-level operation through the SDK, not redeem a refresh token directly. ### D4. Transactional mutation extends `SecretsFileWriter`; no OAuth-specific store **Decision:** Add a path-scoped transactional update to `SecretsFileWriter` that -derives a lock identity from the canonical path, then holds one cross-process -lock across read, leaf decryption, JSON parse, the caller's mutation, -serialization, encryption, and `AtomicFile` replacement with permission hardening. Reuse the named-mutex / -SHA-256 path-hash pattern already proven in `WebhookRouteStore`. Migrate every -current `secrets.json` read/modify/write caller onto this boundary. Callers pass +derives a lock identity from the canonical path, then holds one lock across +read, leaf decryption, JSON parse, the caller's mutation, serialization, +encryption, and `AtomicFile` replacement with permission hardening. + +**Amended during implementation:** the lock is in-process rather than a named +cross-process mutex, and its key resolves only the immediate parent directory's +symlink rather than every path segment. The daemon is the sole runtime writer +and the CLI writes while it is stopped, so cross-process contention is a +separate, pre-existing concern. Caller migration is limited to the credential +store and the two TUI save paths that were overwriting daemon-written +`McpOAuthTokens`; the remaining CLI and provider callers, and transactional +rollback for `ProviderRenamer` and `BootstrapDeviceSeeder`, move to their own +change. Callers pass owned field mutations that execute against the latest document read under the lock; they do not pass a whole-file snapshot captured before the lock. Long-lived editors retain their contribution actions and replay those actions inside the @@ -224,18 +246,26 @@ This check happens before the SDK can inject an old bearer token into a request to the changed endpoint. Netclaw does not silently stamp a binding onto a legacy record because the profile may have changed before upgrade. -An explicit authorization candidate uses a flow-scoped token-cache view. SDK -stores write a durable pending record rather than replacing the active record. -After tool listing succeeds, the lifecycle gate atomically promotes that pending -record and publishes the candidate; candidate failure or expiry removes only the -pending record. A crash may leave a pending record, but startup never treats it -as active and removes it after its flow lifetime. Ordinary refresh on a published -connection continues to update the active record persist-before-publish. +An explicit authorization candidate uses a flow-scoped token cache that starts +without an access token. SDK stores remain local to that unpublished candidate. +After tool listing succeeds, the lifecycle gate writes the complete replacement +to the sole durable active record, transfers cache ownership, and publishes the +candidate. Candidate failure or expiry discards the local cache without a durable +cleanup path. A crash after the durable write but before runtime publication +leaves a complete active record for the next startup. Ordinary refresh on a +published connection continues to update the active record persist-before-return. If an authorization server rejects a stored dynamically registered identity as -`invalid_client`, the current flow fails visibly and records that dynamic -identity as rejected. The next explicit authorization attempt withholds it, -allowing the SDK to perform DCR with a new URL and PKCE verifier. This recovery +`invalid_client`, the current flow fails visibly and the rejected client +identity is discarded from the durable record while its tokens are left intact. +The next explicit authorization registers a new client, because an absent client +ID is already the registrar's trigger. + +**Amended during implementation:** an earlier design persisted a +`RejectedDynamicClientId` marker instead of discarding the identity. That is a +latch: it survives restart, has to be cleared explicitly, and against a server +that cannot complete registration it makes every later attempt fail the same +way. Discarding needs no extra persisted field and self-heals. This recovery never discards an explicitly configured static client ID and never replaces the active record until the new candidate publishes. @@ -267,12 +297,12 @@ acceptable — no second file exists solely to preserve an incomplete flow. ### D6. Explicit reauthorization withholds the access token instead of deleting credentials -**Decision:** `netclaw mcp auth ` runs against a cache view that does not -return the existing access token, which forces the SDK down the authorization -path — while the durable token set and refresh token stay intact until the new -tokens are stored. The interactive candidate is unpublished until success; -failure or cancellation disposes only the candidate and leaves the previous -connection and credentials untouched. +**Decision:** `netclaw mcp auth ` runs against a candidate-local cache that +does not return the existing access token, which forces the SDK down the +authorization path. The durable token set and refresh token stay intact until +the candidate initializes and commits once. Failure or cancellation disposes +only the unpublished candidate and leaves the previous connection and +credentials untouched. **Rationale:** An operator re-authorizing must not lose working credentials if the new flow fails or is abandoned. Deleting durable state first to "force" the @@ -364,13 +394,13 @@ Deriving the port from config fixes defect 7 for any non-default local port. **Decision:** Persistence and diagnostics follow a strict fail-loud ordering. -- `StoreTokensAsync` constructs the complete replacement record, persists it - transactionally, updates the applicable singleton in-memory view, then returns - success. Published-connection refresh updates the active record; explicit - authorization updates a flow-scoped pending record that is promoted only with - candidate publication. If persistence fails, neither view is advanced and the - failure propagates — the connection fails visibly rather than continuing on - an in-memory-only rotated token. +- Published-connection `StoreTokensAsync` constructs the complete replacement + record, persists it transactionally, updates the active in-memory view, then + returns success. Ordinary startup and reconnect candidates do the same because + refresh may already have rotated remote state. An explicit authorization candidate keeps SDK writes local + until initialization succeeds, then commits the complete record once before + publication. If that commit fails, active state is unchanged and the failure + propagates visibly. - A token response that omits `refresh_token` retains the prior refresh token. - Authenticated OAuth API endpoints return a structured `McpErrorResponse` for discovery, DCR rejection, credential persistence, and connection init. The @@ -393,10 +423,14 @@ Recovery paths: a failed candidate leaves the prior generation serving; a rejected refresh surfaces `AuthFailed` and directs the operator to `netclaw mcp auth `; a disk failure fails the store call and the connection, keeping the last active durable record authoritative; `StopAsync` -enters each server's gate, marks shutdown, rejects new leases and reconnects, -allows active invocations a bounded drain period, cancels any remainder with the -daemon shutdown token, then removes and disposes the snapshot so state -publication cannot race shutdown. +enters each server's gate, marks shutdown, rejects new reconnects, then removes +and disposes the snapshot so state publication cannot race shutdown. + +**Amended during implementation:** invocation leases and the bounded drain are +deferred to the separate client-lifecycle change. A replaced or shutting-down +client is disposed once its replacement is published, so an invocation still in +flight is cancelled rather than drained. `dev` behaved the same way, so this is a +non-improvement rather than a regression, but it is a deliberate gap. **Rationale:** Persist-before-publish is the only ordering that keeps disk and memory from disagreeing across a restart. The status taxonomy turns defect 8's diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md b/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md index 2f1200f92..e5969f7d8 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md @@ -21,40 +21,57 @@ parts: one owner per concern, not a more complete second OAuth implementation. MCP client creation, publication, replacement, and disposal. Immutable per-server connection snapshots with monotonic generations; concurrent reconnects coalesce by observed generation; candidates initialize fully - before atomically replacing the sole published generation; replaced - generations drain in-flight calls before disposal; teardown is serialized. - Strengthens PRD-006 `MCP-009` under concurrency. + before atomically replacing the sole published generation; teardown is + serialized. A replaced client is disposed once its replacement is published, + so an invocation still in flight is cancelled rather than drained — draining + is deferred to the separate client-lifecycle change. Strengthens PRD-006 + `MCP-009` under concurrency. - **Failure classification**: reconnect narrows to classified transport/session failures and restores service for later calls without replaying an ambiguously failed invocation. Caller cancellation and tool-declared/application errors propagate without teardown or reconnect. Revises the reconnection behavior of PRD-006 `MCP-008` (graceful degradation). -- **OAuth ownership**: OAuth discovery, DCR, PKCE, authorization-code - exchange, refresh, and bearer injection delegate to the MCP C# SDK - (ModelContextProtocol.Core 1.4.1 `ClientOAuthOptions`, `ITokenCache`, - `AuthorizationRedirectDelegate`). Netclaw's manual protocol implementation - in `McpOAuthService` and the `mcp-oauth-metadata.json` runtime dependency - are **removed**. Existing metadata files are ignored, not deleted. +- **OAuth ownership**: PKCE, authorization-code exchange, refresh, and bearer + injection delegate to the MCP C# SDK (ModelContextProtocol.Core 1.4.1 + `ClientOAuthOptions`, `ITokenCache`, `AuthorizationRedirectDelegate`). + Netclaw's manual protocol implementation in `McpOAuthService` and the + `mcp-oauth-metadata.json` runtime dependency are **removed**. Existing + metadata files are ignored, not deleted. +- **Client registration stays Netclaw's**: `McpOAuthClientRegistrar` performs + protected-resource discovery and RFC 7591 registration, because the SDK + hard-codes `token_endpoint_auth_method: "client_secret_post"` and never reads + the authorization server's advertised `token_endpoint_auth_methods_supported` + (csharp-sdk#1611, unfixed in 1.4.1, every 2.0 prerelease, and `main`; PR #1615 + covers only the token request). Servers accepting public clients only — such + as TextForge, which advertises `["none"]` — reject the SDK's registration with + `400 invalid_client_metadata`, making SDK-driven registration impossible + against them. Netclaw registers with exactly the method the SDK will later + select for the token request, so the two cannot diverge, and seeds + `ClientOAuthOptions.ClientId` so the SDK's registration path never runs. A + missing or failing `registration_endpoint` yields an actionable error naming + `OAuthClientId`. - **New narrow components**: `McpOAuthFlowBroker` (interactive browser handoff only: opaque one-time state, bounded flow lifetime via `TimeProvider`) and `McpOAuthCredentialStore` (durable per-server token sets plus the DCR-issued client ID; supplies SDK `ITokenCache` adapters). One SDK redirect delegate owns each flow's PKCE/code exchange; concurrent delegates observe authorization in progress but never reuse its code. -- **Durable credentials**: in-memory token state publishes only after durable - persistence succeeds; persistence failure propagates through - `ITokenCache.StoreTokensAsync` and fails the connection visibly. A token +- **Durable credentials**: active token state changes only after durable + persistence succeeds; persistence failure fails the connection visibly. A token response that omits `refresh_token` retains the prior refresh token. Stored credentials are bound to the configured MCP resource identity and withheld - after that identity changes. Explicit authorization writes a durable pending - record and promotes it only when the candidate connection publishes, so later - initialization failure cannot replace the last working record. -- **Transactional secrets**: `SecretsFileWriter` gains a path-scoped, - cross-process-locked read/decrypt/mutate/encrypt/replace transaction - (reusing the established `WebhookRouteStore` named-mutex pattern). All - secrets.json read-modify-write callers migrate; concurrent updates to - different sections cannot lose either update. + after that identity changes. Explicit authorization keeps credentials local to + the unpublished candidate and commits them once after initialization succeeds, + so failure cannot replace the last working record. +- **Transactional secrets**: `SecretsFileWriter` gains a path-scoped + read/decrypt/mutate/encrypt/replace transaction so concurrent updates to + different sections cannot lose either update. The lock is in-process and its + key resolves a symlinked config directory, so two spellings of one file share + it. The credential store and the TUI config/wizard save paths adopt it — the + TUI paths because they were overwriting daemon-written `McpOAuthTokens`. + Migrating the remaining CLI and provider callers, and any cross-process + locking, is deferred to a separate change. - **Callback URI**: derived from `DaemonConfig.Port` (`http://127.0.0.1:{port}/api/mcp/oauth/callback`) instead of hard-coded 5199. Never derived from a request Host header. @@ -75,20 +92,21 @@ existing behavior. ### New Capabilities -- `mcp-oauth`: SDK-delegated OAuth authorization for HTTP MCP servers — - ownership boundaries, interactive browser-flow brokering, durable - credential and client-registration persistence, callback identity, and - actionable OAuth failure diagnostics. +- `mcp-oauth`: OAuth authorization for HTTP MCP servers — SDK-delegated PKCE, + exchange, and refresh with Netclaw-owned client registration, plus ownership + boundaries, interactive browser-flow brokering, durable credential and + client-registration persistence, callback identity, and actionable OAuth + failure diagnostics. - `transactional-secrets`: serialized, atomic mutation of secrets.json — - cross-process locking, preservation of unrelated secret sections under - concurrent writers, and loud persistence failure. + preservation of unrelated secret sections under concurrent writers and loud + persistence failure. ### Modified Capabilities - `netclaw-mcp`: - "Configured MCP server has daemon-bound client ownership" — strengthened to hold under concurrent reconnects via generation-aware coalescing, - atomic publication, and in-flight-call draining. + and atomic publication. - "Graceful degradation" — reconnection narrowed to classified transport/session failures without automatic invocation replay; cancellation and tool-declared errors excluded. @@ -102,8 +120,13 @@ existing behavior. — largely deleted, `McpTokenCacheAdapter`, `McpEndpointRouteBuilderExtensions`, `McpReconnectionService`), `src/Netclaw.Configuration/SecretsFileWriter.cs`, `src/Netclaw.Configuration/NetclawPaths.cs` (metadata path removal), - `src/Netclaw.Cli` MCP auth/status commands. Expected net-negative - production LOC. + `src/Netclaw.Cli` MCP auth/status commands. Measured outcome: MCP production + code moves from 1370 lines on `dev` (manager 618 + `McpOAuthService` 663 + + `McpTokenCacheAdapter` 89) to 2488 (manager 1256 + credential store 604 + + flow broker 386 + registrar 242). The increase is the diagnostics taxonomy + (#1475), durable client identity, resource binding with legacy migration, and + candidate-then-publish — none of which `dev` had. Net-negative production LOC + was predicted and not achieved. - **.NET source compatibility**: removes the public `McpOAuthServerMetadata` cache type and `NetclawPaths.McpOAuthMetadataPath` property. These represented a runtime cache that no longer exists; external @@ -138,25 +161,28 @@ existing behavior. daemon rather than an HTTP request. - Persisted OAuth credentials are bound to the configured resource identity; changing a profile endpoint requires authorization for the new identity. -- Legacy credential records without that binding fail closed with actionable - reauthorization guidance rather than being silently trusted for the current - profile. This is an operator-visible security migration, not an API shape - change. +- Legacy credential records are migrated onto the configured endpoint when they + describe the same resource, so upgrading never invalidates a credential the + previous release was successfully using. Equivalence tolerates a trailing + slash, path case, and a bare-origin resource indicator; scheme, host, port, + and query must agree, and origin-to-path narrowing is accepted while a + path-scoped credential is never widened to a sibling path. Records that fail + that test fail closed with actionable reauthorization guidance, and the + rejected binding is logged next to the configured one. - Operators see actionable status (`AwaitingAuth` → run `netclaw mcp auth `) instead of generic connection errors. ### In scope (MVP) -- Generation-aware client lifecycle, coalesced reconnects, and safe draining - of replaced generations. +- Generation-aware client lifecycle and coalesced reconnects. - SDK-delegated OAuth with browser-flow broker and credential store. - Transactional secrets mutation and caller migration. - Configurable local callback port; structured diagnostics. - Compatibility bridge: persisting the DCR client ID beside the token set and seeding `ClientOAuthOptions.ClientId` from it (stable SDK 1.4.1's `TokenContainer` carries no registration fields). -- Durable pending credentials for explicit authorization, promoted only with - successful candidate publication. +- Candidate-local credentials for explicit authorization, committed once to + the sole durable active record immediately before successful publication. ### Out of scope @@ -165,6 +191,14 @@ existing behavior. - Vendored or preview SDK; the official SDK upgrade is a separate follow-up. - Remote/public callback URL design (#297). - Serializing all MCP tool calls. +- Draining in-flight invocations before disposing a replaced or shutting-down + client. `dev` did not drain either, so this is a non-improvement rather than a + regression, but it is a deliberate gap: a tool call in flight when the daemon + stops is cancelled. Tracked with the separate client-lifecycle change. +- Migrating the remaining secrets.json callers (`SecretsCommand`, `PairCommand`, + `ProviderCommand`, `ProviderManagerViewModel`, `ExposureModeStepViewModel`, + `ProviderCredentialWriter`, `OAuthTokenPersistence`) and transactional + rollback for `ProviderRenamer` and `BootstrapDeviceSeeder`. - Attributing past provider incidents to these defects without traces. ## Source PRDs diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md index d3ebbfcdc..26a3b90ce 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md @@ -2,14 +2,45 @@ ## ADDED Requirements -### Requirement: OAuth protocol ownership belongs to the MCP SDK +### Requirement: OAuth protocol ownership belongs to the MCP SDK, except client registration + +The system SHALL delegate MCP OAuth protocol operations — authorization-server +selection, PKCE generation and validation, authorization-code exchange, +refresh-token redemption, and bearer-token injection — to the MCP C# SDK. +Netclaw SHALL NOT implement those protocol operations and SHALL NOT maintain a +separate OAuth metadata cache as a runtime dependency. + +Netclaw SHALL own protected-resource discovery and RFC 7591 dynamic client +registration, because the SDK hard-codes +`token_endpoint_auth_method: "client_secret_post"` in its registration request +and never consults the authorization server's advertised +`token_endpoint_auth_methods_supported`, which makes registration impossible +against a server that accepts public clients only. Netclaw SHALL register with +the same method the SDK selects for the token request — the first entry of +`token_endpoint_auth_methods_supported`, defaulting to `client_secret_post` when +the server advertises none — so the registered method and the method used at the +token endpoint cannot diverge. Netclaw SHALL seed `ClientOAuthOptions.ClientId` +from the durable record so the SDK's registration path never executes. + +Registration SHALL occur only during explicit authorization. A server that +advertises no protected-resource metadata SHALL be treated as requiring no +OAuth. A missing or failing `registration_endpoint` SHALL produce an +operator-facing error naming `OAuthClientId` as the remedy. + +#### Scenario: A public-client-only server is registered as a public client + +- **GIVEN** an authorization server advertising `token_endpoint_auth_methods_supported: ["none"]` +- **WHEN** the operator runs explicit authorization +- **THEN** the registration request carries `token_endpoint_auth_method: "none"` +- **AND** the SDK issues no registration request of its own +- **AND** the token request authenticates as a public client + +#### Scenario: A server without dynamic registration names the remedy -The system SHALL delegate MCP OAuth protocol operations — protected-resource -and authorization-server discovery, authorization-server selection, dynamic -client registration, PKCE generation and validation, authorization-code -exchange, refresh-token redemption, and bearer-token injection — to the MCP -C# SDK. Netclaw SHALL NOT implement these protocol operations and SHALL NOT -maintain a separate OAuth metadata cache as a runtime dependency. +- **GIVEN** an authorization server that publishes no `registration_endpoint`, or whose registration endpoint rejects the request +- **WHEN** the operator runs explicit authorization +- **THEN** the failure names `OAuthClientId` as the way to supply a manually registered client +- **AND** the provider's response body reaches the daemon log but not the operator-facing message #### Scenario: Startup with bound cached tokens requires no metadata cache @@ -25,7 +56,7 @@ maintain a separate OAuth metadata cache as a runtime dependency. - **GIVEN** a configured HTTP MCP server with OAuth in use - **WHEN** tokens are acquired or refreshed during any flow - **THEN** every token-endpoint request originates from the MCP SDK -- **AND** no Netclaw-owned code constructs discovery, registration, authorization, or token requests +- **AND** no Netclaw-owned code constructs authorization or token requests #### Scenario: Legacy metadata files are ignored, not deleted @@ -120,16 +151,19 @@ existing five-minute CLI and TUI polling timeout. ### Requirement: Durable credential persistence precedes publication -The system SHALL treat secrets.json as the durable authority for MCP OAuth -credentials. Token state SHALL be published to memory only after durable -persistence succeeds, and a persistence failure SHALL propagate through the -SDK token-cache boundary and fail the connection visibly. Credentials acquired -by an unpublished interactive candidate SHALL first persist as a pending record -bound to that flow and SHALL become the active record only when candidate tool -listing and publication succeed. Failed or expired candidates SHALL remove -their pending record without changing the active record. When a token -response omits a refresh token, the system SHALL retain the previous refresh -token. The effective client ID — and client secret, when issued — from +The system SHALL treat secrets.json as the durable authority for active MCP +OAuth credentials. Published-connection token updates SHALL persist before the +SDK store call returns. Token rotations acquired by an ordinary startup or +reconnect candidate SHALL also persist before the SDK store call returns because +the authorization server may already have consumed the prior refresh token. +Credentials acquired by an unpublished interactive +candidate SHALL remain local to that candidate until tool listing succeeds, +then SHALL replace the sole durable active record before the candidate is +published. A persistence failure SHALL fail the candidate visibly without +changing active credentials or the published connection. Failed or expired +candidates SHALL require no durable cleanup. When a token response omits a +refresh token, the system SHALL retain a previous refresh token only when the +resource and effective client identity match. The effective client ID — and client secret, when issued — from dynamic client registration SHALL be stored with the token record and supplied to the SDK's OAuth options on subsequent connections. The record SHALL also contain the normalized configured MCP resource identity used when @@ -151,9 +185,9 @@ and query retained. #### Scenario: Failed candidate does not replace active credentials - **GIVEN** a server has active durable credentials and a published connection -- **AND** an explicit authorization candidate durably stores replacement credentials as pending +- **AND** an explicit authorization candidate stores replacement credentials in its local cache - **WHEN** candidate initialization or tool listing fails before publication -- **THEN** the pending credentials are discarded +- **THEN** the candidate-local credentials are discarded - **AND** the prior active credential record remains authoritative and unchanged #### Scenario: Omitted refresh token is retained @@ -177,20 +211,30 @@ and query retained. - **AND** the server reports `AwaitingAuth` for the new identity - **AND** the old durable record remains intact until replacement credentials are stored successfully -#### Scenario: Legacy unbound credentials fail closed +#### Scenario: Legacy credentials for the same resource migrate on upgrade + +- **GIVEN** a stored credential record predates the canonical resource binding and carries a legacy resource that describes the configured endpoint +- **WHEN** the daemon loads the server after upgrade +- **THEN** the record is stamped with the canonical resource identity and remains usable without reauthorization +- **AND** the legacy resource field is retained so the migration can be repeated if the endpoint is corrected later +- **AND** an absent obtained-at timestamp is stamped, so the credential is not computed as expired + +#### Scenario: Legacy credentials for a different audience fail closed -- **GIVEN** a stored credential record has no canonical configured resource binding +- **GIVEN** a legacy record whose resource differs from the configured endpoint in scheme, host, port, query, or sibling path - **WHEN** the daemon loads the server after upgrade -- **THEN** no token, dynamically registered client ID, or client secret from that record is supplied to the SDK +- **THEN** no token, client ID, or client secret from that record is supplied to the SDK - **AND** the server reports `AwaitingAuth` with `netclaw mcp auth ` as the remedy -- **AND** Netclaw does not silently bind the legacy record to the current profile +- **AND** the rejected binding and the configured endpoint are both logged +- **AND** the record on disk is left unchanged #### Scenario: Revoked dynamic registration can be replaced explicitly - **GIVEN** credentials contain a dynamically registered client identity that the authorization server rejects as `invalid_client` - **WHEN** the operator runs explicit authorization -- **THEN** that flow fails visibly and records the stored dynamic identity as rejected -- **AND** the next explicit authorization attempt withholds the rejected identity so the SDK can perform new dynamic registration with a new authorization URL +- **THEN** that flow fails visibly and the rejected client identity is discarded from the durable record +- **AND** the stored tokens are left intact, because only the client identity was rejected +- **AND** the next explicit authorization registers a new client and completes - **AND** the prior active credentials remain unchanged until the replacement candidate is published - **BUT** an explicitly configured static client ID is never discarded or replaced automatically diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md b/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md index b1b664c26..3589ca5a1 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md @@ -56,15 +56,15 @@ never `Thread.Sleep` or `Task.Delay` in orchestration. ## 8. PR 2 — Credential store and persist-before-publish -- [x] 8.1 Add the `McpOAuthCredentialStore` singleton backing every per-connection `ITokenCache` adapter, so per-adapter in-memory dictionaries cannot diverge. Done when all adapters read one shared view. -- [x] 8.2 Implement persist-before-publish `StoreTokensAsync`: published-connection refresh writes the active record, while explicit authorization writes a flow-scoped durable pending record. Persist transactionally before advancing the matching in-memory view; on failure advance neither and propagate. Done when failed storage never advances memory and a failed candidate cannot replace active credentials. +- [x] 8.1 Add one `McpOAuthCredentialStore` singleton with one active record per server and one token-cache type. Unpublished candidates keep an isolated local view; the published cache is the sole active writer. +- [x] 8.2 Implement persist-before-return for published refreshes and one persist-before-publication commit for explicit authorization. On failure, leave the active record and published connection unchanged and propagate visibly. - [x] 8.3 Retain the prior refresh token when a token response omits `refresh_token`. Done when the persisted record keeps the previous refresh token. - [x] 8.4 Persist the DCR-issued effective client ID — and client secret, when issued — with the token record, update both from `DynamicClientRegistration.ResponseDelegate`, and seed `ClientOAuthOptions.ClientId`/`ClientSecret` from them on restart. Done when the stored client identity feeds the SDK options on reconnect. - [x] 8.5 Restart-without-metadata-file test: a registered client identity survives restart with no `mcp-oauth-metadata.json` present, and no re-registration occurs while the stored registration is valid. - [x] 8.6 Persist the canonical configured MCP resource identity with each credential record and compare it before returning cached tokens or seeding SDK client options. Done when repointing the same profile name withholds the old access token, refresh token, dynamically registered client ID, and client secret, reports `AwaitingAuth`, and preserves the old durable record until replacement succeeds; an explicitly configured static client ID remains authoritative. - [x] 8.7 Resource-binding canonicalization tests cover scheme/host case, default ports, fragments, path, and query so equivalent endpoint spellings compare consistently while a changed resource fails closed. - [x] 8.8 Legacy-binding migration test: a record without the new canonical binding supplies no token or dynamic client credentials, reports `AwaitingAuth`, preserves the old record, and never silently stamps the current profile identity onto it. -- [x] 8.9 Pending-record lifecycle tests: explicit auth stores pending credentials durably, successful candidate publication promotes them atomically, failed/expired candidates remove only pending state, and restart ignores and prunes abandoned pending records without changing active credentials. +- [x] 8.9 Candidate lifecycle tests: explicit auth stores credentials only in its local cache, successful initialization commits once before publication, failed/expired candidates leave durable active state unchanged, and restart after commit loads the complete replacement. - [x] 8.10 Invalid dynamic registration recovery test: explicit auth receiving `invalid_client` fails the current flow and records the dynamic identity as rejected; the next explicit attempt withholds that identity so SDK DCR can run with a new URL, while static configured client IDs never take this fallback. ## 9. PR 2 — Flow broker and endpoint wiring @@ -72,7 +72,7 @@ never `Thread.Sleep` or `Task.Delay` in orchestration. - [x] 9.1 Add `McpOAuthFlowBroker`: a cryptographically opaque one-time state bound to a single server and flow, a five-minute `TimeProvider`-bounded lifetime matching the existing CLI/TUI timeout, a task the owner SDK delegate completes with the authorization URL, and a task the callback completes with the code. The broker performs no discovery, DCR, PKCE, exchange, or refresh. The first delegate invocation owns the URL/code exchange; concurrent delegates observe authorization in progress without another prompt and never receive the owner's code. The owner honors SDK cancellation, and the broker validates callback state itself (the SDK neither generates nor validates `state` — inject it via `AdditionalAuthorizationParameters`). At most one flow is active per server; concurrent start requests coalesce and receive the same state and URL. - [x] 9.2 Wire the start, status, and callback endpoints keeping their existing surface: `POST /api/mcp/oauth/start/{name}` opens a pending flow and an unpublished candidate whose `AuthorizationRedirectDelegate` is owned by the flow; the status endpoint is polled unchanged; `GET /api/mcp/oauth/callback` validates the exact pending state and completes the flow. - [x] 9.3 Give each flow and candidate a manager-owned cancellation token bounded by flow expiry and daemon shutdown, independent of start and callback HTTP request cancellation, so a returned start response or closed browser tab cannot cancel a running exchange. Make missing/mismatched/reused/expired state fail with safe callback HTML without affecting other flows. Classify a cancelled or failed code exchange as requiring fresh authorization (the one-time code is burned) — never retry the exchange. -- [x] 9.4 Make explicit reauthorization use a cache view that withholds the existing access token — forcing the SDK authorization path — while the durable token set and refresh token stay intact until the new tokens are stored. +- [x] 9.4 Make explicit reauthorization use a candidate-local cache without the existing access token, forcing the SDK authorization path while durable active credentials remain intact until successful candidate commit. - [x] 9.5 Derive the callback URI from `DaemonConfig.Port` (`http://127.0.0.1:{port}/api/mcp/oauth/callback`), replacing all three hard-coded 5199 sites; never derive the host from a request `Host` header. Done when a non-default port is used consistently for registration and callback. - [x] 9.6 Tie flow status to the full candidate lifecycle: `Completed` only after durable token storage, tool listing, and publication; any subsequent initialization failure reports `Failed`. Keep the broker aligned with the existing five-minute CLI/TUI polling timeout and test that client cancellation does not cancel the daemon-owned flow. - [x] 9.7 Concurrent-start test: two start requests for one server receive the same flow identity and URL, create one candidate, and can produce only one credential write and published generation. diff --git a/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs index fc698557e..ab95f21d2 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs @@ -540,34 +540,6 @@ public void TryEmitOsc52Copy_StringWriter_ReturnsFalse() Assert.Equal(string.Empty, output.ToString()); } - [Fact] - public async Task Auth_ParsesStructuredBodylessDcrFailure() - { - await McpCommand.RunAsync( - ["mcp", "add", "--transport", "http", "oauth", "https://mcp.example/mcp"], - _paths, - output: _output); - var daemonApi = CreateDaemonApi(request => request.RequestUri!.AbsolutePath switch - { - "/api/mcp/oauth/start/oauth" => new HttpResponseMessage(HttpStatusCode.BadGateway) - { - Content = new StringContent( - "{\"error\":\"MCP OAuth dynamic client registration failed: HTTP 403 Forbidden.\",\"operation\":\"dynamic client registration\",\"status\":403}", - Encoding.UTF8, - "application/json"), - }, - _ => new HttpResponseMessage(HttpStatusCode.NotFound), - }); - var output = new StringWriter(); - - var exitCode = await McpCommand.RunAsync(["mcp", "auth", "oauth"], _paths, daemonApi, output); - - Assert.Equal(1, exitCode); - Assert.Contains("dynamic client registration", output.ToString(), StringComparison.Ordinal); - Assert.Contains("HTTP 403 Forbidden", output.ToString(), StringComparison.Ordinal); - Assert.DoesNotContain("{\"error\"", output.ToString(), StringComparison.Ordinal); - } - [Fact] public async Task Auth_EmptyErrorBodyFallsBackToHttpStatusAndReason() { diff --git a/src/Netclaw.Cli.Tests/Mcp/McpOAuthEndToEndTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpOAuthEndToEndTests.cs index 568c57f1b..8df1176b9 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpOAuthEndToEndTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpOAuthEndToEndTests.cs @@ -55,6 +55,9 @@ public async Task BodylessDcr403TraversesProviderDaemonEndpointSerializationAndC new ToolRegistry(), new ToolConfig(), credentials, + new McpOAuthClientRegistrar( + new HttpClient(new BodylessDcrHandler()) { BaseAddress = new Uri("https://oauth.test") }, + new RecordingLogger()), broker, new DaemonConfig(), NullNotificationSink.Instance, diff --git a/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs b/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs deleted file mode 100644 index 118b62958..000000000 --- a/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs +++ /dev/null @@ -1,484 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Text.Json; -using Netclaw.Cli.Config; -using Netclaw.Cli.Provider; -using Netclaw.Configuration; -using Netclaw.Configuration.Secrets; -using Netclaw.Tests.Utilities; -using Xunit; - -namespace Netclaw.Cli.Tests.Provider; - -public sealed class ProviderRenamerTests : IDisposable -{ - private readonly DisposableTempDir _dir = new(); - private readonly NetclawPaths _paths; - - public ProviderRenamerTests() - { - _paths = new NetclawPaths(_dir.Path); - _paths.EnsureDirectoriesExist(); - } - - public void Dispose() => _dir.Dispose(); - - [Fact] - public void Rename_SwapsKeyInConfigAndSecrets() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary - { - ["Type"] = "openai-compatible", - ["Endpoint"] = "http://localhost:8080", - ["AuthMethod"] = "ApiKey" - } - } - }); - - WriteSecrets(new Dictionary - { - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary - { - ["ApiKey"] = "sk-fake" - } - } - }); - - var result = ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100"); - - Assert.True(result.Success); - Assert.Null(result.ErrorMessage); - - var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); - var providers = config.RootElement.GetProperty("Providers"); - Assert.False(providers.TryGetProperty("my-vllm", out _)); - Assert.True(providers.TryGetProperty("lab-a100", out var entry)); - Assert.Equal("openai-compatible", entry.GetProperty("Type").GetString()); - Assert.Equal("http://localhost:8080", entry.GetProperty("Endpoint").GetString()); - - var secrets = JsonDocument.Parse(File.ReadAllText(_paths.SecretsPath)); - var secretProviders = secrets.RootElement.GetProperty("Providers"); - Assert.False(secretProviders.TryGetProperty("my-vllm", out _)); - Assert.True(secretProviders.TryGetProperty("lab-a100", out _)); - } - - [Fact] - public void Rename_NoSecretsEntry_StillSucceeds() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-ollama"] = new Dictionary - { - ["Type"] = "ollama", - ["Endpoint"] = "http://localhost:11434" - } - } - }); - - var result = ProviderRenamer.Rename(_paths, "my-ollama", "lab-ollama"); - - Assert.True(result.Success); - - var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); - var providers = config.RootElement.GetProperty("Providers"); - Assert.True(providers.TryGetProperty("lab-ollama", out _)); - } - - [Fact] - public void Rename_OldNameMissing_ReturnsError() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary() - }); - - var result = ProviderRenamer.Rename(_paths, "does-not-exist", "anything"); - - Assert.False(result.Success); - Assert.Contains("does-not-exist", result.ErrorMessage!); - } - - [Fact] - public void Rename_EmptyNewName_ReturnsError() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } - } - }); - - var result = ProviderRenamer.Rename(_paths, "my-vllm", " "); - - Assert.False(result.Success); - Assert.NotEmpty(result.ErrorMessage!); - } - - [Fact] - public void Rename_CollidesWithExistingProvider_ReturnsError() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm-a"] = new Dictionary { ["Type"] = "openai-compatible" }, - ["my-vllm-b"] = new Dictionary { ["Type"] = "openai-compatible" } - } - }); - - var result = ProviderRenamer.Rename(_paths, "my-vllm-a", "my-vllm-b"); - - Assert.False(result.Success); - Assert.Contains("my-vllm-b", result.ErrorMessage!); - } - - [Fact] - public void Rename_CollidesWithExistingSecretProvider_ReturnsErrorAndLeavesConfigUnchanged() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } - } - }); - WriteSecrets(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["ApiKey"] = "sk-old" }, - ["lab-a100"] = new Dictionary { ["ApiKey"] = "sk-new" } - } - }); - - var result = ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100"); - - Assert.False(result.Success); - Assert.Contains("lab-a100", result.ErrorMessage!); - - var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); - var providers = config.RootElement.GetProperty("Providers"); - Assert.True(providers.TryGetProperty("my-vllm", out _)); - Assert.False(providers.TryGetProperty("lab-a100", out _)); - } - - [Fact] - public void Rename_SecretsReadFailure_ThrowsAndLeavesConfigUnchanged() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } - } - }); - File.WriteAllText(_paths.SecretsPath, "{not-json"); - - Assert.ThrowsAny(() => ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100")); - - var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); - var providers = config.RootElement.GetProperty("Providers"); - Assert.True(providers.TryGetProperty("my-vllm", out _)); - Assert.False(providers.TryGetProperty("lab-a100", out _)); - } - - [Fact] - public void Rename_ConfigWriteFailure_ThrowsAndLeavesSecretsUnchanged() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } - } - }); - WriteSecrets(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["ApiKey"] = "sk-old" } - } - }); - var originalConfig = File.ReadAllText(_paths.NetclawConfigPath); - var originalSecrets = File.ReadAllText(_paths.SecretsPath); - - Assert.Throws(() => ProviderRenamer.Rename( - _paths, - "my-vllm", - "lab-a100", - static (_, _) => throw new InvalidOperationException("config write failed"), - static (path, text) => AtomicFile.WriteAllText(path, text), - secretsProtector: null)); - - Assert.Equal(originalConfig, File.ReadAllText(_paths.NetclawConfigPath)); - Assert.Equal(originalSecrets, File.ReadAllText(_paths.SecretsPath)); - AssertProviderState(_paths.NetclawConfigPath, oldNamePresent: true, newNamePresent: false); - AssertProviderState(_paths.SecretsPath, oldNamePresent: true, newNamePresent: false); - } - - [Fact] - public void Rename_SecretsWriteFailure_RestoresOriginalConfigAndLeavesSecretsUnchanged() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } - } - }); - WriteSecrets(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["ApiKey"] = "sk-old" } - } - }); - var originalConfig = File.ReadAllText(_paths.NetclawConfigPath); - var originalSecrets = File.ReadAllText(_paths.SecretsPath); - - Assert.Throws(() => ProviderRenamer.Rename( - _paths, - "my-vllm", - "lab-a100", - ConfigFileHelper.WriteConfigFile, - static (path, text) => AtomicFile.WriteAllText(path, text), - new ThrowingProtectSecretsProtector())); - - Assert.Equal(originalConfig, File.ReadAllText(_paths.NetclawConfigPath)); - Assert.Equal(originalSecrets, File.ReadAllText(_paths.SecretsPath)); - AssertProviderState(_paths.NetclawConfigPath, oldNamePresent: true, newNamePresent: false); - AssertProviderState(_paths.SecretsPath, oldNamePresent: true, newNamePresent: false); - } - - [Fact] - public void Rename_SecretsWriteFailureAndRestoreFailure_ThrowsWithBothErrors() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } - } - }); - WriteSecrets(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["ApiKey"] = "sk-old" } - } - }); - - var exception = Assert.Throws(() => ProviderRenamer.Rename( - _paths, - "my-vllm", - "lab-a100", - ConfigFileHelper.WriteConfigFile, - static (_, _) => throw new InvalidOperationException("restore failed"), - new ThrowingProtectSecretsProtector())); - - var aggregate = Assert.IsType(exception.InnerException); - Assert.Contains(aggregate.InnerExceptions, ex => ex.Message.Contains("secrets write failed", StringComparison.Ordinal)); - Assert.Contains(aggregate.InnerExceptions, ex => ex.Message.Contains("restore failed", StringComparison.Ordinal)); - } - - [Fact] - public void Rename_CollisionCheckIsCaseInsensitive() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" }, - ["my-ollama"] = new Dictionary { ["Type"] = "ollama" } - } - }); - - var result = ProviderRenamer.Rename(_paths, "my-vllm", "MY-OLLAMA"); - - Assert.False(result.Success); - } - - [Fact] - public void Rename_CaseOnlyChange_RewritesKeyInPlace() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } - } - }); - - var result = ProviderRenamer.Rename(_paths, "my-vllm", "My-Vllm"); - - Assert.True(result.Success); - - var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); - var providers = config.RootElement.GetProperty("Providers"); - Assert.True(providers.TryGetProperty("My-Vllm", out _)); - Assert.False(providers.TryGetProperty("my-vllm", out _)); - } - - [Fact] - public void Rename_CascadesToAllMatchingModelRoles() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" }, - ["my-ollama"] = new Dictionary { ["Type"] = "ollama" } - }, - ["Models"] = new Dictionary - { - ["Main"] = new Dictionary - { - ["Provider"] = "my-vllm", - ["ModelId"] = "Qwen/Qwen3-30B" - }, - ["Fallback"] = new Dictionary - { - ["Provider"] = "my-vllm", - ["ModelId"] = "Qwen/Qwen3-7B" - }, - ["Compaction"] = new Dictionary - { - ["Provider"] = "my-ollama", - ["ModelId"] = "qwen3:7b" - } - } - }); - - var result = ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100"); - - Assert.True(result.Success); - Assert.Equal(new[] { "Main", "Fallback" }, result.ReassignedModelRoles); - - using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); - var models = doc.RootElement.GetProperty("Models"); - Assert.Equal("lab-a100", models.GetProperty("Main").GetProperty("Provider").GetString()); - Assert.Equal("lab-a100", models.GetProperty("Fallback").GetProperty("Provider").GetString()); - Assert.Equal("my-ollama", models.GetProperty("Compaction").GetProperty("Provider").GetString()); - } - - [Fact] - public void Rename_NoModelsSection_ReportsEmptyReassignments() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } - } - }); - - var result = ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100"); - - Assert.True(result.Success); - Assert.Empty(result.ReassignedModelRoles); - } - - [Fact] - public void Rename_CascadeIsCaseInsensitive() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } - }, - ["Models"] = new Dictionary - { - ["Main"] = new Dictionary - { - ["Provider"] = "MY-VLLM", - ["ModelId"] = "x" - } - } - }); - - var result = ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100"); - - Assert.True(result.Success); - Assert.Contains("Main", result.ReassignedModelRoles); - } - - [Fact] - public void Rename_TrimsWhitespaceOnNewName() - { - WriteConfig(new Dictionary - { - ["configVersion"] = 1, - ["Providers"] = new Dictionary - { - ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } - } - }); - - var result = ProviderRenamer.Rename(_paths, "my-vllm", " lab-a100 "); - - Assert.True(result.Success); - - var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); - var providers = config.RootElement.GetProperty("Providers"); - Assert.True(providers.TryGetProperty("lab-a100", out _)); - } - - private void WriteConfig(Dictionary data) - { - File.WriteAllText(_paths.NetclawConfigPath, - JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); - } - - private void WriteSecrets(Dictionary data) - { - File.WriteAllText(_paths.SecretsPath, - JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); - } - - private static void AssertProviderState(string path, bool oldNamePresent, bool newNamePresent) - { - using var document = JsonDocument.Parse(File.ReadAllText(path)); - var providers = document.RootElement.GetProperty("Providers"); - Assert.Equal(oldNamePresent, providers.TryGetProperty("my-vllm", out _)); - Assert.Equal(newNamePresent, providers.TryGetProperty("lab-a100", out _)); - } - - private sealed class ThrowingProtectSecretsProtector : ISecretsProtector - { - public string Protect(string plaintext) => throw new InvalidOperationException("secrets write failed"); - - public string Unprotect(string ciphertext) => ciphertext; - } -} diff --git a/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs b/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs index 802f294ee..50338a0c2 100644 --- a/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs +++ b/src/Netclaw.Cli/Config/ProviderCredentialWriter.cs @@ -54,7 +54,7 @@ internal static void WriteProvider( paths.EnsureDirectoriesExist(); // Build provider config entry in netclaw.json - var (config, _) = ConfigFileHelper.LoadConfigFiles(paths); + var (config, secrets) = ConfigFileHelper.LoadConfigFiles(paths); var providers = ConfigFileHelper.GetOrCreateSection(config, "Providers"); var providerEntry = new Dictionary @@ -93,15 +93,13 @@ internal static void WriteProvider( } else if (!string.IsNullOrWhiteSpace(apiKey)) { - ConfigFileHelper.UpdateSecretsFile(paths, (secrets, _) => + var secretProviders = ConfigFileHelper.GetOrCreateSection(secrets, "Providers"); + secretProviders[providerName] = new Dictionary { - var secretProviders = ConfigFileHelper.GetOrCreateSection(secrets, "Providers"); - secretProviders[providerName] = new Dictionary - { - ["ApiKey"] = apiKey - }; - return true; - }, effectiveProtector); + ["ApiKey"] = apiKey + }; + SecretsFileWriter.Write(paths.SecretsPath, secrets, + options: JsonDefaults.Indented, protector: effectiveProtector); } } } diff --git a/src/Netclaw.Cli/Daemon/PairCommand.cs b/src/Netclaw.Cli/Daemon/PairCommand.cs index 117d20ce2..b0d117d4d 100644 --- a/src/Netclaw.Cli/Daemon/PairCommand.cs +++ b/src/Netclaw.Cli/Daemon/PairCommand.cs @@ -72,11 +72,9 @@ public static async Task RunAsync(string[] args, NetclawPaths paths) } // Persist token to secrets.json (encrypted at rest). - ConfigFileHelper.UpdateSecretsFile(paths, (secrets, _) => - { - secrets["DeviceToken"] = result.Token; - return true; - }); + var secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); + secrets["DeviceToken"] = result.Token; + ConfigFileHelper.WriteSecretsFile(paths, secrets); // Persist the local client's preferred daemon endpoint separately from // daemon-owned netclaw.json. diff --git a/src/Netclaw.Cli/Provider/ProviderCommand.cs b/src/Netclaw.Cli/Provider/ProviderCommand.cs index 2531c2c90..08be43d31 100644 --- a/src/Netclaw.Cli/Provider/ProviderCommand.cs +++ b/src/Netclaw.Cli/Provider/ProviderCommand.cs @@ -428,7 +428,7 @@ private static int RunRemove(string[] args, NetclawPaths paths, TextWriter write return 1; } - var (config, _) = ConfigFileHelper.LoadConfigFiles(paths); + var (config, secrets) = ConfigFileHelper.LoadConfigFiles(paths); var removed = false; var providers = ConfigFileHelper.GetSectionOrNull(config, "Providers"); @@ -438,13 +438,12 @@ private static int RunRemove(string[] args, NetclawPaths paths, TextWriter write removed = true; } - var removedSecrets = ConfigFileHelper.UpdateSecretsFile(paths, (secrets, _) => + var secretProviders = ConfigFileHelper.GetSectionOrNull(secrets, "Providers"); + if (secretProviders?.Remove(name) == true) { - var secretProviders = ConfigFileHelper.GetSectionOrNull(secrets, "Providers"); - var removedSecret = secretProviders?.Remove(name) == true; - return (removedSecret, removedSecret); - }); - removed |= removedSecrets; + ConfigFileHelper.WriteSecretsFile(paths, secrets); + removed = true; + } if (removed) { diff --git a/src/Netclaw.Cli/Provider/ProviderRenamer.cs b/src/Netclaw.Cli/Provider/ProviderRenamer.cs index 624278b3a..ed0620763 100644 --- a/src/Netclaw.Cli/Provider/ProviderRenamer.cs +++ b/src/Netclaw.Cli/Provider/ProviderRenamer.cs @@ -3,11 +3,9 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- -using System.Runtime.ExceptionServices; using System.Text.Json; using Netclaw.Cli.Config; using Netclaw.Configuration; -using Netclaw.Configuration.Secrets; namespace Netclaw.Cli.Provider; @@ -51,34 +49,17 @@ internal static class ProviderRenamer /// /// public static RenameResult Rename(NetclawPaths paths, string oldName, string newName) - => Rename( - paths, - oldName, - newName, - ConfigFileHelper.WriteConfigFile, - static (path, text) => AtomicFile.WriteAllText(path, text), - secretsProtector: null); - - internal static RenameResult Rename( - NetclawPaths paths, - string oldName, - string newName, - Action> writeConfigFile, - Action restoreConfigFile, - ISecretsProtector? secretsProtector) { var trimmed = newName?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(trimmed)) return RenameResult.Fail("Provider name cannot be empty."); - var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + var (config, secrets) = ConfigFileHelper.LoadConfigFiles(paths); var providers = ConfigFileHelper.GetSectionOrNull(config, "Providers"); if (providers is null || !providers.ContainsKey(oldName)) return RenameResult.Fail($"Provider '{oldName}' not found."); - var originalConfigText = File.ReadAllText(paths.NetclawConfigPath); - // Collision check: walk both config and secrets dictionaries. A key // that case-insensitive-equals oldName is the entry we're renaming and // is not a collision. Any other key that case-insensitive-equals the @@ -86,80 +67,26 @@ internal static RenameResult Rename( if (HasCollision(providers, oldName, trimmed)) return RenameResult.Fail($"A provider named '{trimmed}' already exists."); + var secretProviders = ConfigFileHelper.GetSectionOrNull(secrets, "Providers"); + if (secretProviders is not null && HasCollision(secretProviders, oldName, trimmed)) + return RenameResult.Fail($"A provider named '{trimmed}' already exists in secrets."); + var entry = providers[oldName]; providers.Remove(oldName); providers[trimmed] = entry; var reassigned = CascadeRenameModelRoles(config, oldName, trimmed); - var configWritten = false; - - RenameSecretsResult secretsResult; - try - { - secretsResult = ConfigFileHelper.UpdateSecretsFile(paths, (latestSecrets, _) => - { - var latestSecretProviders = ConfigFileHelper.GetSectionOrNull(latestSecrets, "Providers"); - if (latestSecretProviders is null) - { - writeConfigFile(paths.NetclawConfigPath, config); - configWritten = true; - return (false, RenameSecretsResult.NoOldName); - } - - if (HasCollision(latestSecretProviders, oldName, trimmed)) - return (false, RenameSecretsResult.Collision); - - if (!latestSecretProviders.TryGetValue(oldName, out var secretEntry)) - { - writeConfigFile(paths.NetclawConfigPath, config); - configWritten = true; - return (false, RenameSecretsResult.NoOldName); - } - - writeConfigFile(paths.NetclawConfigPath, config); - configWritten = true; - latestSecretProviders.Remove(oldName); - latestSecretProviders[trimmed] = secretEntry; - return (true, RenameSecretsResult.Renamed); - }, secretsProtector); - } - catch (Exception ex) when (configWritten) - { - RestoreOriginalConfigAndThrow(paths.NetclawConfigPath, originalConfigText, restoreConfigFile, ex); - throw; - } - - if (secretsResult is RenameSecretsResult.Collision) - return RenameResult.Fail($"A provider named '{trimmed}' already exists in secrets."); - return RenameResult.Ok(reassigned); - } + ConfigFileHelper.WriteConfigFile(paths.NetclawConfigPath, config); - private static void RestoreOriginalConfigAndThrow( - string configPath, - string originalConfigText, - Action restoreConfigFile, - Exception renameException) - { - try - { - restoreConfigFile(configPath, originalConfigText); - } - catch (Exception restoreException) + if (secretProviders is not null && secretProviders.TryGetValue(oldName, out var secretEntry)) { - throw new InvalidOperationException( - "Provider rename failed after writing netclaw.json, then failed to restore the original netclaw.json.", - new AggregateException(renameException, restoreException)); + secretProviders.Remove(oldName); + secretProviders[trimmed] = secretEntry; + ConfigFileHelper.WriteSecretsFile(paths, secrets); } - ExceptionDispatchInfo.Capture(renameException).Throw(); - } - - private enum RenameSecretsResult - { - NoOldName, - Renamed, - Collision, + return RenameResult.Ok(reassigned); } private static List CascadeRenameModelRoles( diff --git a/src/Netclaw.Cli/Secrets/SecretsCommand.cs b/src/Netclaw.Cli/Secrets/SecretsCommand.cs index 243b9b0c3..2d8d168ea 100644 --- a/src/Netclaw.Cli/Secrets/SecretsCommand.cs +++ b/src/Netclaw.Cli/Secrets/SecretsCommand.cs @@ -51,6 +51,18 @@ private static int RunSet(string[] args, NetclawPaths paths, TextWriter writer) var keyPath = args[2]; var value = args[3]; + // Load existing secrets or start fresh + JsonObject root; + if (File.Exists(paths.SecretsPath)) + { + var existing = File.ReadAllText(paths.SecretsPath); + root = JsonNode.Parse(existing)?.AsObject() ?? []; + } + else + { + root = []; + } + string[] segments; try { @@ -62,16 +74,12 @@ private static int RunSet(string[] args, NetclawPaths paths, TextWriter writer) return 1; } + SecretsJsonUpdater.UpsertValue(root, segments, value); + + // Write with encryption — the protector encrypts all plaintext leaves var protector = SecretsProtection.CreateProtector(paths); - SecretsFileWriter.Update( - paths.SecretsPath, - (root, _) => - { - SecretsJsonUpdater.UpsertValue(root, segments, value); - return (root, true); - }, - JsonDefaults.Indented, - protector); + var json = root.ToJsonString(JsonDefaults.Indented); + SecretsFileWriter.Write(paths.SecretsPath, json, protector); writer.WriteLine($"Set {string.Join('.', segments)} (encrypted)."); return 0; diff --git a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs index d9db3cbe1..a39c60fd5 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs @@ -692,15 +692,13 @@ private void WriteFixedCredentials() if (!string.IsNullOrWhiteSpace(FixApiKey)) { - ConfigFileHelper.UpdateSecretsFile(_paths, (secrets, _) => + var (_, secrets) = ConfigFileHelper.LoadConfigFiles(_paths); + var secretProviders = ConfigFileHelper.GetOrCreateSection(secrets, "Providers"); + secretProviders[name] = new Dictionary { - var secretProviders = ConfigFileHelper.GetOrCreateSection(secrets, "Providers"); - secretProviders[name] = new Dictionary - { - ["ApiKey"] = FixApiKey - }; - return true; - }); + ["ApiKey"] = FixApiKey + }; + ConfigFileHelper.WriteSecretsFile(_paths, secrets); } if (FixEndpoint is not null && DetailProvider.Entry is not null @@ -759,17 +757,15 @@ public void ConfirmRemove() return; } - var (config, _) = ConfigFileHelper.LoadConfigFiles(_paths); + var (config, secrets) = ConfigFileHelper.LoadConfigFiles(_paths); var providers = ConfigFileHelper.GetSectionOrNull(config, "Providers"); if (providers?.Remove(RemoveProviderName) == true) ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); - ConfigFileHelper.UpdateSecretsFile(_paths, (secrets, _) => - { - var secretProviders = ConfigFileHelper.GetSectionOrNull(secrets, "Providers"); - return secretProviders?.Remove(RemoveProviderName) == true; - }); + var secretProviders = ConfigFileHelper.GetSectionOrNull(secrets, "Providers"); + if (secretProviders?.Remove(RemoveProviderName) == true) + ConfigFileHelper.WriteSecretsFile(_paths, secrets); StatusMessage.Value = $"Removed provider '{RemoveProviderName}'. Restart daemon for changes to take effect."; RemoveProviderName = null; diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs index 3b025a738..148f2a85e 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs @@ -359,12 +359,12 @@ public void EnsureCurrentClientPaired(NetclawPaths paths) private static void WriteLocalDeviceTokenValue(NetclawPaths paths, string rawToken) { - ConfigFileHelper.UpdateSecretsFile(paths, (secrets, _) => - { - secrets["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; - secrets["DeviceToken"] = rawToken; - return true; - }); + var secrets = File.Exists(paths.SecretsPath) + ? ConfigFileHelper.LoadJsonDict(paths.SecretsPath) + : new Dictionary(); + secrets["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + secrets["DeviceToken"] = rawToken; + ConfigFileHelper.WriteSecretsFile(paths, secrets); } private static List ReadPairedDevices(NetclawPaths paths) diff --git a/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj b/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj index ef129857d..762769752 100644 --- a/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj +++ b/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj @@ -28,8 +28,6 @@ - diff --git a/src/Netclaw.Configuration.Tests/SecretsFileWriterTests.cs b/src/Netclaw.Configuration.Tests/SecretsFileWriterTests.cs index 874246d81..71ddf381a 100644 --- a/src/Netclaw.Configuration.Tests/SecretsFileWriterTests.cs +++ b/src/Netclaw.Configuration.Tests/SecretsFileWriterTests.cs @@ -246,47 +246,6 @@ public async Task Update_serializes_cross_section_mutations_and_second_writer_ob } } - [Fact] - public async Task Update_serializes_cross_process_mutations_and_observes_committed_state() - { - SecretsFileWriter.Write(_secretsPath, """{"Initial":"keep"}"""); - using var child = StartSecretsLockProbe(_secretsPath, "from-child"); - var cancellationToken = TestContext.Current.CancellationToken; - - Assert.Equal("entered", await ReadRequiredStdoutLineAsync(child, cancellationToken)); - - string? parentObservedChild = null; - using var parentStarted = new ManualResetEventSlim(); - var parent = Task.Run(() => - { - parentStarted.Set(); - return SecretsFileWriter.Update( - _secretsPath, - (root, _) => - { - parentObservedChild = root["Child"]?.GetValue(); - root["Parent"] = "from-parent"; - return (root, true); - }, - cancellationToken: cancellationToken); - }, cancellationToken); - - Assert.True(parentStarted.Wait(TimeSpan.FromSeconds(10), cancellationToken)); - await child.StandardInput.WriteLineAsync("release"); - await child.StandardInput.FlushAsync(cancellationToken); - - Assert.Equal("committed", await ReadRequiredStdoutLineAsync(child, cancellationToken)); - await child.WaitForExitAsync(cancellationToken); - Assert.Equal(0, child.ExitCode); - Assert.True(await parent.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken)); - - Assert.Equal("from-child", parentObservedChild); - using var doc = JsonDocument.Parse(File.ReadAllText(_secretsPath)); - Assert.Equal("keep", doc.RootElement.GetProperty("Initial").GetString()); - Assert.Equal("from-child", doc.RootElement.GetProperty("Child").GetString()); - Assert.Equal("from-parent", doc.RootElement.GetProperty("Parent").GetString()); - } - [Fact] public void Update_when_file_replacement_fails_propagates_and_keeps_previous_content() { @@ -317,56 +276,4 @@ public void Update_when_file_replacement_fails_propagates_and_keeps_previous_con Assert.Equal(before, File.ReadAllText(_secretsPath)); } - - private static Process StartSecretsLockProbe(string secretsPath, string childValue) - { - var info = new ProcessStartInfo("dotnet") - { - RedirectStandardError = true, - RedirectStandardInput = true, - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - info.ArgumentList.Add(LocateSecretsLockProbeDll()); - info.ArgumentList.Add(secretsPath); - info.ArgumentList.Add(childValue); - - return Process.Start(info) - ?? throw new InvalidOperationException("Failed to start secrets lock probe."); - } - - private static async Task ReadRequiredStdoutLineAsync(Process process, CancellationToken cancellationToken) - { - var line = await process.StandardOutput - .ReadLineAsync(cancellationToken) - .AsTask() - .WaitAsync(TimeSpan.FromSeconds(30), cancellationToken); - - if (line is not null) - return line; - - var stderr = await process.StandardError.ReadToEndAsync(cancellationToken); - throw new InvalidOperationException($"Secrets lock probe exited before writing a line. stderr: {stderr}"); - } - - private static string LocateSecretsLockProbeDll() - { - var repo = new DirectoryInfo(AppContext.BaseDirectory); - while (repo is not null && !File.Exists(Path.Combine(repo.FullName, "Netclaw.slnx"))) - repo = repo.Parent; - Assert.NotNull(repo); - - var projectDir = Path.Combine(repo!.FullName, "tests", "Netclaw.SecretsLockProbe"); - var binMarker = $"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"; - var dll = Directory - .EnumerateFiles(projectDir, "Netclaw.SecretsLockProbe.dll", SearchOption.AllDirectories) - .Where(p => p.Contains(binMarker, StringComparison.Ordinal)) - .OrderByDescending(File.GetLastWriteTimeUtc) - .FirstOrDefault(); - - Assert.True(dll is not null, - $"Netclaw.SecretsLockProbe.dll not found under {projectDir}/bin - is the project built?"); - return dll!; - } } diff --git a/src/Netclaw.Configuration/McpOAuthTokenSet.cs b/src/Netclaw.Configuration/McpOAuthTokenSet.cs index eecbd294d..ae0e6af69 100644 --- a/src/Netclaw.Configuration/McpOAuthTokenSet.cs +++ b/src/Netclaw.Configuration/McpOAuthTokenSet.cs @@ -48,34 +48,4 @@ public sealed class McpOAuthTokenSet /// Canonical configured endpoint identity bound to these credentials. public string? ResourceIdentity { get; set; } - - /// - /// Ownership epoch used to reject writes from retired connections and stale processes. - /// - public string? CredentialEpoch { get; set; } -} - -/// Flow-scoped credentials that are not yet active. -public sealed class McpOAuthPendingCredential -{ - public string FlowId { get; set; } = null!; - - public DateTimeOffset ExpiresAt { get; set; } - - public McpOAuthTokenSet Credentials { get; set; } = null!; -} - -/// Per-server durable active and pending OAuth state. -public sealed class McpOAuthCredentialEnvelope -{ - public McpOAuthTokenSet? Active { get; set; } - - public McpOAuthPendingCredential? Pending { get; set; } - - /// - /// A dynamic identity rejected as invalid_client. Explicit flows continue - /// withholding it until another dynamic identity is captured or a - /// replacement credential set publishes. - /// - public string? RejectedDynamicClientId { get; set; } } diff --git a/src/Netclaw.Configuration/SecretsFileWriter.cs b/src/Netclaw.Configuration/SecretsFileWriter.cs index 0fc29dad6..b1b04024d 100644 --- a/src/Netclaw.Configuration/SecretsFileWriter.cs +++ b/src/Netclaw.Configuration/SecretsFileWriter.cs @@ -3,8 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- -using System.Diagnostics; -using System.Security.Cryptography; +using System.Collections.Concurrent; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -22,7 +21,6 @@ namespace Netclaw.Configuration; public static class SecretsFileWriter { private static readonly TimeSpan SecretsLockTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan SecretsLockPollInterval = TimeSpan.FromMilliseconds(50); private static readonly JsonSerializerOptions DefaultJsonOptions = new() { @@ -40,7 +38,7 @@ public static void Write(string secretsPath, string json, ISecretsProtector? pro } /// - /// Read the latest secrets document and replace it while holding a path-scoped interprocess lock. + /// Read the latest secrets document and replace it while holding a path-scoped lock. /// Returning a null updated root leaves the file unchanged. /// public static TResult Update( @@ -257,117 +255,44 @@ private static void CountNode(JsonNode node, ref int encrypted, ref int plaintex /// internal static void SetOwnerOnlyPermissions(string path) => AtomicFile.HardenOwnerOnly(path); + /// + /// Serializes read-modify-write against one secrets file within this process. The daemon + /// is the only writer at runtime, and the CLI writes while it is stopped, so a + /// cross-process lock buys nothing that this does not already cover. + /// private static IDisposable AcquireSecretsLock(string secretsPath, CancellationToken cancellationToken) { - var canonicalPath = GetCanonicalLockPath(secretsPath); - var lockIdentity = OperatingSystem.IsWindows() - ? canonicalPath.ToUpperInvariant() - : canonicalPath; - var lockId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(lockIdentity))); - var lockScope = OperatingSystem.IsWindows() ? @"Global\" : string.Empty; - var mutex = new Mutex(initiallyOwned: false, $"{lockScope}netclaw-secrets-file-{lockId}"); - var ownsMutex = false; - - try - { - try - { - ownsMutex = WaitForMutex(mutex, cancellationToken); - } - catch (AbandonedMutexException) - { - ownsMutex = true; - // A killed writer may leave only a sibling temp file; the destination remains atomic. - DeleteAbandonedTempFiles(canonicalPath); - } - - if (!ownsMutex) - throw new TimeoutException($"Timed out waiting to update secrets file '{secretsPath}'."); - - return new SecretsLock(mutex); - } - catch - { - if (ownsMutex) - mutex.ReleaseMutex(); - mutex.Dispose(); - throw; - } + var gate = Gates.GetOrAdd(GateKey(secretsPath), static _ => new SemaphoreSlim(1, 1)); + if (!gate.Wait(SecretsLockTimeout, cancellationToken)) + throw new TimeoutException($"Timed out waiting to update secrets file '{secretsPath}'."); + return new SecretsLock(gate); } - private static bool WaitForMutex(Mutex mutex, CancellationToken cancellationToken) - { - if (!cancellationToken.CanBeCanceled) - return mutex.WaitOne(SecretsLockTimeout); - - var startedAt = Stopwatch.GetTimestamp(); - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - if (mutex.WaitOne(TimeSpan.Zero)) - return true; - - var remaining = SecretsLockTimeout - Stopwatch.GetElapsedTime(startedAt); - if (remaining <= TimeSpan.Zero) - return false; - - var wait = remaining < SecretsLockPollInterval ? remaining : SecretsLockPollInterval; - if (cancellationToken.WaitHandle.WaitOne(wait)) - cancellationToken.ThrowIfCancellationRequested(); - } - } + private static readonly ConcurrentDictionary Gates = new(StringComparer.Ordinal); - private static string GetCanonicalLockPath(string filePath) + /// + /// Two spellings of the same file must share one gate, so a symlinked config directory + /// does not hand concurrent writers separate locks and lose an update. Only the + /// immediate parent is resolved; a symlink deeper in the path is not a shape + /// produces. + /// + private static string GateKey(string secretsPath) { - var fullPath = Path.GetFullPath(filePath); - var directoryPath = Path.GetDirectoryName(fullPath) + var fullPath = Path.GetFullPath(secretsPath); + var directory = Path.GetDirectoryName(fullPath) ?? throw new InvalidOperationException("Secrets path has no parent directory."); - return Path.Combine(GetCanonicalDirectoryPath(directoryPath), Path.GetFileName(fullPath)); - } - - private static string GetCanonicalDirectoryPath(string directoryPath) - { - var fullPath = Path.GetFullPath(directoryPath); - var root = Path.GetPathRoot(fullPath) - ?? throw new InvalidOperationException("Secrets path has no root directory."); - var current = root; - var relativeDirectory = Path.GetRelativePath(root, fullPath); - foreach (var segment in relativeDirectory.Split( - [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], - StringSplitOptions.RemoveEmptyEntries)) + if (Directory.Exists(directory)) { - var nextPath = Path.Combine(current, segment); - if (!Directory.Exists(nextPath)) - { - current = nextPath; - continue; - } - - var directory = new DirectoryInfo(nextPath); - var target = directory.ResolveLinkTarget(returnFinalTarget: true); - current = target is null - ? directory.FullName - : GetCanonicalDirectoryPath(target.FullName); + directory = new DirectoryInfo(directory).ResolveLinkTarget(returnFinalTarget: true)?.FullName + ?? directory; } - return current; + var key = Path.Combine(directory, Path.GetFileName(fullPath)); + return OperatingSystem.IsWindows() ? key.ToUpperInvariant() : key; } - private static void DeleteAbandonedTempFiles(string filePath) + private sealed class SecretsLock(SemaphoreSlim gate) : IDisposable { - var directory = Path.GetDirectoryName(filePath) - ?? throw new InvalidOperationException("Secrets path has no parent directory."); - var fileName = Path.GetFileName(filePath); - foreach (var tempPath in Directory.EnumerateFiles(directory, $"{fileName}.tmp-*")) - File.Delete(tempPath); - } - - private sealed class SecretsLock(Mutex mutex) : IDisposable - { - public void Dispose() - { - mutex.ReleaseMutex(); - mutex.Dispose(); - } + public void Dispose() => gate.Release(); } } diff --git a/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs b/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs index d339250d1..be6719e59 100644 --- a/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs +++ b/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs @@ -18,6 +18,7 @@ using Netclaw.Daemon.Configuration; using Netclaw.Daemon.Gateway; using Netclaw.Daemon.Mcp; +using Netclaw.Daemon.Tests.Mcp; using Netclaw.Daemon.Services; using Netclaw.Providers.OAuth; using Netclaw.Tools; @@ -297,6 +298,7 @@ public async Task IncludesMcpConnectorHealthFromRuntimeStatuses() new ToolRegistry(), new ToolConfig(), credentials, + McpOAuthTestDoubles.UnusedRegistrar(), flowBroker, new DaemonConfig(), NullNotificationSink.Instance, diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs index 3ce7d8e8f..e1f5ef93b 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs @@ -200,38 +200,7 @@ public async Task ShutdownRacingReconnect_PublishesNothingAndDisposesEveryClient } [Fact] - public async Task Replacement_DrainsPriorGenerationBeforeDisposal() - { - var invocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var releaseInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var runtime = new ControlledMcpClientRuntime(); - var initial = runtime.Enqueue(new ClientPlan("run") - { - Invoke = async (_, ct) => - { - invocationEntered.TrySetResult(); - await releaseInvocation.Task.WaitAsync(ct); - return "completed on old generation"; - }, - }); - var replacement = runtime.Enqueue(new ClientPlan("run")); - await using var harness = CreateHarness(runtime); - await harness.Manager.StartAsync(TestContext.Current.CancellationToken); - - var invocation = InvokeAsync(harness.Manager, TestContext.Current.CancellationToken); - await invocationEntered.Task; - Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); - - Assert.Equal(0, initial.DisposeCount); - Assert.Same(replacement.Client, harness.Manager.GetSnapshot(ServerName)?.Client); - releaseInvocation.SetResult(); - Assert.Equal("completed on old generation", await invocation); - await initial.Disposed.Task; - Assert.Equal(1, initial.DisposeCount); - } - - [Fact] - public async Task TransportFailure_ReleasesLeaseBeforeReconnectAndDoesNotReplay() + public async Task TransportFailure_ReconnectsForLaterCallsAndDoesNotReplay() { var runtime = new ControlledMcpClientRuntime(); var initial = runtime.Enqueue(new ClientPlan("run") @@ -254,106 +223,7 @@ public async Task TransportFailure_ReleasesLeaseBeforeReconnectAndDoesNotReplay( } [Fact] - public async Task ShutdownWithActiveInvocation_BoundsDrainThenCancelsAndDisposesAfterRelease() - { - var invocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var invocationCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var holdInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var runtime = new ControlledMcpClientRuntime(); - var active = runtime.Enqueue(new ClientPlan("run") - { - Invoke = async (_, ct) => - { - invocationEntered.TrySetResult(); - try - { - await holdInvocation.Task.WaitAsync(ct); - return "unreachable"; - } - catch (OperationCanceledException) - { - invocationCancelled.TrySetResult(); - throw; - } - }, - }); - var time = new FakeTimeProvider(InitialTime); - await using var harness = CreateHarness(runtime, time); - await harness.Manager.StartAsync(TestContext.Current.CancellationToken); - - var invocation = InvokeAsync(harness.Manager, TestContext.Current.CancellationToken); - await invocationEntered.Task; - var stop = harness.Manager.StopAsync(TestContext.Current.CancellationToken); - - Assert.True(harness.Manager.IsStopping); - Assert.Equal(0, active.DisposeCount); - await Assert.ThrowsAsync( - () => InvokeAsync(harness.Manager, TestContext.Current.CancellationToken)); - Assert.False(stop.IsCompleted); - - time.Advance(McpClientManager.ShutdownDrainTimeout); - await invocationCancelled.Task; - await Assert.ThrowsAnyAsync(() => invocation); - await stop; - await active.Disposed.Task; - - Assert.Equal(1, active.DisposeCount); - Assert.Null(harness.Manager.GetSnapshot(ServerName)); - Assert.Empty(harness.Registry.GetToolsForServer(ServerName, int.MaxValue)); - } - - [Fact] - public async Task ReplacementThenShutdown_WaitsForRetiredGenerationAndCancelsItsInvocation() - { - var invocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var invocationCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var holdInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var runtime = new ControlledMcpClientRuntime(); - var retired = runtime.Enqueue(new ClientPlan("old_tool") - { - Invoke = async (_, ct) => - { - invocationEntered.TrySetResult(); - try - { - await holdInvocation.Task.WaitAsync(ct); - return "unreachable"; - } - catch (OperationCanceledException) - { - invocationCancelled.TrySetResult(); - throw; - } - }, - }); - var current = runtime.Enqueue(new ClientPlan("new_tool")); - var time = new FakeTimeProvider(InitialTime); - await using var harness = CreateHarness(runtime, time); - await harness.Manager.StartAsync(TestContext.Current.CancellationToken); - - var invocation = InvokeAsync(harness.Manager, "old_tool", TestContext.Current.CancellationToken); - await invocationEntered.Task; - Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); - AssertPublishedTools(harness, "new_tool"); - Assert.Equal(0, retired.DisposeCount); - - var stop = harness.Manager.StopAsync(TestContext.Current.CancellationToken); - Assert.Null(harness.Manager.GetSnapshot(ServerName)); - Assert.Empty(harness.Registry.GetToolsForServer(ServerName, int.MaxValue)); - Assert.False(stop.IsCompleted); - - time.Advance(McpClientManager.ShutdownDrainTimeout); - await invocationCancelled.Task; - await Assert.ThrowsAnyAsync(() => invocation); - await stop; - - await Task.WhenAll(retired.Disposed.Task, current.Disposed.Task); - Assert.Equal(1, retired.DisposeCount); - Assert.Equal(1, current.DisposeCount); - } - - [Fact] - public async Task CandidateInitializationAndDisposalFailure_AreBothSurfacedAndPriorToolsRemainPublished() + public async Task CandidateInitializationAndDisposalFailures_AreLoudAndPriorToolsRemainPublished() { var runtime = new ControlledMcpClientRuntime(); var initial = runtime.Enqueue(new ClientPlan("old_tool")); @@ -375,82 +245,6 @@ public async Task CandidateInitializationAndDisposalFailure_AreBothSurfacedAndPr Assert.Equal(1, candidate.DisposeCount); } - [Fact] - public async Task RetiredClientDisposalFailure_IsPreservedUntilShutdownAndRegistryIsRemoved() - { - var runtime = new ControlledMcpClientRuntime(); - var retired = runtime.Enqueue(new ClientPlan("old_tool") - { - DisposeFailure = new IOException("retired dispose failed"), - }); - var current = runtime.Enqueue(new ClientPlan("new_tool")); - var harness = CreateHarness(runtime); - try - { - await harness.Manager.StartAsync(TestContext.Current.CancellationToken); - Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); - await retired.Disposed.Task; - AssertPublishedTools(harness, "new_tool"); - - var failure = await Assert.ThrowsAsync( - () => harness.Manager.StopAsync(TestContext.Current.CancellationToken)); - harness.MarkStopFailureObserved(); - - Assert.Equal("retired dispose failed", failure.Message); - Assert.Null(harness.Manager.GetSnapshot(ServerName)); - Assert.Empty(harness.Registry.GetToolsForServer(ServerName, int.MaxValue)); - Assert.Equal(1, retired.DisposeCount); - Assert.Equal(1, current.DisposeCount); - } - finally - { - await harness.DisposeAsync(); - } - } - - [Fact] - public async Task RepeatedReconnects_PruneSuccessfulOwnersButRetainDisposalFailureForShutdown() - { - const int replacementCount = 25; - var runtime = new ControlledMcpClientRuntime(); - var faulted = runtime.Enqueue(new ClientPlan("tool_0") - { - DisposeFailure = new IOException("retained disposal failure"), - }); - var replacements = Enumerable.Range(1, replacementCount) - .Select(index => runtime.Enqueue(new ClientPlan($"tool_{index}"))) - .ToList(); - var harness = CreateHarness(runtime); - harness.MarkStopFailureObserved(); - try - { - await harness.Manager.StartAsync(TestContext.Current.CancellationToken); - - for (var index = 1; index <= replacementCount; index++) - { - Assert.True(await harness.Manager.TryReconnectAsync( - ServerName, - TestContext.Current.CancellationToken)); - Assert.Equal(2, harness.Manager.GetTrackedOwnerCount(ServerName)); - } - - Assert.Equal(1, faulted.DisposeCount); - Assert.All(replacements.Take(replacementCount - 1), plan => Assert.Equal(1, plan.DisposeCount)); - Assert.Equal(0, replacements[^1].DisposeCount); - - var failure = await Assert.ThrowsAsync( - () => harness.Manager.StopAsync(TestContext.Current.CancellationToken)); - - Assert.Equal("retained disposal failure", failure.Message); - Assert.Equal(1, replacements[^1].DisposeCount); - Assert.Equal(0, harness.Manager.GetTrackedOwnerCount(ServerName)); - } - finally - { - await harness.DisposeAsync(); - } - } - [Fact] public async Task ApplicationExceptionsWrappingTransportLikeErrors_DoNotReconnect() { @@ -480,48 +274,28 @@ await Assert.ThrowsAsync( } [Fact] - public async Task DisposeWithoutStop_CleansCurrentAndRetiredOwnersAfterReplacement() + public async Task ReplacementDisposesTheRetiredClientAndDisposeWithoutStopClearsPublishedState() { - var invocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var invocationCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var holdInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var runtime = new ControlledMcpClientRuntime(); - var retired = runtime.Enqueue(new ClientPlan("old_tool") - { - Invoke = async (_, ct) => - { - invocationEntered.TrySetResult(); - try - { - await holdInvocation.Task.WaitAsync(ct); - return "unreachable"; - } - catch (OperationCanceledException) - { - invocationCancelled.TrySetResult(); - throw; - } - }, - }); + var retired = runtime.Enqueue(new ClientPlan("old_tool")); var current = runtime.Enqueue(new ClientPlan("new_tool")); var harness = CreateHarness(runtime); try { await harness.Manager.StartAsync(TestContext.Current.CancellationToken); - var invocation = InvokeAsync(harness.Manager, "old_tool", TestContext.Current.CancellationToken); - await invocationEntered.Task; Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); - Assert.Equal(0, retired.DisposeCount); + + // The replaced client is disposed as soon as its replacement is published. + // Waiting for in-flight calls to drain first belongs to the separate + // client-lifecycle work, not to this one. + await retired.Disposed.Task; + Assert.Equal(1, retired.DisposeCount); + AssertPublishedTools(harness, "new_tool"); harness.DisposeManagerWithoutStop(); Assert.Null(harness.Manager.GetSnapshot(ServerName)); Assert.Empty(harness.Registry.GetToolsForServer(ServerName, int.MaxValue)); - await invocationCancelled.Task; - await Assert.ThrowsAnyAsync(() => invocation); - await Task.WhenAll(retired.Disposed.Task, current.Disposed.Task); - Assert.Equal(1, retired.DisposeCount); - Assert.Equal(1, current.DisposeCount); } finally { @@ -658,6 +432,7 @@ public ManagerHarness( Registry, new ToolConfig(), credentials, + McpOAuthTestDoubles.UnusedRegistrar(), _flowBroker, new DaemonConfig(), notificationSink, diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerStatusTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerStatusTests.cs index ca1c715a5..4dc933a2a 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerStatusTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerStatusTests.cs @@ -115,11 +115,11 @@ public void PublicErrorsNeverIncludeProviderBodySecrets() } [Fact] - public void WrappedStaleCredentialEpochIsClassifiedAsCredentialPersistence() + public void WrappedRetiredCredentialWriterIsClassifiedAsCredentialPersistence() { var exception = new InvalidOperationException( "SDK token cache callback failed.", - new McpOAuthStaleCredentialEpochException("Pending credentials changed.")); + new McpOAuthRetiredCredentialWriterException("The prior connection no longer owns credentials.")); var error = McpClientManager.CreateSafeOAuthError(exception, "connection initialization"); @@ -127,6 +127,20 @@ public void WrappedStaleCredentialEpochIsClassifiedAsCredentialPersistence() Assert.Equal( "MCP OAuth credential persistence failed. Check daemon logs for details.", error.Error); - Assert.DoesNotContain("Pending credentials changed", error.Error, StringComparison.Ordinal); + Assert.DoesNotContain("prior connection", error.Error, StringComparison.Ordinal); + } + + [Fact] + public void DynamicRegistrationBadRequestIsNotMisreportedAsOuterUnauthorizedChallenge() + { + var exception = new InvalidOperationException( + "Failed to handle unauthorized response with 'Bearer' scheme. " + + "Dynamic client registration failed with status BadRequest: invalid_client_metadata"); + + var error = McpClientManager.CreateSafeOAuthError(exception, "connection initialization"); + + Assert.Equal("dynamic client registration", error.Operation); + Assert.Equal(400, error.Status); + Assert.Contains("HTTP 400 BadRequest", error.Error, StringComparison.Ordinal); } } diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs index ad075c7b7..a86a92441 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs @@ -77,6 +77,7 @@ private async Task CreateAppAsync( toolRegistry, new ToolConfig(), credentialStore, + McpOAuthTestDoubles.UnusedRegistrar(), flowBroker, new DaemonConfig(), NullNotificationSink.Instance, @@ -167,22 +168,6 @@ public async Task Callback_returns_failure_html_for_unknown_state() Assert.Contains("Authorization failed", html); } - [Fact] - public async Task Callback_is_reachable_anonymously() - { - // This test ensures AllowAnonymous is actually wired — the anonymous - // request reaches the handler rather than being rejected with 401. - var ct = TestContext.Current.CancellationToken; - await using var app = await CreateAppAsync(spoofLoopback: false); - var client = app.GetTestClient(); - // Deliberately no Authorization header - - var response = await client.GetAsync("/api/mcp/oauth/callback", ct); - - // Any non-401 proves the endpoint was reached - Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode); - } - // ─── POST /api/mcp/oauth/start/{name} ───────────────────────────────────── [Fact] @@ -218,24 +203,6 @@ public async Task OauthStart_returns_400_when_server_has_no_url() Assert.Contains("no URL", body.GetProperty("error").GetString()); } - // ─── Trivial GETs — authenticated returns 200 with expected shape ────────── - - [Fact] - public async Task GetStatuses_returns_200_with_empty_dictionary_when_no_servers_configured() - { - var ct = TestContext.Current.CancellationToken; - await using var app = await CreateAppAsync(spoofLoopback: true); - var client = app.GetTestClient(); - - var response = await client.GetAsync("/api/mcp/statuses", ct); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(ct); - // No servers registered → empty object - Assert.Equal(JsonValueKind.Object, body.ValueKind); - Assert.Empty(body.EnumerateObject()); - } - [Fact] public async Task GetStatuses_includes_lastErrorAt_for_connection_failure() { @@ -262,49 +229,6 @@ public async Task GetStatuses_includes_lastErrorAt_for_connection_failure() await manager.StopAsync(ct); } - [Fact] - public async Task GetTools_returns_200_with_empty_array_for_unknown_server() - { - var ct = TestContext.Current.CancellationToken; - await using var app = await CreateAppAsync(spoofLoopback: true); - var client = app.GetTestClient(); - - var response = await client.GetAsync("/api/mcp/tools/no-such-server", ct); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(ct); - Assert.Equal(JsonValueKind.Array, body.ValueKind); - Assert.Equal(0, body.GetArrayLength()); - } - - [Fact] - public async Task GetOauthStatus_returns_200_with_status_field_for_known_server() - { - var ct = TestContext.Current.CancellationToken; - await using var app = await CreateAppAsync(spoofLoopback: true); - var client = app.GetTestClient(); - - var response = await client.GetAsync("/api/mcp/oauth/status/any-server", ct); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(ct); - Assert.True(body.TryGetProperty("status", out _)); - } - - [Fact] - public async Task GetOauthStatusByState_returns_200_with_status_field_for_any_state() - { - var ct = TestContext.Current.CancellationToken; - await using var app = await CreateAppAsync(spoofLoopback: true); - var client = app.GetTestClient(); - - var response = await client.GetAsync("/api/mcp/oauth/status-by-state/some-arbitrary-state", ct); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(ct); - Assert.True(body.TryGetProperty("status", out _)); - } - [Fact] public async Task OAuthStatusByNameAndStateIncludeSameSafeStructuredTerminalError() { diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs index 13b64f6ff..bb2189533 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs @@ -3,7 +3,6 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- -using System.Text.Json; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; @@ -25,336 +24,170 @@ public sealed class McpOAuthCredentialStoreTests : IDisposable private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 7, 22, 12, 0, 0, TimeSpan.Zero)); [Fact] - public async Task EveryAdapterReadsSharedPersistedActiveView() + public async Task AuthorizationCandidateStaysLocalUntilPublication() { - var store = CreateStore(); - var context = store.CreateContext(ServerName, Resource, "static-client", false); - var writer = store.CreateTokenCache( - ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); - var reader = store.CreateTokenCache( - ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); + var paths = Paths(); + var store = CreateStore(paths); + var active = await PublishStaticAsync(store, "active-access", "active-refresh"); + var candidate = store.CreateTokenCache(ServerName, Resource, "static-client", true); - await writer.StoreTokensAsync(Tokens("access-one", "refresh-one"), CancellationToken.None); - var loaded = await reader.GetTokensAsync(CancellationToken.None); + await candidate.StoreTokensAsync(Tokens("candidate-access", "candidate-refresh"), CancellationToken.None); - Assert.Equal("access-one", loaded?.AccessToken); - Assert.Equal("refresh-one", loaded?.RefreshToken); + Assert.Equal("candidate-access", (await candidate.GetTokensAsync(CancellationToken.None))?.AccessToken); + Assert.Equal("active-access", store.GetActiveForTests(ServerName)?.AccessToken.Value); + Assert.DoesNotContain("candidate-access", File.ReadAllText(paths.SecretsPath), StringComparison.Ordinal); + store.Discard(candidate); + Assert.Equal("active-access", (await active.GetTokensAsync(CancellationToken.None))?.AccessToken); } [Fact] - public async Task ConcurrentStoresForOneServerKeepMemoryAndDiskCoherent() + public async Task PublishedCandidatePersistsRefreshesAndSurvivesRestart() { var paths = Paths(); var store = CreateStore(paths); - var context = store.CreateContext(ServerName, Resource, "static-client", false); - var adapters = Enumerable.Range(0, 12) - .Select(_ => store.CreateTokenCache( - ServerName, - Resource, - context, - McpOAuthCredentialTarget.Active, - null, - null, - false)) - .ToArray(); - - await Task.WhenAll(adapters.Select((adapter, index) => Task.Run(async () => - await adapter.StoreTokensAsync( - Tokens($"access-{index}", $"refresh-{index}"), - TestContext.Current.CancellationToken)))); - - var memory = store.GetEnvelopeForTests(ServerName).Active; - var restarted = CreateStore(paths).GetEnvelopeForTests(ServerName).Active; - Assert.NotNull(memory); - Assert.Equal(memory!.AccessToken.Value, restarted?.AccessToken.Value); - Assert.Equal(memory.RefreshToken?.Value, restarted?.RefreshToken?.Value); + var candidate = store.CreateTokenCache(ServerName, Resource, "static-client", true); + await candidate.StoreTokensAsync(Tokens("authorized", "first-refresh"), CancellationToken.None); + store.Publish(candidate, CancellationToken.None); + + await candidate.StoreTokensAsync(Tokens("refreshed", "rotated-refresh"), CancellationToken.None); + + var restarted = CreateStore(paths).GetActiveForTests(ServerName); + Assert.Equal("refreshed", restarted?.AccessToken.Value); + Assert.Equal("rotated-refresh", restarted?.RefreshToken?.Value); } [Fact] - public async Task PersistenceFailureDoesNotAdvanceSharedMemory() + public async Task CommitFailureLeavesActiveStateUntouched() { var paths = Paths(); Directory.CreateDirectory(paths.SecretsPath); var store = CreateStore(paths); - var context = store.CreateContext(ServerName, Resource, "static-client", false); - var cache = store.CreateTokenCache( - ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); + var candidate = store.CreateTokenCache(ServerName, Resource, "static-client", true); + await candidate.StoreTokensAsync(Tokens("not-published", null), CancellationToken.None); - await Assert.ThrowsAnyAsync(async () => - await cache.StoreTokensAsync(Tokens("not-published", null), CancellationToken.None)); + Assert.ThrowsAny(() => store.Publish(candidate, CancellationToken.None)); - Assert.Null(await cache.GetTokensAsync(CancellationToken.None)); - Assert.Null(store.GetEnvelopeForTests(ServerName).Active); + Assert.Null(store.GetActiveForTests(ServerName)); + Assert.False(candidate.Published); } [Fact] - public async Task StoreHonorsCancellationBeforeDiskOrMemoryMutation() + public async Task OmittedRefreshTokenRetainsMatchingClientToken() { var store = CreateStore(); - var context = store.CreateContext(ServerName, Resource, "static-client", false); - var cache = store.CreateTokenCache( - ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); + var active = await PublishStaticAsync(store, "old-access", "keep-refresh"); - await Assert.ThrowsAsync(async () => - await cache.StoreTokensAsync(Tokens("cancelled", null), cancellation.Token)); + await active.StoreTokensAsync(Tokens("new-access", null), CancellationToken.None); - Assert.Null(store.GetEnvelopeForTests(ServerName).Active); + Assert.Equal("keep-refresh", store.GetActiveForTests(ServerName)?.RefreshToken?.Value); } [Fact] - public async Task RefreshResponseWithoutRefreshTokenRetainsPriorToken() + public async Task ExplicitAuthorizationSupersedesRefreshThatFinishesFirst() { var store = CreateStore(); - var context = store.CreateContext(ServerName, Resource, "static-client", false); - var cache = store.CreateTokenCache( - ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); - await cache.StoreTokensAsync(Tokens("old-access", "keep-refresh"), CancellationToken.None); + var active = await PublishStaticAsync(store, "old-access", "old-refresh"); + var candidate = store.CreateTokenCache(ServerName, Resource, "static-client", true); + await active.StoreTokensAsync(Tokens("raced-access", "raced-refresh"), CancellationToken.None); + await candidate.StoreTokensAsync(Tokens("authorized-access", null), CancellationToken.None); - await cache.StoreTokensAsync(Tokens("new-access", null), CancellationToken.None); + store.Publish(candidate, CancellationToken.None); - var active = store.GetEnvelopeForTests(ServerName).Active; - Assert.Equal("new-access", active?.AccessToken.Value); - Assert.Equal("keep-refresh", active?.RefreshToken?.Value); + var committed = store.GetActiveForTests(ServerName); + Assert.Equal("authorized-access", committed?.AccessToken.Value); + Assert.Equal("raced-refresh", committed?.RefreshToken?.Value); } [Fact] - public async Task PromotedInteractiveCacheWritesLaterRefreshToActiveAndSurvivesRestart() + public async Task OrdinaryCandidatePersistsRotatedTokenBeforePublication() { var paths = Paths(); var store = CreateStore(paths); - var context = store.CreateContext(ServerName, Resource, "static-client", true); - var cache = store.CreateTokenCache( - ServerName, - Resource, - context, - McpOAuthCredentialTarget.Pending, - "published-flow", - _time.GetUtcNow().AddMinutes(5), - true); - await cache.StoreTokensAsync(Tokens("authorized-access", "authorized-refresh"), CancellationToken.None); - store.PromotePending(ServerName, context, "published-flow", CancellationToken.None); - - await cache.StoreTokensAsync(Tokens("refreshed-after-publication", "rotated-refresh"), CancellationToken.None); - - var active = store.GetEnvelopeForTests(ServerName).Active; - var restarted = CreateStore(paths).GetEnvelopeForTests(ServerName).Active; - Assert.Equal("refreshed-after-publication", active?.AccessToken.Value); - Assert.Equal("rotated-refresh", active?.RefreshToken?.Value); - Assert.Equal(active?.CredentialEpoch, restarted?.CredentialEpoch); - Assert.Equal("refreshed-after-publication", restarted?.AccessToken.Value); - } + var active = await PublishStaticAsync(store, "seed", "seed-refresh"); + var candidate = store.CreateTokenCache(ServerName, Resource, "static-client", false); - [Fact] - public async Task OmittedRefreshTokenNeverCrossesResourceOrPendingFlowBoundary() - { - var store = CreateStore(); - await StoreActiveAsync(store, Resource, "old-resource-access"); - var changedResource = "https://other.example.com/tools"; - var context = store.CreateContext(ServerName, changedResource, "static-client", true); - var cache = store.CreateTokenCache( - ServerName, - changedResource, - context, - McpOAuthCredentialTarget.Pending, - "new-resource-flow", - _time.GetUtcNow().AddMinutes(5), - true); - - await cache.StoreTokensAsync(Tokens("new-resource-access", null), CancellationToken.None); - - Assert.Null(store.GetEnvelopeForTests(ServerName).Pending?.Credentials.RefreshToken); - Assert.Equal("refresh", store.GetEnvelopeForTests(ServerName).Active?.RefreshToken?.Value); - } + await candidate.StoreTokensAsync(Tokens("rotated", "rotated-refresh"), CancellationToken.None); + store.Discard(candidate); - [Fact] - public async Task PendingRefreshRetentionIsLimitedToOwningFlowAndEpoch() - { - var store = CreateStore(); - await StoreActiveAsync(store, Resource, "active-access"); - var owner = store.CreateContext(ServerName, Resource, "static-client", true); - var ownerCache = store.CreateTokenCache( - ServerName, - Resource, - owner, - McpOAuthCredentialTarget.Pending, - "owner-flow", - _time.GetUtcNow().AddMinutes(5), - true); - await ownerCache.StoreTokensAsync(Tokens("pending-one", "flow-refresh"), CancellationToken.None); - await ownerCache.StoreTokensAsync(Tokens("pending-two", null), CancellationToken.None); - - var competing = store.CreateContext(ServerName, Resource, "static-client", true); - var competingCache = store.CreateTokenCache( - ServerName, - Resource, - competing, - McpOAuthCredentialTarget.Pending, - "competing-flow", - _time.GetUtcNow().AddMinutes(5), - true); - - await Assert.ThrowsAsync(async () => - await competingCache.StoreTokensAsync(Tokens("competing", null), CancellationToken.None)); - Assert.Equal("flow-refresh", store.GetEnvelopeForTests(ServerName).Pending?.Credentials.RefreshToken?.Value); - Assert.Equal("owner-flow", store.GetEnvelopeForTests(ServerName).Pending?.FlowId); + Assert.Equal("rotated", CreateStore(paths).GetActiveForTests(ServerName)?.AccessToken.Value); + Assert.Equal("rotated", (await active.GetTokensAsync(CancellationToken.None))?.AccessToken); } [Fact] - public async Task RetiredGenerationCannotOverwriteNewOwnerEpoch() + public async Task RetiredCacheCannotOverwritePublishedCredentials() { var store = CreateStore(); - var retiredContext = store.CreateContext(ServerName, Resource, "static-client", false); - var retiredCache = store.CreateTokenCache( - ServerName, Resource, retiredContext, McpOAuthCredentialTarget.Active, null, null, false); - await retiredCache.StoreTokensAsync(Tokens("generation-one", "refresh-one"), CancellationToken.None); - - var currentContext = store.CreateContext(ServerName, Resource, "static-client", false); - store.CreateTokenCache( - ServerName, Resource, currentContext, McpOAuthCredentialTarget.Active, null, null, false); - store.ClaimActiveEpoch(ServerName, currentContext, CancellationToken.None); - - await Assert.ThrowsAsync(async () => - await retiredCache.StoreTokensAsync(Tokens("stale-overwrite", "stale-refresh"), CancellationToken.None)); - - var active = store.GetEnvelopeForTests(ServerName).Active; - Assert.Equal("generation-one", active?.AccessToken.Value); - Assert.Equal(currentContext.OwnerEpoch, active?.CredentialEpoch); - } + var retired = await PublishStaticAsync(store, "generation-one", "refresh-one"); + var current = store.CreateTokenCache(ServerName, Resource, "static-client", false); + store.Publish(current, CancellationToken.None); - [Fact] - public async Task CrossProcessStaleWriterCannotOverwriteRotatedCredentials() - { - var paths = Paths(); - var seed = CreateStore(paths); - await StoreActiveAsync(seed, Resource, "seed-access"); - var firstProcess = CreateStore(paths); - var staleProcess = CreateStore(paths); - var firstContext = firstProcess.CreateContext(ServerName, Resource, "static-client", false); - var staleContext = staleProcess.CreateContext(ServerName, Resource, "static-client", false); - var firstCache = firstProcess.CreateTokenCache( - ServerName, Resource, firstContext, McpOAuthCredentialTarget.Active, null, null, false); - var staleCache = staleProcess.CreateTokenCache( - ServerName, Resource, staleContext, McpOAuthCredentialTarget.Active, null, null, false); - - await firstCache.StoreTokensAsync(Tokens("rotated-access", "rotated-refresh"), CancellationToken.None); - firstProcess.ClaimActiveEpoch(ServerName, firstContext, CancellationToken.None); - await Assert.ThrowsAsync(async () => - await staleCache.StoreTokensAsync(Tokens("stale-access", "stale-refresh"), CancellationToken.None)); - - var durable = CreateStore(paths).GetEnvelopeForTests(ServerName).Active; - Assert.Equal("rotated-access", durable?.AccessToken.Value); - Assert.Equal("rotated-refresh", durable?.RefreshToken?.Value); - Assert.Equal("rotated-access", staleProcess.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); + await Assert.ThrowsAsync(async () => + await retired.StoreTokensAsync(Tokens("stale", "stale-refresh"), CancellationToken.None)); + + Assert.Equal("generation-one", store.GetActiveForTests(ServerName)?.AccessToken.Value); } [Fact] - public async Task CrossProcessStaleWriterCannotOverwritePromotedCredentials() + public async Task OrdinaryCandidateCannotOverwriteConcurrentRefresh() { - var paths = Paths(); - var seed = CreateStore(paths); - await StoreActiveAsync(seed, Resource, "old-active"); - var staleProcess = CreateStore(paths); - var staleContext = staleProcess.CreateContext(ServerName, Resource, "static-client", false); - var staleCache = staleProcess.CreateTokenCache( - ServerName, Resource, staleContext, McpOAuthCredentialTarget.Active, null, null, false); - var authorizingProcess = CreateStore(paths); - var authContext = authorizingProcess.CreateContext(ServerName, Resource, "static-client", true); - var pending = authorizingProcess.CreateTokenCache( - ServerName, - Resource, - authContext, - McpOAuthCredentialTarget.Pending, - "promoted-flow", - _time.GetUtcNow().AddMinutes(5), - true); - await pending.StoreTokensAsync(Tokens("promoted-access", "promoted-refresh"), CancellationToken.None); - authorizingProcess.PromotePending(ServerName, authContext, "promoted-flow", CancellationToken.None); - - await Assert.ThrowsAsync(async () => - await staleCache.StoreTokensAsync(Tokens("stale-access", "stale-refresh"), CancellationToken.None)); - - var durable = CreateStore(paths).GetEnvelopeForTests(ServerName).Active; - Assert.Equal("promoted-access", durable?.AccessToken.Value); - Assert.Equal("promoted-refresh", durable?.RefreshToken?.Value); + var store = CreateStore(); + var active = await PublishStaticAsync(store, "seed", "seed-refresh"); + var candidate = store.CreateTokenCache(ServerName, Resource, "static-client", false); + await active.StoreTokensAsync(Tokens("latest", "latest-refresh"), CancellationToken.None); + await Assert.ThrowsAsync(async () => + await candidate.StoreTokensAsync( + Tokens("stale-candidate", "stale-refresh"), CancellationToken.None)); + Assert.Equal("latest", store.GetActiveForTests(ServerName)?.AccessToken.Value); } [Fact] - public async Task DynamicClientIdentityAndSecretSurviveRestartWithoutMetadataFile() + public async Task DynamicClientIdentityAndSecretSurviveRestart() { var paths = Paths(); var store = CreateStore(paths); - var context = store.CreateContext(ServerName, Resource, null, false); - context.CaptureDynamicRegistration(new DynamicClientRegistrationResponse - { - ClientId = "dynamic-client", - ClientSecret = "dynamic-secret", - }); - var cache = store.CreateTokenCache( - ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); - await cache.StoreTokensAsync(Tokens("access", "refresh"), CancellationToken.None); + var candidate = store.CreateTokenCache(ServerName, Resource, null, true); + store.AdoptClientIdentity(candidate, new McpOAuthClientIdentity("dynamic-client", "dynamic-secret", DynamicClientRegistration: true)); + await candidate.StoreTokensAsync(Tokens("access", "refresh"), CancellationToken.None); + store.Publish(candidate, CancellationToken.None); var restarted = CreateStore(paths); - var restored = restarted.CreateContext(ServerName, Resource, null, false).SnapshotIdentity(); + var restored = restarted.GetIdentity( + restarted.CreateTokenCache(ServerName, Resource, null, false)); Assert.Equal("dynamic-client", restored.ClientId); Assert.Equal("dynamic-secret", restored.ClientSecret); Assert.True(restored.DynamicClientRegistration); - Assert.False(File.Exists(Path.Combine(paths.ConfigDirectory, "mcp-oauth-metadata.json"))); } [Fact] - public async Task RawSecretsFileNeverContainsOAuthTokensOrDcrClientSecret() + public async Task RawSecretsFileDoesNotContainOAuthSecrets() { var paths = Paths(); var protector = SecretsProtection.CreateProtector(paths); var store = new McpOAuthCredentialStore( - paths, - _time, - protector, - NullLogger.Instance); - var context = store.CreateContext(ServerName, Resource, null, false); - context.CaptureDynamicRegistration(new DynamicClientRegistrationResponse - { - ClientId = "encrypted-client-id", - ClientSecret = "dcr-secret-must-not-leak", - }); - var cache = store.CreateTokenCache( - ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); - - await cache.StoreTokensAsync( - Tokens("access-token-must-not-leak", "refresh-token-must-not-leak"), - CancellationToken.None); + paths, _time, protector, NullLogger.Instance); + var candidate = store.CreateTokenCache(ServerName, Resource, null, true); + store.AdoptClientIdentity(candidate, new McpOAuthClientIdentity("dynamic-client", "client-secret-must-not-leak", DynamicClientRegistration: true)); + await candidate.StoreTokensAsync( + Tokens("access-must-not-leak", "refresh-must-not-leak"), CancellationToken.None); + store.Publish(candidate, CancellationToken.None); var raw = File.ReadAllText(paths.SecretsPath); - Assert.DoesNotContain("access-token-must-not-leak", raw, StringComparison.Ordinal); - Assert.DoesNotContain("refresh-token-must-not-leak", raw, StringComparison.Ordinal); - Assert.DoesNotContain("dcr-secret-must-not-leak", raw, StringComparison.Ordinal); + Assert.DoesNotContain("access-must-not-leak", raw, StringComparison.Ordinal); + Assert.DoesNotContain("refresh-must-not-leak", raw, StringComparison.Ordinal); + Assert.DoesNotContain("client-secret-must-not-leak", raw, StringComparison.Ordinal); Assert.Contains("ENC:", raw, StringComparison.Ordinal); - var restarted = new McpOAuthCredentialStore( - paths, - _time, - protector, - NullLogger.Instance); - var active = restarted.GetEnvelopeForTests(ServerName).Active; - Assert.Equal("access-token-must-not-leak", active?.AccessToken.Value); - Assert.Equal("refresh-token-must-not-leak", active?.RefreshToken?.Value); - Assert.Equal("dcr-secret-must-not-leak", active?.ClientSecret?.Value); } [Fact] - public async Task LegacyMetadataFileRemainsByteForByteUntouched() + public async Task LegacyMetadataFileRemainsUntouched() { var paths = Paths(); var metadataPath = Path.Combine(paths.ConfigDirectory, "mcp-oauth-metadata.json"); - var original = Encoding.UTF8.GetBytes( - "{\n \"legacy\": { \"clientId\": \"old-client\", \"note\": \"leave exactly as-is\" }\n}\n"); + var original = Encoding.UTF8.GetBytes("{\n \"legacy\": true\n}\n"); File.WriteAllBytes(metadataPath, original); - var store = CreateStore(paths); - await StoreActiveAsync(store, Resource, "new-access"); - _ = CreateStore(paths); + await PublishStaticAsync(CreateStore(paths), "access", "refresh"); Assert.Equal(original, File.ReadAllBytes(metadataPath)); } @@ -365,296 +198,229 @@ public async Task LegacyMetadataFileRemainsByteForByteUntouched() public async Task EquivalentResourceSpellingsReturnCredentials(string equivalent) { var store = CreateStore(); - await StoreActiveAsync(store, Resource); + await PublishStaticAsync(store, "access", "refresh"); - var cache = store.CreateTokenCache( - ServerName, - equivalent, - store.CreateContext(ServerName, equivalent, null, false), - McpOAuthCredentialTarget.Active, - null, - null, - false); + var cache = store.CreateTokenCache(ServerName, equivalent, null, false); Assert.Equal("access", (await cache.GetTokensAsync(CancellationToken.None))?.AccessToken); } - [Theory] - [InlineData("http://mcp.example.com/tools?tenant=one")] - [InlineData("https://mcp.example.com/other?tenant=one")] - [InlineData("https://mcp.example.com/tools?tenant=two")] - public async Task ChangedResourceWithholdsAllDynamicCredentialsAndPreservesDisk(string changed) + [Fact] + public async Task ChangedResourceWithholdsTokensAndDynamicIdentity() { var store = CreateStore(); - await StoreDynamicActiveAsync(store, Resource); + await PublishDynamicAsync(store); - var context = store.CreateContext(ServerName, changed, null, false); - var cache = store.CreateTokenCache( - ServerName, changed, context, McpOAuthCredentialTarget.Active, null, null, false); + var cache = store.CreateTokenCache(ServerName, "https://other.example/mcp", null, false); Assert.Null(await cache.GetTokensAsync(CancellationToken.None)); - Assert.Null(context.SnapshotIdentity().ClientId); - Assert.True(store.RequiresAuthorization(ServerName, changed)); - Assert.Equal("access", store.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); + Assert.Null(store.GetIdentity(cache).ClientId); + Assert.True(store.RequiresAuthorization(ServerName, "https://other.example/mcp")); + Assert.Equal("access", store.GetActiveForTests(ServerName)?.AccessToken.Value); } [Fact] public void StaticClientIdRemainsAuthoritativeAfterResourceChange() { var store = CreateStore(); + var cache = store.CreateTokenCache( + ServerName, "https://other.example/mcp", "configured-client", false); - var context = store.CreateContext(ServerName, "https://other.example/mcp", "configured-client", false); - - Assert.Equal("configured-client", context.SnapshotIdentity().ClientId); - Assert.False(context.SnapshotIdentity().DynamicClientRegistration); + Assert.Equal("configured-client", store.GetIdentity(cache).ClientId); + Assert.False(store.GetIdentity(cache).DynamicClientRegistration); } [Fact] - public async Task LegacyUnboundRecordFailsClosedWithoutStampingBinding() + public async Task ExactLegacyResourceMatchMigratesCredentialsBeforeUse() { var paths = Paths(); - File.WriteAllText(paths.SecretsPath, """ - { - "McpOAuthTokens": { - "test-server": { - "AccessToken": "legacy-access", - "RefreshToken": "legacy-refresh", - "ClientId": "legacy-client", - "McpServerUrl": "https://mcp.example.com/tools" - } - } - } - """); + WriteLegacyCredentials(paths, Resource); var store = CreateStore(paths); - var context = store.CreateContext(ServerName, Resource, null, false); - var cache = store.CreateTokenCache( - ServerName, Resource, context, McpOAuthCredentialTarget.Active, null, null, false); - Assert.Null(await cache.GetTokensAsync(CancellationToken.None)); - Assert.Null(context.SnapshotIdentity().ClientId); - Assert.Null(store.GetEnvelopeForTests(ServerName).Active?.ResourceIdentity); - Assert.Contains("legacy-access", File.ReadAllText(paths.SecretsPath), StringComparison.Ordinal); + var cache = store.CreateTokenCache(ServerName, Resource, null, false); + + Assert.Equal("legacy-access", (await cache.GetTokensAsync(CancellationToken.None))?.AccessToken); + Assert.Equal("legacy-client", store.GetIdentity(cache).ClientId); + Assert.True(store.GetIdentity(cache).DynamicClientRegistration); + var migrated = store.GetActiveForTests(ServerName); + Assert.Equal(Resource, migrated?.ResourceIdentity); + + // Retained, not erased: an endpoint corrected later still has something to match + // against, and a rollback to a release that reads this field keeps its binding. + Assert.Equal(Resource, migrated?.McpServerUrl); + var restartedStore = CreateStore(paths); + var restarted = restartedStore.CreateTokenCache(ServerName, Resource, null, false); + Assert.Equal("legacy-client", restartedStore.GetIdentity(restarted).ClientId); } [Fact] - public async Task PendingCredentialsPromoteOnlyForMatchingSuccessfulFlow() + public void LegacyMigrationFailureExposesNoCredentials() { - var store = CreateStore(); - await StoreActiveAsync(store, Resource, "active-access"); - var context = store.CreateContext(ServerName, Resource, "static-client", true); - var pending = store.CreateTokenCache( - ServerName, - Resource, - context, - McpOAuthCredentialTarget.Pending, - "flow-one", - _time.GetUtcNow().AddMinutes(5), - true); - await pending.StoreTokensAsync(Tokens("pending-access", "pending-refresh"), CancellationToken.None); - - Assert.Equal("active-access", store.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); - Assert.Equal("pending-access", store.GetEnvelopeForTests(ServerName).Pending?.Credentials.AccessToken.Value); - - store.PromotePending(ServerName, context, "flow-one", CancellationToken.None); - - Assert.Equal("pending-access", store.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); - Assert.Null(store.GetEnvelopeForTests(ServerName).Pending); + var paths = Paths(); + WriteLegacyCredentials(paths, Resource); + var store = new McpOAuthCredentialStore( + paths, + _time, + new ThrowingProtectSecretsProtector(), + NullLogger.Instance); + + Assert.Throws( + () => store.CreateTokenCache(ServerName, Resource, null, false)); + + Assert.Null(store.GetActiveForTests(ServerName)?.ResourceIdentity); + Assert.Contains("legacy-access", File.ReadAllText(paths.SecretsPath), StringComparison.Ordinal); } [Fact] - public async Task FailedCandidateRemovesOnlyPendingCredentials() + public async Task UnexpiredLegacyCredentialWithoutObtainedAtIsNotReportedExpired() { - var store = CreateStore(); - await StoreActiveAsync(store, Resource, "active-access"); - var context = store.CreateContext(ServerName, Resource, "static-client", true); - var pending = store.CreateTokenCache( - ServerName, - Resource, - context, - McpOAuthCredentialTarget.Pending, - "failed-flow", - _time.GetUtcNow().AddMinutes(5), - true); - await pending.StoreTokensAsync(Tokens("failed-candidate", null), CancellationToken.None); - - store.RemovePending(ServerName, "failed-flow", CancellationToken.None); - - Assert.Equal("active-access", store.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); - Assert.Null(store.GetEnvelopeForTests(ServerName).Pending); + // Legacy records predate ObtainedAt, so it deserializes as 0001-01-01. Measuring + // the lifetime from there yields ~6.4e10 seconds, which saturates the int + // conversion and makes the SDK treat a perfectly good token as long expired — + // sending the operator back through authorization on every upgrade. + var paths = Paths(); + var expiresAt = _time.GetUtcNow().AddDays(30); + WriteLegacyCredentials(paths, Resource, expiresAt, refreshToken: null); + var store = CreateStore(paths); + + var cache = store.CreateTokenCache(ServerName, Resource, null, false); + var tokens = await cache.GetTokensAsync(CancellationToken.None); + + Assert.NotNull(tokens); + Assert.Equal("legacy-access", tokens!.AccessToken); + Assert.NotNull(tokens.ExpiresIn); + Assert.InRange(tokens.ExpiresIn!.Value, 1, (int)TimeSpan.FromDays(31).TotalSeconds); + Assert.Equal(_time.GetUtcNow(), tokens.ObtainedAt); + + // Netclaw's own view has to agree with the SDK's, or status and behavior diverge. + Assert.False(store.RequiresAuthorization(ServerName, Resource)); } - [Fact] - public async Task RestartPrunesAbandonedPendingWithoutChangingActive() + [Theory] + // Trailing slash and path case describe the same endpoint as Resource. + [InlineData("https://mcp.example.com/tools/?tenant=one")] + [InlineData("https://mcp.example.com/TOOLS?tenant=one")] + // Providers commonly publish the RFC 8707 resource indicator as the bare origin + // while the MCP endpoint sits on a path beneath it. + [InlineData("https://mcp.example.com")] + public async Task LegacyResourceEquivalentToConfiguredEndpointMigrates(string legacyResource) { var paths = Paths(); + WriteLegacyCredentials(paths, legacyResource); var store = CreateStore(paths); - await StoreActiveAsync(store, Resource, "active-access"); - var pending = store.CreateTokenCache( - ServerName, - Resource, - store.CreateContext(ServerName, Resource, null, true), - McpOAuthCredentialTarget.Pending, - "abandoned", - _time.GetUtcNow().AddMinutes(5), - true); - await pending.StoreTokensAsync(Tokens("abandoned-access", null), CancellationToken.None); - var restarted = CreateStore(paths); + var cache = store.CreateTokenCache(ServerName, Resource, null, false); - Assert.Equal("active-access", restarted.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); - Assert.Null(restarted.GetEnvelopeForTests(ServerName).Pending); + Assert.Equal("legacy-access", (await cache.GetTokensAsync(CancellationToken.None))?.AccessToken); + Assert.Equal(Resource, store.GetActiveForTests(ServerName)?.ResourceIdentity); } - [Fact] - public async Task RestartKeepsPromotedCredentialsAfterCrashBeforeRuntimePublication() + [Theory] + // A different host, scheme, or port is a different audience — never migrate. + [InlineData("https://other.example.com/tools?tenant=one")] + [InlineData("http://mcp.example.com/tools?tenant=one")] + [InlineData("https://mcp.example.com:8443/tools?tenant=one")] + // Narrowing origin -> path is allowed; widening path -> sibling path is not. + [InlineData("https://mcp.example.com/other?tenant=one")] + // The query can select a tenant, so a different one is a different resource. + [InlineData("https://mcp.example.com/tools?tenant=two")] + [InlineData("https://mcp.example.com/tools")] + public async Task LegacyResourceFromADifferentAudienceFailsClosed(string legacyResource) { var paths = Paths(); + WriteLegacyCredentials(paths, legacyResource); var store = CreateStore(paths); - await StoreActiveAsync(store, Resource, "old-active"); - using var broker = new McpOAuthFlowBroker(_time, CancellationToken.None); - var flow = broker.StartOrJoin(ServerName).Flow; - var redirectOwner = flow.HandleAuthorizationRedirectAsync( - new Uri("https://auth.example/authorize"), - new Uri("http://127.0.0.1:5199/api/mcp/oauth/callback"), - CancellationToken.None); - flow.DeliverCode("commit-code"); - Assert.Equal("commit-code", await redirectOwner); - var context = store.CreateContext(ServerName, Resource, "static-client", true); - var pending = store.CreateTokenCache( - ServerName, - Resource, - context, - McpOAuthCredentialTarget.Pending, - flow.State, - _time.GetUtcNow().AddMinutes(5), - true); - await pending.StoreTokensAsync(Tokens("promoted-before-crash", "new-refresh"), CancellationToken.None); - broker.BeginCommit(flow); - store.PromotePending(ServerName, context, flow.State, CancellationToken.None); - - // Simulate process loss after durable promotion but before runtime publication/Complete. - var restarted = CreateStore(paths); - Assert.Equal("promoted-before-crash", restarted.GetEnvelopeForTests(ServerName).Active?.AccessToken.Value); - Assert.Null(restarted.GetEnvelopeForTests(ServerName).Pending); + var cache = store.CreateTokenCache(ServerName, Resource, null, false); + + Assert.Null(await cache.GetTokensAsync(CancellationToken.None)); + Assert.Null(store.GetIdentity(cache).ClientId); + Assert.Contains("legacy-access", File.ReadAllText(paths.SecretsPath), StringComparison.Ordinal); } [Fact] - public async Task RejectedDynamicIdentityRemainsWithheldAcrossRepeatedAttempts() + public void UnreadableSecretsFileLeavesTheDaemonStartable() { - var store = CreateStore(); - await StoreDynamicActiveAsync(store, Resource); - store.MarkDynamicIdentityRejected(ServerName, Resource, null, CancellationToken.None); + // LoadDurableState runs in a singleton constructor and decrypts the whole secrets + // file. Throwing here would take down every channel, webhook, and schedule over a + // credential that only costs a reauthorization. + var paths = Paths(); + File.WriteAllText(paths.SecretsPath, "{ this is not valid json"); - var next = store.CreateContext(ServerName, Resource, null, true).SnapshotIdentity(); - var later = store.CreateContext(ServerName, Resource, null, true).SnapshotIdentity(); + var store = CreateStore(paths); - Assert.Null(next.ClientId); - Assert.Null(later.ClientId); - Assert.Equal("dynamic-client", store.GetEnvelopeForTests(ServerName).RejectedDynamicClientId); + Assert.Null(store.GetActiveForTests(ServerName)); } [Fact] - public async Task RejectedMarkerNeverDiscardsConfiguredStaticClientId() + public async Task MismatchedLegacyResourceFailsClosedWithoutChangingDisk() { - var store = CreateStore(); - await StoreDynamicActiveAsync(store, Resource); - store.MarkDynamicIdentityRejected(ServerName, Resource, null, CancellationToken.None); - - var context = store.CreateContext(ServerName, Resource, "static-client", true).SnapshotIdentity(); + var paths = Paths(); + WriteLegacyCredentials(paths, "https://mcp.example.com/tools"); + var store = CreateStore(paths); + var cache = store.CreateTokenCache(ServerName, Resource, null, false); - Assert.Equal("static-client", context.ClientId); - Assert.False(context.DynamicClientRegistration); + Assert.Null(await cache.GetTokensAsync(CancellationToken.None)); + Assert.Null(store.GetIdentity(cache).ClientId); + Assert.Contains("legacy-access", File.ReadAllText(paths.SecretsPath), StringComparison.Ordinal); } [Fact] - public async Task FailedFlowAfterReplacementCaptureKeepsRejectedMarkerAndWithholdsOldIdentity() + public async Task ForgettingARejectedClientIdentityKeepsTokensAndForcesReregistration() { - var store = CreateStore(); - await StoreDynamicActiveAsync(store, Resource); - store.MarkDynamicIdentityRejected(ServerName, Resource, null, CancellationToken.None); - var context = store.CreateContext(ServerName, Resource, null, true); - - store.CaptureDynamicRegistration( - ServerName, - context, - new DynamicClientRegistrationResponse - { - ClientId = "replacement-client", - ClientSecret = "replacement-secret", - }, - CancellationToken.None); - var pending = store.CreateTokenCache( - ServerName, - Resource, - context, - McpOAuthCredentialTarget.Pending, - "failed-replacement", - _time.GetUtcNow().AddMinutes(5), - true); - await pending.StoreTokensAsync(Tokens("failed-access", "failed-refresh"), CancellationToken.None); - store.RemovePending(ServerName, "failed-replacement", CancellationToken.None); - - var nextAttempt = store.CreateContext(ServerName, Resource, null, true).SnapshotIdentity(); - Assert.Equal("dynamic-client", store.GetEnvelopeForTests(ServerName).RejectedDynamicClientId); - Assert.Equal("replacement-client", context.SnapshotIdentity().ClientId); - Assert.Null(nextAttempt.ClientId); + var paths = Paths(); + var store = CreateStore(paths); + await PublishDynamicAsync(store); + + store.ForgetClientIdentity(ServerName, Resource, CancellationToken.None); + + // The identity is gone, so the next authorization registers afresh instead of + // reusing an id the server has already refused. + Assert.Null(store.GetIdentity(store.CreateTokenCache(ServerName, Resource, null, true)).ClientId); + var active = store.GetActiveForTests(ServerName); + Assert.Null(active?.ClientId); + Assert.False(active?.DynamicClientRegistration); + + // Tokens are not collateral damage; only the client identity was rejected. + Assert.Equal("access", active?.AccessToken.Value); + Assert.Equal("refresh", active?.RefreshToken?.Value); + + Assert.Null(CreateStore(paths).GetActiveForTests(ServerName)?.ClientId); } [Fact] - public async Task PromotedReplacementClearsRejectedMarker() + public async Task NewDynamicIdentityDoesNotInheritOldRefreshToken() { var store = CreateStore(); - await StoreDynamicActiveAsync(store, Resource); - store.MarkDynamicIdentityRejected(ServerName, Resource, null, CancellationToken.None); - var context = store.CreateContext(ServerName, Resource, null, true); - store.CaptureDynamicRegistration( - ServerName, - context, - new DynamicClientRegistrationResponse - { - ClientId = "replacement-client", - ClientSecret = "replacement-secret", - }, - CancellationToken.None); - var pending = store.CreateTokenCache( - ServerName, - Resource, - context, - McpOAuthCredentialTarget.Pending, - "successful-replacement", - _time.GetUtcNow().AddMinutes(5), - true); - await pending.StoreTokensAsync(Tokens("replacement-access", "replacement-refresh"), CancellationToken.None); - - store.PromotePending(ServerName, context, "successful-replacement", CancellationToken.None); - - Assert.Null(store.GetEnvelopeForTests(ServerName).RejectedDynamicClientId); - Assert.Equal("replacement-client", store.GetEnvelopeForTests(ServerName).Active?.ClientId); + await PublishDynamicAsync(store); + store.ForgetClientIdentity(ServerName, Resource, CancellationToken.None); + var replacement = store.CreateTokenCache(ServerName, Resource, null, true); + store.AdoptClientIdentity(replacement, new McpOAuthClientIdentity("new-client", "new-secret", DynamicClientRegistration: true)); + await replacement.StoreTokensAsync(Tokens("new-access", null), CancellationToken.None); + + store.Publish(replacement, CancellationToken.None); + + Assert.Null(store.GetActiveForTests(ServerName)?.RefreshToken); } - private async Task StoreActiveAsync( + private async Task PublishStaticAsync( McpOAuthCredentialStore store, - string resource, - string accessToken = "access") + string accessToken, + string? refreshToken) { - var context = store.CreateContext(ServerName, resource, "static-client", false); - var cache = store.CreateTokenCache( - ServerName, resource, context, McpOAuthCredentialTarget.Active, null, null, false); - await cache.StoreTokensAsync(Tokens(accessToken, "refresh"), CancellationToken.None); + var cache = store.CreateTokenCache(ServerName, Resource, "static-client", false); + await cache.StoreTokensAsync(Tokens(accessToken, refreshToken), CancellationToken.None); + store.Publish(cache, CancellationToken.None); + return cache; } - private async Task StoreDynamicActiveAsync(McpOAuthCredentialStore store, string resource) + private async Task PublishDynamicAsync(McpOAuthCredentialStore store) { - var context = store.CreateContext(ServerName, resource, null, false); - context.CaptureDynamicRegistration(new DynamicClientRegistrationResponse - { - ClientId = "dynamic-client", - ClientSecret = "dynamic-secret", - }); - var cache = store.CreateTokenCache( - ServerName, resource, context, McpOAuthCredentialTarget.Active, null, null, false); + var cache = store.CreateTokenCache(ServerName, Resource, null, true); + store.AdoptClientIdentity(cache, new McpOAuthClientIdentity("dynamic-client", "dynamic-secret", DynamicClientRegistration: true)); await cache.StoreTokensAsync(Tokens("access", "refresh"), CancellationToken.None); + store.Publish(cache, CancellationToken.None); + return cache; } private TokenContainer Tokens(string accessToken, string? refreshToken) => new() @@ -674,6 +440,42 @@ private NetclawPaths Paths() return paths; } + /// + /// A pre-ResourceIdentity record exactly as older releases wrote it: no + /// ObtainedAt, no TokenType, no provenance flag. Pass + /// to cover the shape that carries an expiry, which is + /// where the missing ObtainedAt becomes load-bearing. + /// + private static void WriteLegacyCredentials( + NetclawPaths paths, + string resource, + DateTimeOffset? expiresAt = null, + string? refreshToken = "legacy-refresh") + { + var expiry = expiresAt is { } value + ? $""" + "ExpiresAt": "{value:O}", + """ + : string.Empty; + var refresh = refreshToken is null + ? string.Empty + : $""" + "RefreshToken": "{refreshToken}", + """; + File.WriteAllText(paths.SecretsPath, $$""" + { + "McpOAuthTokens": { + "test-server": { + "AccessToken": "legacy-access", + {{refresh}}{{expiry}} + "ClientId": "legacy-client", + "McpServerUrl": "{{resource}}" + } + } + } + """); + } + private McpOAuthCredentialStore CreateStore(NetclawPaths? paths = null) => new( paths ?? Paths(), @@ -681,5 +483,12 @@ private McpOAuthCredentialStore CreateStore(NetclawPaths? paths = null) new NullSecretsProtector(), NullLogger.Instance); + private sealed class ThrowingProtectSecretsProtector : ISecretsProtector + { + public string Protect(string plaintext) => throw new InvalidOperationException("secrets write failed"); + + public string Unprotect(string ciphertext) => ciphertext; + } + public void Dispose() => _dir.Dispose(); } diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthTestDoubles.cs b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthTestDoubles.cs new file mode 100644 index 000000000..0b1480bac --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthTestDoubles.cs @@ -0,0 +1,32 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Logging.Abstractions; +using Netclaw.Daemon.Mcp; + +namespace Netclaw.Daemon.Tests.Mcp; + +internal static class McpOAuthTestDoubles +{ + /// + /// A registrar for tests that never take the explicit-authorization path. Any HTTP + /// call fails loudly, so a test that starts registering by accident reports it + /// instead of quietly behaving as if the server had no OAuth metadata. + /// + public static McpOAuthClientRegistrar UnusedRegistrar() + => new(new HttpClient(new UnreachableHandler()), NullLogger.Instance); + + public static McpOAuthClientRegistrar RegistrarFor(HttpClient httpClient) + => new(httpClient, NullLogger.Instance); + + private sealed class UnreachableHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + => throw new InvalidOperationException( + $"This test's MCP OAuth registrar was not expected to issue requests (attempted {request.RequestUri})."); + } +} diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs index 009d460f2..5be326314 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs @@ -37,138 +37,6 @@ public sealed class McpSdkOAuthFlowIntegrationTests { private static readonly Uri RedirectUri = new("http://127.0.0.1:5199/api/mcp/oauth/callback"); - [Fact] - public async Task SdkRedirectDelegateFlow_PerformsDiscoveryDcrPkceExchangeAndStoresTokens() - { - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - timeout.CancelAfter(TimeSpan.FromMinutes(1)); - var ct = timeout.Token; - - await using var server = await FakeOAuthMcpServer.StartAsync(ct); - var tokenCache = new RecordingTokenCache(); - var broker = OperatorPromptBroker.Authorizing(server, "netclaw-broker-state"); - DynamicClientRegistrationResponse? dcrResponse = null; - - var oauth = new ClientOAuthOptions - { - RedirectUri = RedirectUri, - AdditionalAuthorizationParameters = new Dictionary - { - ["state"] = broker.State, - }, - AuthorizationRedirectDelegate = broker.HandleAuthorizationAsync, - TokenCache = tokenCache, - DynamicClientRegistration = new DynamicClientRegistrationOptions - { - ClientName = "netclaw-oauth-spike", - ResponseDelegate = (response, _) => - { - dcrResponse = response; - return Task.CompletedTask; - }, - }, - }; - - await using var client = await CreateClientAsync(server, oauth, ct); - var tools = await client.ListToolsAsync(cancellationToken: ct); - - Assert.Contains(tools, tool => tool.Name == "oauth_probe"); - - Assert.Equal(1, server.ProtectedResourceDiscoveryCount); - Assert.Equal(1, server.AuthorizationServerDiscoveryCount); - Assert.Equal(1, server.UnauthorizedMcpChallengeCount); - Assert.True(server.AuthorizedMcpRequestCount >= 1); - - var registration = Assert.Single(server.DynamicClientRegistrations); - Assert.Contains(RedirectUri.ToString(), registration.RedirectUris); - Assert.Equal("client_secret_post", registration.RequestedTokenEndpointAuthMethod); - Assert.Equal("fake.read fake.write", registration.Scope); - Assert.NotNull(dcrResponse); - Assert.Equal(registration.ClientId, dcrResponse!.ClientId); - Assert.Equal(registration.ClientSecret, dcrResponse.ClientSecret); - - var authorization = Assert.Single(server.AuthorizationRequests); - Assert.Equal(registration.ClientId, authorization.ClientId); - Assert.Equal(RedirectUri.ToString(), authorization.RedirectUri); - Assert.Equal(server.McpEndpoint.ToString(), authorization.Resource); - Assert.Equal("fake.read fake.write", authorization.Scope); - Assert.Equal(broker.State, authorization.State); - Assert.Equal("S256", authorization.CodeChallengeMethod); - Assert.False(string.IsNullOrWhiteSpace(authorization.CodeChallenge)); - - Assert.Equal(1, broker.DelegateInvocationCount); - Assert.Equal(1, broker.OperatorPromptCount); - Assert.Equal(broker.State, broker.ReturnedState); - Assert.Equal(authorization.Code, broker.ReturnedCode); - Assert.NotNull(broker.DeliveredAuthorizationUrl); - Assert.Equal(broker.State, GetQueryValue(broker.DeliveredAuthorizationUrl!, "state")); - Assert.Equal(authorization.CodeChallenge, GetQueryValue(broker.DeliveredAuthorizationUrl!, "code_challenge")); - Assert.Equal("S256", GetQueryValue(broker.DeliveredAuthorizationUrl!, "code_challenge_method")); - - var tokenRequest = Assert.Single(server.TokenRequests); - Assert.Equal(authorization.Code, tokenRequest.Code); - Assert.Equal(registration.ClientId, tokenRequest.ClientId); - Assert.Equal(registration.ClientSecret, tokenRequest.ClientSecret); - Assert.Equal(RedirectUri.ToString(), tokenRequest.RedirectUri); - Assert.Equal(server.McpEndpoint.ToString(), tokenRequest.Resource); - Assert.True(tokenRequest.PkceVerified); - - var storedTokens = tokenCache.StoredTokens; - var stored = Assert.Single(storedTokens); - Assert.Equal(tokenRequest.IssuedAccessToken, stored.AccessToken); - Assert.Equal(tokenRequest.IssuedRefreshToken, stored.RefreshToken); - Assert.Equal("Bearer", stored.TokenType); - Assert.Equal("fake.read fake.write", stored.Scope); - Assert.Equal(3600, stored.ExpiresIn); - } - - [Fact] - public async Task ProductionFlowFollowerFailsClassifiedWhileOwnerCompletesWithOneCodeExchange() - { - var ct = TestContext.Current.CancellationToken; - await using var server = await FakeOAuthMcpServer.StartAsync(ct); - using var flowBroker = new McpOAuthFlowBroker(TimeProvider.System, CancellationToken.None); - var flow = flowBroker.StartOrJoin(new McpServerName("production-flow")).Flow; - var tokenCache = new RecordingTokenCache(); - var oauth = new ClientOAuthOptions - { - RedirectUri = RedirectUri, - AdditionalAuthorizationParameters = new Dictionary - { - ["state"] = flow.State, - }, - AuthorizationRedirectDelegate = flow.HandleAuthorizationRedirectAsync, - TokenCache = tokenCache, - DynamicClientRegistration = new DynamicClientRegistrationOptions - { - ClientName = "netclaw-production-flow-test", - }, - }; - - var owner = CreateClientAsync(server, oauth, ct); - var authorizationUrl = await flow.WaitForAuthorizationUrlAsync(ct); - var follower = CreateClientAsync(server, oauth, ct); - - var followerError = await CaptureExceptionAsync(follower); - Assert.True(ContainsException(followerError)); - Assert.False(owner.IsCompleted); - Assert.Empty(server.TokenRequests); - - var authorization = await server.AuthorizeAsync(authorizationUrl, RedirectUri, ct); - flow.DeliverCode(authorization.Code); - await using var ownerClient = await owner; - var tools = await ownerClient.ListToolsAsync(cancellationToken: ct); - flowBroker.BeginCommit(flow); - flowBroker.Complete(flow); - - Assert.Contains(tools, tool => tool.Name == "oauth_probe"); - var tokenRequest = Assert.Single(server.TokenRequests); - Assert.Equal(authorization.Code, tokenRequest.Code); - Assert.True(tokenRequest.PkceVerified); - Assert.Single(tokenCache.StoredTokens); - Assert.Equal(McpOAuthFlowStatus.Completed, flowBroker.GetStatusByState(flow.State).Status); - } - [Fact] public async Task ManagerExplicitAuthorization_PublishesOnlyAfterSdkExchangeAndToolListing() { @@ -192,11 +60,10 @@ public async Task ManagerExplicitAuthorization_PublishesOnlyAfterSdkExchangeAndT $"{terminal.Error?.Error} :: {harness.Manager.GetServerStatuses()[harness.ServerName].ErrorMessage} :: {harness.Logger.LastException}"); Assert.Equal(McpConnectionState.Connected, harness.Manager.GetServerStatuses()[harness.ServerName].State); Assert.Contains("oauth_probe", harness.Manager.GetToolNames(harness.ServerName)); - var active = harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active; + var active = harness.Credentials.GetActiveForTests(harness.ServerName); Assert.NotNull(active); Assert.Equal("client-1", active!.ClientId); Assert.Equal("secret-client-1", active.ClientSecret?.Value); - Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending); Assert.Equal("http://127.0.0.1:7331/api/mcp/oauth/callback", server.DynamicClientRegistrations.Single().RedirectUris.Single()); await harness.Runtime.LastHttpOptions!.OAuth!.TokenCache!.StoreTokensAsync( @@ -211,8 +78,7 @@ public async Task ManagerExplicitAuthorization_PublishesOnlyAfterSdkExchangeAndT ct); Assert.Equal( "refresh-after-publication", - harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active?.AccessToken.Value); - Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending); + harness.Credentials.GetActiveForTests(harness.ServerName)?.AccessToken.Value); } [Fact] @@ -252,8 +118,6 @@ public async Task FailedExchangeAfterCodeDeliveryRequiresNewStatePkceVerifierAnd await using var harness = CreateManagerHarness(server, directory.Path); server.FailNextTokenExchange(); var firstStart = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); - var firstOperation = Assert.IsAssignableFrom( - harness.Manager.GetInteractiveAuthorizationTask(harness.ServerName)); var firstAuthorization = await server.AuthorizeAsync( new Uri(firstStart.AuthorizationUrl), RedirectUri, @@ -263,7 +127,6 @@ public async Task FailedExchangeAfterCodeDeliveryRequiresNewStatePkceVerifierAnd Assert.Equal( McpOAuthFlowStatus.Failed, (await firstFlow.WaitForTerminalAsync(ct)).Status); - await firstOperation.WaitAsync(ct); var secondStart = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); var secondAuthorization = await server.AuthorizeAsync( @@ -309,7 +172,7 @@ public async Task ReconnectWhileAuthorizationPendingDoesNotCreateCompetingCandid } [Fact] - public async Task RuntimeFlowExpiryRemovesDurablePendingCredentialsWithoutRestart() + public async Task RuntimeFlowExpiryDiscardsCandidateCredentials() { var ct = TestContext.Current.CancellationToken; var time = new FakeTimeProvider(new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)); @@ -319,47 +182,18 @@ public async Task RuntimeFlowExpiryRemovesDurablePendingCredentialsWithoutRestar var barrier = new InitializationBarrier(); harness.Runtime.InitializationBarrier = barrier; var started = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); - var operation = Assert.IsAssignableFrom( - harness.Manager.GetInteractiveAuthorizationTask(harness.ServerName)); var authorization = await server.AuthorizeAsync(new Uri(started.AuthorizationUrl), RedirectUri, ct); var flow = harness.Broker.GetForCallback(started.State); flow.DeliverCode(authorization.Code); await barrier.Reached.Task.WaitAsync(ct); - Assert.Equal(started.State, harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending?.FlowId); + Assert.Null(harness.Credentials.GetActiveForTests(harness.ServerName)); time.Advance(McpOAuthFlowBroker.FlowLifetime); - await operation.WaitAsync(ct); - Assert.Equal(McpOAuthFlowStatus.Failed, harness.Broker.GetStatusByState(started.State).Status); - Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending); + Assert.Null(harness.Credentials.GetActiveForTests(harness.ServerName)); barrier.Release.TrySetResult(true); } - [Fact] - public async Task FailedToolListingRemovesPendingAndPreservesActiveCredentials() - { - var ct = TestContext.Current.CancellationToken; - await using var server = await FakeOAuthMcpServer.StartAsync(ct); - using var directory = new DisposableTempDir(); - await using (var first = CreateManagerHarness(server, directory.Path)) - { - await CompleteManagerAuthorizationAsync(server, first, ct); - } - - await using var failing = CreateManagerHarness(server, directory.Path, failToolListing: true); - var oldAccess = failing.Credentials.GetEnvelopeForTests(failing.ServerName).Active!.AccessToken.Value; - var started = await failing.Manager.StartAuthorizationAsync(failing.ServerName, ct); - var authorization = await server.AuthorizeAsync(new Uri(started.AuthorizationUrl), RedirectUri, ct); - var flow = failing.Broker.GetForCallback(started.State); - flow.DeliverCode(authorization.Code); - - var terminal = await flow.WaitForTerminalAsync(ct); - - Assert.Equal(McpOAuthFlowStatus.Failed, terminal.Status); - Assert.Equal(oldAccess, failing.Credentials.GetEnvelopeForTests(failing.ServerName).Active?.AccessToken.Value); - Assert.Null(failing.Credentials.GetEnvelopeForTests(failing.ServerName).Pending); - } - [Fact] public async Task FailedExplicitReplacementPreservesSameLiveConnectionAndActiveCredentials() { @@ -369,7 +203,7 @@ public async Task FailedExplicitReplacementPreservesSameLiveConnectionAndActiveC await using var harness = CreateManagerHarness(server, directory.Path); await CompleteManagerAuthorizationAsync(server, harness, ct); var published = Assert.IsType(harness.Manager.GetSnapshot(harness.ServerName)); - var active = harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active!; + var active = harness.Credentials.GetActiveForTests(harness.ServerName)!; harness.Runtime.FailNextToolListing(); var started = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); @@ -379,61 +213,17 @@ public async Task FailedExplicitReplacementPreservesSameLiveConnectionAndActiveC var terminal = await flow.WaitForTerminalAsync(ct); var retained = Assert.IsType(harness.Manager.GetSnapshot(harness.ServerName)); - var retainedCredentials = harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active; + var retainedCredentials = harness.Credentials.GetActiveForTests(harness.ServerName); Assert.Equal(McpOAuthFlowStatus.Failed, terminal.Status); Assert.Same(published.Client, retained.Client); Assert.Equal(published.Generation, retained.Generation); Assert.Equal(published.ToolFunctions.Keys, retained.ToolFunctions.Keys); Assert.Equal(active.AccessToken.Value, retainedCredentials?.AccessToken.Value); Assert.Equal(active.RefreshToken?.Value, retainedCredentials?.RefreshToken?.Value); - Assert.Equal(active.CredentialEpoch, retainedCredentials?.CredentialEpoch); - Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending); - } - - [Fact] - public async Task PendingStoreConflictReportsCredentialPersistenceTerminalError() - { - var ct = TestContext.Current.CancellationToken; - await using var server = await FakeOAuthMcpServer.StartAsync(ct); - using var directory = new DisposableTempDir(); - await using var harness = CreateManagerHarness(server, directory.Path); - var started = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); - var competingContext = harness.Credentials.CreateContext( - harness.ServerName, - server.McpEndpoint.ToString(), - null, - true); - var competingCache = harness.Credentials.CreateTokenCache( - harness.ServerName, - server.McpEndpoint.ToString(), - competingContext, - McpOAuthCredentialTarget.Pending, - "competing-flow", - TimeProvider.System.GetUtcNow().AddMinutes(5), - true); - await competingCache.StoreTokensAsync( - new TokenContainer - { - AccessToken = "competing-access", - RefreshToken = "competing-refresh", - TokenType = "Bearer", - ObtainedAt = TimeProvider.System.GetUtcNow(), - ExpiresIn = 3600, - }, - ct); - var authorization = await server.AuthorizeAsync(new Uri(started.AuthorizationUrl), RedirectUri, ct); - var flow = harness.Broker.GetForCallback(started.State); - flow.DeliverCode(authorization.Code); - - var terminal = await flow.WaitForTerminalAsync(ct); - - Assert.Equal(McpOAuthFlowStatus.Failed, terminal.Status); - Assert.Equal("credential persistence", terminal.Error?.Operation); - Assert.DoesNotContain("competing-flow", terminal.Error?.Error, StringComparison.Ordinal); } [Fact] - public async Task PromotionEpochConflictReportsCredentialPersistenceTerminalError() + public async Task ExplicitAuthorizationSupersedesActiveRefreshThatFinishesFirst() { var ct = TestContext.Current.CancellationToken; await using var server = await FakeOAuthMcpServer.StartAsync(ct); @@ -468,12 +258,10 @@ await publishedCache.StoreTokensAsync( var terminal = await flow.WaitForTerminalAsync(ct); - Assert.Equal(McpOAuthFlowStatus.Failed, terminal.Status); - Assert.Equal("credential persistence", terminal.Error?.Operation); - Assert.Equal( + Assert.Equal(McpOAuthFlowStatus.Completed, terminal.Status); + Assert.NotEqual( "active-rotated-during-flow", - harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active?.AccessToken.Value); - Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Pending); + harness.Credentials.GetActiveForTests(harness.ServerName)?.AccessToken.Value); } [Fact] @@ -498,7 +286,7 @@ public async Task RestartUsesStoredDynamicIdentityWithoutMetadataOrReregistratio } [Fact] - public async Task InvalidClientMarkerSurvivesFailedReplacementAndForcesAnotherFreshDcr() + public async Task RejectedClientIdentityIsDiscardedSoTheNextAuthorizationRegistersAfresh() { var ct = TestContext.Current.CancellationToken; await using var server = await FakeOAuthMcpServer.StartAsync(ct); @@ -508,44 +296,31 @@ public async Task InvalidClientMarkerSurvivesFailedReplacementAndForcesAnotherFr await CompleteManagerAuthorizationAsync(server, first, ct); } + // The provider drops the registration behind our back. server.RejectClient("client-1"); await using var harness = CreateManagerHarness(server, directory.Path); var rejectedStart = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); var rejectedAuthorization = await server.AuthorizeAsync(new Uri(rejectedStart.AuthorizationUrl), RedirectUri, ct); var rejectedFlow = harness.Broker.GetForCallback(rejectedStart.State); rejectedFlow.DeliverCode(rejectedAuthorization.Code); - var rejectedTerminal = await rejectedFlow.WaitForTerminalAsync(ct); - Assert.Equal(McpOAuthFlowStatus.Failed, rejectedTerminal.Status); - Assert.Equal( - "client-1", - harness.Credentials.GetEnvelopeForTests(harness.ServerName).RejectedDynamicClientId); + Assert.Equal(McpOAuthFlowStatus.Failed, (await rejectedFlow.WaitForTerminalAsync(ct)).Status); - var replacementStart = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); + // The dead identity is discarded rather than marked, so recovery needs no extra + // persisted state and survives a restart the same way. + Assert.Null(harness.Credentials.GetActiveForTests(harness.ServerName)?.ClientId); + var replacementStart = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); Assert.Contains("client_id=client-2", replacementStart.AuthorizationUrl, StringComparison.Ordinal); Assert.Equal(2, server.DynamicClientRegistrations.Count); - Assert.Equal( - "client-1", - harness.Credentials.GetEnvelopeForTests(harness.ServerName).RejectedDynamicClientId); - server.RejectClient("client-2"); var replacementAuthorization = await server.AuthorizeAsync( - new Uri(replacementStart.AuthorizationUrl), - RedirectUri, - ct); + new Uri(replacementStart.AuthorizationUrl), RedirectUri, ct); var replacementFlow = harness.Broker.GetForCallback(replacementStart.State); replacementFlow.DeliverCode(replacementAuthorization.Code); - Assert.Equal( - McpOAuthFlowStatus.Failed, - (await replacementFlow.WaitForTerminalAsync(ct)).Status); - Assert.Equal( - "client-1", - harness.Credentials.GetEnvelopeForTests(harness.ServerName).RejectedDynamicClientId); - var thirdStart = await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct); - Assert.Contains("client_id=client-3", thirdStart.AuthorizationUrl, StringComparison.Ordinal); - Assert.Equal(3, server.DynamicClientRegistrations.Count); + Assert.Equal(McpOAuthFlowStatus.Completed, (await replacementFlow.WaitForTerminalAsync(ct)).Status); + Assert.Equal("client-2", harness.Credentials.GetActiveForTests(harness.ServerName)?.ClientId); } [Fact] @@ -570,7 +345,40 @@ public async Task RepointedProfileWithholdsOldCredentialsAndReportsAwaitingAuth( repointed.Manager.GetServerStatuses()[repointed.ServerName].State); Assert.Equal( server.McpEndpoint.ToString(), - repointed.Credentials.GetEnvelopeForTests(repointed.ServerName).Active?.ResourceIdentity); + repointed.Credentials.GetActiveForTests(repointed.ServerName)?.ResourceIdentity); + } + + [Fact] + public async Task ExactLegacyResourceMatchReconnectsWithoutReregistration() + { + var ct = TestContext.Current.CancellationToken; + await using var server = await FakeOAuthMcpServer.StartAsync(ct); + server.AcceptBearer("legacy-access"); + using var directory = new DisposableTempDir(); + var paths = new NetclawPaths(directory.Path); + paths.EnsureDirectoriesExist(); + File.WriteAllText(paths.SecretsPath, $$""" + { + "McpOAuthTokens": { + "fake-oauth": { + "AccessToken": "legacy-access", + "RefreshToken": "legacy-refresh", + "ClientId": "legacy-client", + "McpServerUrl": "{{server.McpEndpoint}}" + } + } + } + """); + await using var harness = CreateManagerHarness(server, directory.Path); + + await harness.Manager.StartAsync(ct); + + Assert.Equal(McpConnectionState.Connected, harness.Manager.GetServerStatuses()[harness.ServerName].State); + Assert.Equal("legacy-client", harness.Runtime.LastHttpOptions?.OAuth?.ClientId); + Assert.Empty(server.DynamicClientRegistrations); + Assert.Equal( + McpOAuthCredentialStore.CanonicalizeResource(server.McpEndpoint.ToString()), + harness.Credentials.GetActiveForTests(harness.ServerName)?.ResourceIdentity); } [Fact] @@ -599,7 +407,7 @@ public async Task LegacyUnboundCredentialsReportAwaitingAuthWithoutBeingStamped( Assert.Equal( McpConnectionState.AwaitingAuth, harness.Manager.GetServerStatuses()[harness.ServerName].State); - Assert.Null(harness.Credentials.GetEnvelopeForTests(harness.ServerName).Active?.ResourceIdentity); + Assert.Null(harness.Credentials.GetActiveForTests(harness.ServerName)?.ResourceIdentity); Assert.Contains("legacy-access", File.ReadAllText(paths.SecretsPath), StringComparison.Ordinal); } @@ -616,11 +424,9 @@ public async Task ExpiredAccessWithoutRefreshReportsAwaitingAuthRemedy() { "McpOAuthTokens": { "fake-oauth": { - "Active": { - "AccessToken": "expired-access", - "ExpiresAt": "2020-01-01T00:00:00+00:00", - "ResourceIdentity": "{{canonical}}" - } + "AccessToken": "expired-access", + "ExpiresAt": "2020-01-01T00:00:00+00:00", + "ResourceIdentity": "{{canonical}}" } } } @@ -737,23 +543,6 @@ public async Task ChallengedStaticAuthorizationIsNotReplacedBySdkOAuth() Assert.Empty(server.DynamicClientRegistrations); } - [Fact] - public async Task BodylessDcr403ProducesStructuredTerminalError() - { - var ct = TestContext.Current.CancellationToken; - await using var server = await FakeOAuthMcpServer.StartAsync(ct, rejectDcrWithoutBody: true); - using var directory = new DisposableTempDir(); - await using var harness = CreateManagerHarness(server, directory.Path); - - var error = await Assert.ThrowsAsync(async () => - await harness.Manager.StartAuthorizationAsync(harness.ServerName, ct)); - - Assert.Equal("dynamic client registration", error.Error.Operation); - Assert.Contains("403", error.Error.Error, StringComparison.Ordinal); - Assert.DoesNotContain("token", error.Error.Error, StringComparison.OrdinalIgnoreCase); - Assert.Contains("403", harness.Logger.LastMessage, StringComparison.OrdinalIgnoreCase); - } - [Fact] public async Task ProviderBodySecretsStayInDaemonLogAndOutOfPublicOAuthErrors() { @@ -822,6 +611,7 @@ private static ManagerOAuthHarness CreateManagerHarness( new ToolRegistry(), new ToolConfig(), credentials, + McpOAuthTestDoubles.RegistrarFor(server.CreateHttpClient()), broker, new DaemonConfig { Port = port }, NullNotificationSink.Instance, @@ -864,8 +654,6 @@ private sealed class RecordingLogger : ILogger { public Exception? LastException { get; private set; } - public string? LastMessage { get; private set; } - public List Exceptions { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull => null; @@ -879,7 +667,6 @@ public void Log( Exception? exception, Func formatter) { - LastMessage = formatter(state, exception); if (exception is not null) { LastException = exception; @@ -952,66 +739,6 @@ private sealed class InitializationBarrier new(TaskCreationOptions.RunContinuationsAsynchronously); } - private static async Task CreateClientAsync( - FakeOAuthMcpServer server, - ClientOAuthOptions oauth, - CancellationToken ct) - { - var transport = new HttpClientTransport(new HttpClientTransportOptions - { - Endpoint = server.McpEndpoint, - Name = "fake-oauth-mcp", - TransportMode = HttpTransportMode.StreamableHttp, - OAuth = oauth, - }, server.CreateHttpClient(), ownsHttpClient: true); - - try - { - return await McpClient.CreateAsync(transport, new McpClientOptions - { - ClientInfo = new Implementation - { - Name = "netclaw-oauth-spike", - Version = "1.0.0", - }, - }, cancellationToken: ct); - } - catch - { - await transport.DisposeAsync(); - throw; - } - } - - private static async Task CaptureExceptionAsync(Task task) - { - try - { - await task; - return null; - } - catch (Exception ex) - { - return ex; - } - } - - private static bool ContainsException(Exception? exception) - where TException : Exception - { - while (exception is not null) - { - if (exception is TException) - return true; - exception = exception.InnerException; - } - - return false; - } - - private static string? GetQueryValue(Uri uri, string name) - => ParseQuery(uri.Query).GetValueOrDefault(name); - private static IReadOnlyDictionary ParseQuery(string query) { var result = new Dictionary(StringComparer.Ordinal); @@ -1026,120 +753,6 @@ private static IReadOnlyDictionary ParseQuery(string query) return result; } - private sealed class OperatorPromptBroker - { - private readonly FakeOAuthMcpServer _server; - private readonly object _sync = new(); - private Task? _authorizationTask; - private int _delegateInvocationCount; - private int _operatorPromptCount; - - private OperatorPromptBroker(FakeOAuthMcpServer server, string state) - { - _server = server; - State = state; - } - - public string State { get; } - - public int DelegateInvocationCount => Volatile.Read(ref _delegateInvocationCount); - - public int OperatorPromptCount => Volatile.Read(ref _operatorPromptCount); - - public Uri? DeliveredAuthorizationUrl { get; private set; } - - public string? ReturnedCode { get; private set; } - - public string? ReturnedState { get; private set; } - - public static OperatorPromptBroker Authorizing(FakeOAuthMcpServer server, string state) - => new(server, state); - - public async Task HandleAuthorizationAsync( - Uri authorizationUri, - Uri redirectUri, - CancellationToken cancellationToken) - { - Interlocked.Increment(ref _delegateInvocationCount); - - Task authorizationTask; - lock (_sync) - { - if (_authorizationTask is null) - { - PublishPrompt(authorizationUri); - _authorizationTask = _server.AuthorizeAsync(authorizationUri, redirectUri, cancellationToken); - } - - authorizationTask = _authorizationTask; - } - - var result = await authorizationTask.WaitAsync(cancellationToken); - ReturnedCode = result.Code; - ReturnedState = result.State; - return result.Code; - } - - private void PublishPrompt(Uri authorizationUri) - { - lock (_sync) - { - if (DeliveredAuthorizationUrl is not null) - return; - - DeliveredAuthorizationUrl = authorizationUri; - Interlocked.Increment(ref _operatorPromptCount); - } - } - } - - private sealed class RecordingTokenCache : ITokenCache - { - private readonly List _storedTokens = []; - private readonly object _sync = new(); - private TokenContainer? _current; - - public IReadOnlyList StoredTokens - { - get - { - lock (_sync) - return _storedTokens.Select(Clone).ToList(); - } - } - - public ValueTask GetTokensAsync(CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - lock (_sync) - return new ValueTask(_current is null ? null : Clone(_current)); - } - - public ValueTask StoreTokensAsync(TokenContainer tokens, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - var clone = Clone(tokens); - lock (_sync) - { - _current = clone; - _storedTokens.Add(Clone(tokens)); - } - - return default; - } - - private static TokenContainer Clone(TokenContainer tokens) - => new() - { - AccessToken = tokens.AccessToken, - RefreshToken = tokens.RefreshToken, - ExpiresIn = tokens.ExpiresIn, - ObtainedAt = tokens.ObtainedAt, - Scope = tokens.Scope, - TokenType = tokens.TokenType, - }; - } - private sealed class FakeOAuthMcpServer : IAsyncDisposable { private readonly WebApplication _app; @@ -1173,6 +786,8 @@ public IReadOnlyList AuthorizationRequests public void RejectClient(string clientId) => _state.RejectClient(clientId); + public void AcceptBearer(string token) => _state.AcceptBearer(token); + public void FailNextTokenExchange() => _state.FailNextTokenExchange(); public static async Task StartAsync( diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpSmokeHarness.cs b/src/Netclaw.Daemon.Tests/Mcp/McpSmokeHarness.cs index b627d4421..cab29ce03 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpSmokeHarness.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpSmokeHarness.cs @@ -48,6 +48,7 @@ public static McpSmokeHarness Create( registry, new ToolConfig(), credentials, + McpOAuthTestDoubles.UnusedRegistrar(), flowBroker, new DaemonConfig(), NullNotificationSink.Instance, diff --git a/src/Netclaw.Daemon.Tests/Security/BootstrapDeviceSeederTests.cs b/src/Netclaw.Daemon.Tests/Security/BootstrapDeviceSeederTests.cs deleted file mode 100644 index b68dec90c..000000000 --- a/src/Netclaw.Daemon.Tests/Security/BootstrapDeviceSeederTests.cs +++ /dev/null @@ -1,174 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Time.Testing; -using Netclaw.Configuration; -using Netclaw.Configuration.Secrets; -using Netclaw.Daemon.Security; -using Netclaw.Tests.Utilities; -using Xunit; - -namespace Netclaw.Daemon.Tests.Security; - -public sealed class BootstrapDeviceSeederTests : IDisposable -{ - private readonly DisposableTempDir _dir = new(); - private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)); - private readonly NetclawPaths _paths; - private readonly DeviceRegistry _deviceRegistry; - private readonly BootstrapStateStore _bootstrapStateStore; - private readonly BootstrapDeviceSeeder _sut; - - public BootstrapDeviceSeederTests() - { - _paths = new NetclawPaths(_dir.Path); - _paths.EnsureDirectoriesExist(); - _deviceRegistry = new DeviceRegistry(_paths, _time, NullLogger.Instance); - _bootstrapStateStore = new BootstrapStateStore(_paths); - _sut = new BootstrapDeviceSeeder( - _paths, - _deviceRegistry, - _bootstrapStateStore, - _time, - NullLogger.Instance, - SecretsProtection.CreateProtector(_paths)); - } - - public void Dispose() => _dir.Dispose(); - - [Fact] - public async Task EnsureSeededAsync_seeds_device_and_local_token_for_non_local_mode() - { - var seeded = await _sut.EnsureSeededAsync( - new DaemonConfig { ExposureMode = ExposureMode.ReverseProxy }, - TestContext.Current.CancellationToken); - - Assert.True(seeded); - var devices = await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken); - var device = Assert.Single(devices); - Assert.Contains("bootstrap", device.Name, StringComparison.OrdinalIgnoreCase); - Assert.True(device.IsBootstrapDevice); - Assert.True(File.Exists(_paths.SecretsPath)); - var secretsText = File.ReadAllText(_paths.SecretsPath); - Assert.Contains("DeviceToken", secretsText, StringComparison.Ordinal); - } - - [Fact] - public async Task EnsureSeededAsync_skips_when_device_already_exists() - { - await _deviceRegistry.AddAsync( - DeviceTestHelpers.MakeDevice("existing", _time.GetUtcNow()).Device, - TestContext.Current.CancellationToken); - - var seeded = await _sut.EnsureSeededAsync( - new DaemonConfig { ExposureMode = ExposureMode.TailscaleServe }, - TestContext.Current.CancellationToken); - - Assert.False(seeded); - var devices = await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken); - Assert.Single(devices); - } - - [Fact] - public async Task EnsureSeededAsync_skips_after_completion_marker_written() - { - _sut.MarkCompleted(); - - var seeded = await _sut.EnsureSeededAsync( - new DaemonConfig { ExposureMode = ExposureMode.ReverseProxy }, - TestContext.Current.CancellationToken); - - Assert.False(seeded); - var devices = await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken); - Assert.Empty(devices); - } - - [Fact] - public async Task EnsureSeededAsync_skips_for_local_mode() - { - var seeded = await _sut.EnsureSeededAsync( - new DaemonConfig { ExposureMode = ExposureMode.Local }, - TestContext.Current.CancellationToken); - - Assert.False(seeded); - Assert.Empty(await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task EnsureSeededAsync_rolls_back_device_when_token_transaction_does_not_commit() - { - File.WriteAllText(_paths.SecretsPath, """{"Other":"ENC:trigger"}"""); - var seeder = new BootstrapDeviceSeeder( - _paths, - _deviceRegistry, - _bootstrapStateStore, - _time, - NullLogger.Instance, - new DeviceTokenAppearsDuringPrecheckProtector(_paths)); - - var seeded = await seeder.EnsureSeededAsync( - new DaemonConfig { ExposureMode = ExposureMode.ReverseProxy }, - TestContext.Current.CancellationToken); - - Assert.False(seeded); - Assert.Empty(await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken)); - - File.Delete(_paths.SecretsPath); - var retrySeeded = await _sut.EnsureSeededAsync( - new DaemonConfig { ExposureMode = ExposureMode.ReverseProxy }, - TestContext.Current.CancellationToken); - - Assert.True(retrySeeded); - Assert.Single(await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task EnsureSeededAsync_rolls_back_device_when_token_persistence_throws() - { - var seeder = new BootstrapDeviceSeeder( - _paths, - _deviceRegistry, - _bootstrapStateStore, - _time, - NullLogger.Instance, - new ThrowingProtectSecretsProtector()); - - await Assert.ThrowsAsync(() => seeder.EnsureSeededAsync( - new DaemonConfig { ExposureMode = ExposureMode.ReverseProxy }, - TestContext.Current.CancellationToken)); - - Assert.Empty(await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken)); - - var retrySeeded = await _sut.EnsureSeededAsync( - new DaemonConfig { ExposureMode = ExposureMode.ReverseProxy }, - TestContext.Current.CancellationToken); - - Assert.True(retrySeeded); - Assert.Single(await _deviceRegistry.ListAsync(TestContext.Current.CancellationToken)); - } - - private sealed class DeviceTokenAppearsDuringPrecheckProtector(NetclawPaths paths) : ISecretsProtector - { - private int _writesRemaining = 1; - - public string Protect(string plaintext) => $"ENC:{plaintext}"; - - public string Unprotect(string ciphertext) - { - if (Interlocked.Exchange(ref _writesRemaining, 0) == 1) - File.WriteAllText(paths.SecretsPath, """{"DeviceToken":"winner"}"""); - - return ciphertext; - } - } - - private sealed class ThrowingProtectSecretsProtector : ISecretsProtector - { - public string Protect(string plaintext) => throw new InvalidOperationException("secret persistence failed"); - - public string Unprotect(string ciphertext) => ciphertext; - } -} diff --git a/src/Netclaw.Daemon/Mcp/McpClientManager.cs b/src/Netclaw.Daemon/Mcp/McpClientManager.cs index 3131c57f5..8516846a3 100644 --- a/src/Netclaw.Daemon/Mcp/McpClientManager.cs +++ b/src/Netclaw.Daemon/Mcp/McpClientManager.cs @@ -21,12 +21,11 @@ namespace Netclaw.Daemon.Mcp; internal sealed class McpClientManager : IHostedService, IDisposable, IMcpToolInvoker, IMcpReconnectable { - internal static readonly TimeSpan ShutdownDrainTimeout = TimeSpan.FromSeconds(10); - private readonly Dictionary _serverEntries; private readonly ToolRegistry _toolRegistry; private readonly ToolConfig _toolConfig; private readonly McpOAuthCredentialStore _credentialStore; + private readonly McpOAuthClientRegistrar _registrar; private readonly McpOAuthFlowBroker _flowBroker; private readonly DaemonConfig _daemonConfig; private readonly IOperationalNotificationSink _notificationSink; @@ -47,6 +46,7 @@ public McpClientManager( ToolRegistry toolRegistry, ToolConfig toolConfig, McpOAuthCredentialStore credentialStore, + McpOAuthClientRegistrar registrar, McpOAuthFlowBroker flowBroker, DaemonConfig daemonConfig, IOperationalNotificationSink notificationSink, @@ -59,6 +59,7 @@ public McpClientManager( _toolRegistry = toolRegistry; _toolConfig = toolConfig; _credentialStore = credentialStore; + _registrar = registrar; _flowBroker = flowBroker; _daemonConfig = daemonConfig; _notificationSink = notificationSink; @@ -163,12 +164,6 @@ public IReadOnlyList GetToolNames(McpServerName serverName) internal McpServerSnapshot? GetSnapshot(McpServerName serverName) => _servers.GetValueOrDefault(serverName)?.Snapshot; - internal int GetTrackedOwnerCount(McpServerName serverName) - => _servers.GetValueOrDefault(serverName)?.TrackedOwnerCount ?? 0; - - internal Task? GetInteractiveAuthorizationTask(McpServerName serverName) - => _servers.GetValueOrDefault(serverName)?.InteractiveAuthorization; - public async Task TryReconnectAsync(McpServerName serverName, CancellationToken ct = default) { if (!_serverEntries.TryGetValue(serverName.Value, out var entry) || !entry.Enabled) @@ -204,21 +199,9 @@ internal async Task StartAuthorizationAsync( if (!_servers.TryGetValue(serverName, out var lifecycle)) throw new InvalidOperationException($"MCP server '{serverName.Value}' is not tracked by the daemon."); - if (lifecycle.InteractiveAuthorization is { IsCompleted: false } - && !_flowBroker.TryGetActive(serverName, out _)) - { - throw new McpOAuthOperationException(new McpErrorResponse( - "The previous authorization attempt is finishing. Retry after its terminal status is available.", - "authorization start")); - } - var started = _flowBroker.StartOrJoin(serverName); if (started.Created) - { - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - lifecycle.SetInteractiveAuthorization(completion.Task); - _ = RunExplicitAuthorizationAsync(lifecycle, entry, started.Flow, completion); - } + _ = RunExplicitAuthorizationAsync(lifecycle, entry, started.Flow); var authorizationUrl = await started.Flow.WaitForAuthorizationUrlAsync(requestCancellation); return new McpOAuthStartResponse(authorizationUrl.ToString(), started.Flow.State); @@ -242,16 +225,14 @@ private async Task InvokeSharedAsync( IDictionary? arguments, CancellationToken ct) { - var lease = TryAcquireInvocationLease(serverName, out var snapshot); - if (lease is null || snapshot is null) + var snapshot = TryGetConnectedSnapshot(serverName); + if (snapshot is null) { - var reconnected = await TryReconnectAsync(serverName, ct); - if (!reconnected) + if (!await TryReconnectAsync(serverName, ct)) throw CreateUnavailableException(serverName, toolName); - lease = TryAcquireInvocationLease(serverName, out snapshot); - if (lease is null || snapshot is null) - throw CreateUnavailableException(serverName, toolName); + snapshot = TryGetConnectedSnapshot(serverName) + ?? throw CreateUnavailableException(serverName, toolName); } Exception? transportFailure = null; @@ -260,13 +241,11 @@ private async Task InvokeSharedAsync( if (!snapshot.ToolFunctions.TryGetValue(toolName.Value, out var function)) throw CreateUnavailableException(serverName, toolName); - using var invocationCancellation = CancellationTokenSource.CreateLinkedTokenSource( - ct, lease.InvocationCancellation); return await InvokeFunctionAsync( function, $"{serverName.Value}/{toolName.Value}", arguments, - invocationCancellation.Token); + ct); } catch (OperationCanceledException) { @@ -280,37 +259,23 @@ private async Task InvokeSharedAsync( { transportFailure = ex; } - finally - { - lease.Dispose(); - } - // The failed call may have completed remotely. Release its lease first, - // reconnect only for later calls, and never replay this invocation. + // The failed call may have completed remotely. Reconnect only for later calls, + // and never replay this invocation. await ReconnectAfterTransportFailureAsync(serverName, snapshot, transportFailure!); ExceptionDispatchInfo.Capture(transportFailure!).Throw(); throw new InvalidOperationException("Unreachable exception propagation path."); } - private McpInvocationLease? TryAcquireInvocationLease( - McpServerName serverName, - out McpServerSnapshot? snapshot) + private McpServerSnapshot? TryGetConnectedSnapshot(McpServerName serverName) { lock (_shutdownSync) { - snapshot = null; if (_stopping || !_servers.TryGetValue(serverName, out var lifecycle)) return null; var current = lifecycle.Snapshot; - if (current is null || !current.IsConnected || current.LeaseOwner is null) - return null; - - if (!current.LeaseOwner.TryAcquire(out var lease)) - return null; - - snapshot = current; - return lease; + return current is not null && current.IsConnected ? current : null; } } @@ -358,7 +323,7 @@ private async Task ReconnectAsync( if (IsStopping) return false; - if (lifecycle.InteractiveAuthorization is { IsCompleted: false }) + if (_flowBroker.TryGetActive(observed.Name, out _)) return observed.IsConnected; using var candidateCancellation = CancellationTokenSource.CreateLinkedTokenSource( @@ -388,7 +353,7 @@ private async Task ReconnectAsync( if (!ReferenceEquals(current, observed)) return current.Generation > observed.Generation && current.IsConnected; - if (lifecycle.InteractiveAuthorization is { IsCompleted: false }) + if (_flowBroker.TryGetActive(current.Name, out _)) return current.IsConnected; return await BuildAndPublishCandidateAsync( @@ -414,13 +379,13 @@ private async Task BuildAndPublishCandidateAsync( McpOAuthFlow? authorizationFlow) { McpClient? candidate = null; - McpOAuthClientContext? oauthContext = null; - Exception? primaryFailure = null; + McpOAuthTokenCache? oauthCache = null; + Exception? candidateFailure = null; try { var created = await CreateClientAsync(current.Name, entry, authorizationFlow, ct); candidate = created.Client; - oauthContext = created.OAuthContext; + oauthCache = created.OAuthCache; var initialization = await _clientRuntime.InitializeAsync(candidate, ct); var tools = initialization.Tools; @@ -449,50 +414,35 @@ private async Task BuildAndPublishCandidateAsync( if (_stopping) return false; if (authorizationFlow is null - && lifecycle.InteractiveAuthorization is { IsCompleted: false }) + && _flowBroker.TryGetActive(current.Name, out _)) return current.IsConnected; if (authorizationFlow is not null) { _flowBroker.BeginCommit(authorizationFlow); - if (oauthContext is null) - throw new InvalidOperationException("Interactive OAuth candidate has no credential context."); - _credentialStore.PromotePending( - current.Name, - oauthContext, - authorizationFlow.State, - CancellationToken.None); - } - else if (oauthContext is not null) - { - _credentialStore.ClaimActiveEpoch(current.Name, oauthContext, ct); + if (oauthCache is null) + throw new InvalidOperationException("Interactive OAuth candidate has no token cache."); } - var leaseOwner = new McpInvocationLeaseOwner( - candidate, - _clientRuntime, - current.Name, - _logger); + if (oauthCache is not null) + _credentialStore.Publish(oauthCache, CancellationToken.None); + replacement = new McpServerSnapshot( current.Name, candidate, functions, checked(current.Generation + 1), - connectedStatus, - leaseOwner); + connectedStatus); _toolRegistry.PublishMcpServerTools( current.Name.Value, publishedTools, - () => - { - lifecycle.Track(leaseOwner); - lifecycle.Publish(replacement); - }); + () => lifecycle.Publish(replacement)); candidate = null; + oauthCache = null; } - if (current.LeaseOwner is not null) - _ = lifecycle.Retire(current.LeaseOwner); + if (current.Client is not null) + await DisposeReplacedAsync(current.Name, current.Client); _logger.LogInformation( "MCP server '{Name}' connected as generation {Generation} ({ToolCount} tools)", current.Name.Value, @@ -504,17 +454,17 @@ private async Task BuildAndPublishCandidateAsync( } catch (OperationCanceledException ex) when (_lifetimeCancellation.IsCancellationRequested) { - primaryFailure = ex; + candidateFailure = ex; return false; } catch (OperationCanceledException ex) { - primaryFailure = ex; + candidateFailure = ex; throw; } catch (Exception ex) { - primaryFailure = ex; + candidateFailure = ex; var now = _timeProvider.GetUtcNow(); var hasOAuthRuntimeHints = HasOAuthRuntimeHints(current.Name, entry); var credentialStateRequiresAuthorization = hasOAuthRuntimeHints @@ -548,14 +498,10 @@ private async Task BuildAndPublishCandidateAsync( if (authorizationFlow is not null) { - if (IsInvalidClientFailure(ex)) - { - _credentialStore.MarkDynamicIdentityRejected( - current.Name, - entry.Url!, - entry.OAuthClientId, - CancellationToken.None); - } + // The server says this client id is not one of its own. Keeping it would + // make every future authorization attempt fail the same way. + if (entry.OAuthClientId is null && IsInvalidClientFailure(ex)) + _credentialStore.ForgetClientIdentity(current.Name, entry.Url!, CancellationToken.None); var error = CreateSafeOAuthError(ex, "connection initialization"); _logger.LogError(ex, @@ -563,10 +509,6 @@ private async Task BuildAndPublishCandidateAsync( current.Name.Value, error.Operation, error.Status); - _credentialStore.RemovePending( - current.Name, - authorizationFlow.State, - CancellationToken.None); _flowBroker.Fail(authorizationFlow, error); } @@ -574,6 +516,8 @@ private async Task BuildAndPublishCandidateAsync( } finally { + if (oauthCache is not null) + _credentialStore.Discard(oauthCache); if (candidate is not null) { try @@ -582,54 +526,27 @@ private async Task BuildAndPublishCandidateAsync( } catch (Exception disposalFailure) { - var surfacedFailure = primaryFailure is null - ? disposalFailure - : new AggregateException( - $"MCP candidate '{current.Name.Value}' initialization and disposal both failed.", - primaryFailure, - disposalFailure); - if (!IsStopping) - { - var failureStatus = CreateUnreachableStatus( - current.Name, - surfacedFailure, - _timeProvider.GetUtcNow()); - lifecycle.Publish(WithFailureStatus(current, failureStatus)); - } - _logger.LogError(disposalFailure, "Error disposing unpublished MCP client '{Name}'", current.Name.Value); - ExceptionDispatchInfo.Capture(surfacedFailure).Throw(); - throw new InvalidOperationException("Unreachable exception propagation path."); + if (candidateFailure is not null) + { + throw new AggregateException( + "MCP candidate initialization and disposal both failed.", + candidateFailure, + disposalFailure); + } + throw; } } - if (authorizationFlow is not null - && _flowBroker.GetStatusByState(authorizationFlow.State).Status is not McpOAuthFlowStatus.Completed) - { - try - { - _credentialStore.RemovePending( - current.Name, - authorizationFlow.State, - CancellationToken.None); - } - catch (Exception cleanupFailure) - { - _logger.LogError(cleanupFailure, - "Failed to remove pending OAuth credentials for MCP server '{Name}'", - current.Name.Value); - } - } } } private async Task RunExplicitAuthorizationAsync( McpServerLifecycle lifecycle, McpServerEntry entry, - McpOAuthFlow flow, - TaskCompletionSource completion) + McpOAuthFlow flow) { try { @@ -640,19 +557,15 @@ private async Task RunExplicitAuthorizationAsync( try { if (IsStopping || lifecycle.Snapshot is not { } current) - { - completion.TrySetResult(false); return; - } - var result = await BuildAndPublishCandidateAsync( + await BuildAndPublishCandidateAsync( lifecycle, entry, current, cancellation.Token, null, flow); - completion.TrySetResult(result); } finally { @@ -665,20 +578,13 @@ private async Task RunExplicitAuthorizationAsync( "Authorization was cancelled or expired. Start a new MCP authorization attempt.", "authorization exchange"); _logger.LogWarning(ex, "Explicit MCP OAuth flow was cancelled for '{Name}'", flow.ServerName.Value); - _credentialStore.RemovePending(flow.ServerName, flow.State, CancellationToken.None); _flowBroker.Fail(flow, error); - completion.TrySetResult(false); } catch (Exception ex) { var error = CreateSafeOAuthError(ex, "connection initialization"); _logger.LogError(ex, "Explicit MCP OAuth flow failed for '{Name}'", flow.ServerName.Value); _flowBroker.Fail(flow, error); - completion.TrySetResult(false); - } - finally - { - lifecycle.ClearInteractiveAuthorization(completion.Task); } } @@ -704,45 +610,18 @@ private async Task StopServerAsync( await lifecycle.Gate.WaitAsync(CancellationToken.None); try { - var snapshot = lifecycle.Snapshot; - var owners = lifecycle.RetireAll(snapshot?.LeaseOwner); + var client = lifecycle.Snapshot?.Client; _toolRegistry.PublishMcpServerTools( serverName.Value, [], () => lifecycle.Publish(null)); - if (owners.Count == 0) + if (client is null) return; - using var cancellationRegistration = shutdownCancellation.Register( - static state => - { - foreach (var owner in (IReadOnlyList)state!) - owner.CancelInvocations(); - }, - owners); - - var drained = Task.WhenAll(owners.Select(owner => owner.Drained)); - if (!drained.IsCompleted) - { - var timeout = Task.Delay(ShutdownDrainTimeout, _timeProvider, CancellationToken.None); - if (await Task.WhenAny(drained, timeout) != drained) - { - foreach (var owner in owners) - owner.CancelInvocations(); - } - } - - await drained; - try - { - await Task.WhenAll(owners.Select(owner => owner.Disposal)); - } - finally - { - lifecycle.Forget(owners); - } - + // An invocation still in flight is cancelled by the client going away rather + // than drained. Draining lives in the separate client-lifecycle work. + await _clientRuntime.DisposeAsync(client); _logger.LogInformation("MCP client '{Name}' shut down", serverName.Value); } finally @@ -757,6 +636,20 @@ private async Task DisposeCandidateAsync(McpServerName name, McpClient candidate _logger.LogDebug("Unpublished MCP client '{Name}' disposed", name.Value); } + private async Task DisposeReplacedAsync(McpServerName name, McpClient replaced) + { + try + { + await _clientRuntime.DisposeAsync(replaced); + } + catch (Exception ex) + { + // The replacement is already published and serving; a failed disposal of the + // old client leaks a connection but must not fail the reconnect. + _logger.LogError(ex, "Error disposing replaced MCP client '{Name}'", name.Value); + } + } + private async Task InvokeFunctionAsync( AIFunction function, string qualifiedToolName, @@ -781,35 +674,61 @@ private async Task CreateClientAsync( McpOAuthFlow? authorizationFlow, CancellationToken ct) { - McpOAuthClientContext? oauthContext = null; + McpOAuthTokenCache? oauthCache = null; if (entry.Transport is not "stdio" && !HasConfiguredAuthorizationHeader(entry)) { - oauthContext = _credentialStore.CreateContext( + oauthCache = _credentialStore.CreateTokenCache( name, entry.Url!, entry.OAuthClientId, authorizationFlow is not null); } - var transport = CreateTransport(name, entry, oauthContext, authorizationFlow); - var client = await _clientRuntime.CreateAsync(transport, new McpClientOptions + try { - ClientInfo = new() + // Register only while explicitly authorizing. A background reconnect with no + // stored identity belongs to a server nobody has authorized yet, and it would + // fail at the redirect delegate regardless — registering there would create + // client records for servers the operator never opted into. + if (oauthCache is not null + && authorizationFlow is not null + && _credentialStore.GetIdentity(oauthCache).ClientId is null) + { + var registered = await _registrar.TryRegisterAsync( + name, + entry.Url!, + BuildRedirectUri(), + ct); + if (registered is not null) + _credentialStore.AdoptClientIdentity(oauthCache, registered); + } + + var transport = CreateTransport(name, entry, oauthCache, authorizationFlow); + var client = await _clientRuntime.CreateAsync(transport, new McpClientOptions { - Name = "netclaw", - Title = "Netclaw", - Version = BuildInfo.Version, - WebsiteUrl = "https://netclaw.dev", - Description = "Open-source autonomous operations agent built on Akka.NET", - }, - }, ct); - return new McpClientCandidate(client, oauthContext); + ClientInfo = new() + { + Name = "netclaw", + Title = "Netclaw", + Version = BuildInfo.Version, + WebsiteUrl = "https://netclaw.dev", + Description = "Open-source autonomous operations agent built on Akka.NET", + }, + }, ct); + return new McpClientCandidate(client, oauthCache); + } + catch + { + if (oauthCache is not null) + _credentialStore.Discard(oauthCache); + throw; + } } private IClientTransport CreateTransport( McpServerName serverName, McpServerEntry entry, - McpOAuthClientContext? oauthContext, + McpOAuthTokenCache? oauthCache, McpOAuthFlow? authorizationFlow) { if (entry.Transport is "stdio") @@ -834,7 +753,7 @@ private IClientTransport CreateTransport( var oauth = HasConfiguredAuthorizationHeader(entry) ? null - : BuildOAuthOptions(serverName, entry, oauthContext!, authorizationFlow); + : BuildOAuthOptions(entry, oauthCache!, authorizationFlow); return _clientRuntime.CreateHttpTransport(new HttpClientTransportOptions { Endpoint = new Uri(entry.Url!), @@ -848,53 +767,35 @@ private IClientTransport CreateTransport( } private ClientOAuthOptions BuildOAuthOptions( - McpServerName serverName, McpServerEntry entry, - McpOAuthClientContext context, + McpOAuthTokenCache cache, McpOAuthFlow? authorizationFlow) { - var identity = context.SnapshotIdentity(); - var redirectUri = new Uri( - $"http://127.0.0.1:{_daemonConfig.Port}/api/mcp/oauth/callback"); - var target = authorizationFlow is null - ? McpOAuthCredentialTarget.Active - : McpOAuthCredentialTarget.Pending; + var identity = _credentialStore.GetIdentity(cache); return new ClientOAuthOptions { - RedirectUri = redirectUri, + RedirectUri = BuildRedirectUri(), ClientId = entry.OAuthClientId ?? identity.ClientId, ClientSecret = entry.OAuthClientId is null ? identity.ClientSecret : null, Scopes = ParseScopes(entry.OAuthScope), - TokenCache = _credentialStore.CreateTokenCache( - serverName, - entry.Url!, - context, - target, - authorizationFlow?.State, - authorizationFlow?.ExpiresAt, - withholdAccessToken: authorizationFlow is not null), + TokenCache = cache, AuthorizationRedirectDelegate = authorizationFlow is null ? static (_, _, _) => Task.FromResult(null) : authorizationFlow.HandleAuthorizationRedirectAsync, AdditionalAuthorizationParameters = authorizationFlow is null ? new Dictionary() : new Dictionary { ["state"] = authorizationFlow.State }, - DynamicClientRegistration = new DynamicClientRegistrationOptions - { - ClientName = "netclaw", - ResponseDelegate = (response, cancellationToken) => - { - _credentialStore.CaptureDynamicRegistration( - serverName, - context, - response, - cancellationToken); - return Task.CompletedTask; - }, - }, + + // DynamicClientRegistration is deliberately left unset. McpOAuthClientRegistrar + // owns registration because the SDK hard-codes client_secret_post and cannot + // register against public-client-only servers (csharp-sdk#1611). A non-null + // ClientId here short-circuits the SDK's registration path entirely. }; } + private Uri BuildRedirectUri() + => new($"http://127.0.0.1:{_daemonConfig.Port}/api/mcp/oauth/callback"); + private bool HasOAuthRuntimeHints(McpServerName serverName, McpServerEntry entry) => entry.Transport is not "stdio" && !HasConfiguredAuthorizationHeader(entry); @@ -1077,7 +978,7 @@ internal static McpErrorResponse CreateSafeOAuthError(Exception ex, string fallb var status = FindHttpStatus(ex); var exceptions = EnumerateExceptionTree(ex).ToArray(); var operation = exceptions.Any(candidate => - candidate is McpOAuthStaleCredentialEpochException + candidate is McpOAuthRetiredCredentialWriterException or IOException or UnauthorizedAccessException || candidate.Message.Contains("secrets", StringComparison.OrdinalIgnoreCase)) @@ -1124,6 +1025,12 @@ private static IEnumerable EnumerateExceptionTree(Exception root) { if (ex is HttpRequestException { StatusCode: { } status }) return status; + foreach (var candidate in Enum.GetValues()) + { + if (ex.Message.Contains($"status {candidate}", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains($"HTTP {(int)candidate}", StringComparison.OrdinalIgnoreCase)) + return candidate; + } if (ex.Message.Contains("403", StringComparison.Ordinal) || ex.Message.Contains("Forbidden", StringComparison.OrdinalIgnoreCase)) return HttpStatusCode.Forbidden; @@ -1236,13 +1143,10 @@ public void Dispose() { foreach (var (serverName, lifecycle) in _servers) { - var owners = lifecycle.RetireAll(lifecycle.Snapshot?.LeaseOwner); _toolRegistry.PublishMcpServerTools( serverName.Value, [], () => lifecycle.Publish(null)); - foreach (var owner in owners) - owner.CancelInvocations(); } } @@ -1305,103 +1209,17 @@ internal sealed record McpClientInitialization( internal sealed record McpClientCandidate( McpClient Client, - McpOAuthClientContext? OAuthContext); + McpOAuthTokenCache? OAuthCache); internal sealed class McpServerLifecycle(McpServerSnapshot initialSnapshot) { private McpServerSnapshot? _snapshot = initialSnapshot; - private Task? _interactiveAuthorization; - private readonly List _owners = []; - private readonly object _ownersSync = new(); public SemaphoreSlim Gate { get; } = new(1, 1); public McpServerSnapshot? Snapshot => Volatile.Read(ref _snapshot); - public Task? InteractiveAuthorization => Volatile.Read(ref _interactiveAuthorization); - - public int TrackedOwnerCount - { - get - { - lock (_ownersSync) - return _owners.Count; - } - } - public void Publish(McpServerSnapshot? snapshot) => Volatile.Write(ref _snapshot, snapshot); - - public void SetInteractiveAuthorization(Task authorization) - { - while (true) - { - var current = Volatile.Read(ref _interactiveAuthorization); - if (current is not null && !current.IsCompleted) - throw new InvalidOperationException("An interactive authorization operation is already active."); - if (Interlocked.CompareExchange(ref _interactiveAuthorization, authorization, current) == current) - return; - } - } - - public void ClearInteractiveAuthorization(Task authorization) - => Interlocked.CompareExchange(ref _interactiveAuthorization, null, authorization); - - public void Track(McpInvocationLeaseOwner owner) - { - var added = false; - lock (_ownersSync) - { - if (!_owners.Contains(owner)) - { - _owners.Add(owner); - added = true; - } - } - - if (!added) - return; - - owner.RegisterSuccessfulDisposal(RemoveSuccessfullyDisposed); - } - - public Task Retire(McpInvocationLeaseOwner owner) - { - Track(owner); - return owner.Retire(); - } - - public IReadOnlyList RetireAll(McpInvocationLeaseOwner? current) - { - List owners; - lock (_ownersSync) - { - if (current is not null && !_owners.Contains(current)) - _owners.Add(current); - - owners = _owners.ToList(); - } - - foreach (var owner in owners) - _ = owner.Retire(); - - return owners; - } - - private void RemoveSuccessfullyDisposed(McpInvocationLeaseOwner owner) - { - lock (_ownersSync) - _owners.Remove(owner); - } - - public void Forget(IReadOnlyList owners) - { - lock (_ownersSync) - { - foreach (var owner in owners) - _owners.Remove(owner); - } - } - } internal sealed record McpServerSnapshot( @@ -1409,191 +1227,16 @@ internal sealed record McpServerSnapshot( McpClient? Client, IReadOnlyDictionary ToolFunctions, long Generation, - McpServerStatus Status, - McpInvocationLeaseOwner? LeaseOwner) + McpServerStatus Status) { private static readonly IReadOnlyDictionary EmptyFunctions = new ReadOnlyDictionary(new Dictionary()); public bool IsConnected - => Client is not null - && LeaseOwner is not null - && Status.State is McpConnectionState.Connected; + => Client is not null && Status.State is McpConnectionState.Connected; public static McpServerSnapshot WithoutConnection(McpServerStatus status, long generation = 0) - => new(status.Name, null, EmptyFunctions, generation, status, null); -} - -internal sealed class McpInvocationLeaseOwner -{ - private readonly object _sync = new(); - private readonly McpClient _client; - private readonly IMcpClientRuntime _runtime; - private readonly McpServerName _serverName; - private readonly ILogger _logger; - private readonly CancellationTokenSource _invocationCancellation = new(); - private readonly TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly TaskCompletionSource _disposed = new(TaskCreationOptions.RunContinuationsAsynchronously); - private int _activeLeases; - private bool _retired; - private bool _disposeStarted; - private bool _disposedSuccessfully; - private Action? _successfulDisposal; - - public McpInvocationLeaseOwner( - McpClient client, - IMcpClientRuntime runtime, - McpServerName serverName, - ILogger logger) - { - _client = client; - _runtime = runtime; - _serverName = serverName; - _logger = logger; - } - - public Task Drained => _drained.Task; - - public Task Disposal => _disposed.Task; - - public void RegisterSuccessfulDisposal(Action callback) - { - ArgumentNullException.ThrowIfNull(callback); - var invokeNow = false; - lock (_sync) - { - if (_disposedSuccessfully) - invokeNow = true; - else - _successfulDisposal += callback; - } - - if (invokeNow) - callback(this); - } - - public bool TryAcquire(out McpInvocationLease? lease) - { - lock (_sync) - { - if (_retired) - { - lease = null; - return false; - } - - checked { _activeLeases++; } - lease = new McpInvocationLease(this, _invocationCancellation.Token); - return true; - } - } - - public Task Retire() - { - var startDispose = false; - lock (_sync) - { - if (!_retired) - _retired = true; - - if (_activeLeases == 0) - { - _drained.TrySetResult(); - if (!_disposeStarted) - { - _disposeStarted = true; - startDispose = true; - } - } - } - - if (startDispose) - _ = DisposeClientAsync(); - - return _disposed.Task; - } - - public void CancelInvocations() - { - try - { - _invocationCancellation.Cancel(); - } - catch (ObjectDisposedException) - { - _logger.LogDebug( - "Invocation cancellation for retired MCP client '{Name}' raced completed disposal", - _serverName.Value); - } - } - - internal void Release() - { - var startDispose = false; - lock (_sync) - { - if (_activeLeases <= 0) - throw new InvalidOperationException("MCP invocation lease released more than once."); - - _activeLeases--; - if (_retired && _activeLeases == 0) - { - _drained.TrySetResult(); - if (!_disposeStarted) - { - _disposeStarted = true; - startDispose = true; - } - } - } - - if (startDispose) - _ = DisposeClientAsync(); - } - - private async Task DisposeClientAsync() - { - try - { - await _runtime.DisposeAsync(_client); - Action? successfulDisposal; - lock (_sync) - { - _disposedSuccessfully = true; - successfulDisposal = _successfulDisposal; - _successfulDisposal = null; - } - - successfulDisposal?.Invoke(this); - _disposed.TrySetResult(); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error disposing retired MCP client '{Name}'", - _serverName.Value); - _disposed.TrySetException(ex); - } - finally - { - _invocationCancellation.Dispose(); - } - } -} - -internal sealed class McpInvocationLease : IDisposable -{ - private McpInvocationLeaseOwner? _owner; - - public McpInvocationLease(McpInvocationLeaseOwner owner, CancellationToken invocationCancellation) - { - _owner = owner; - InvocationCancellation = invocationCancellation; - } - - public CancellationToken InvocationCancellation { get; } - - public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Release(); + => new(status.Name, null, EmptyFunctions, generation, status); } internal enum McpConnectionState diff --git a/src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs b/src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs new file mode 100644 index 000000000..4a7d23e87 --- /dev/null +++ b/src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs @@ -0,0 +1,242 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Netclaw.Tools; + +namespace Netclaw.Daemon.Mcp; + +/// +/// Raised when an MCP server requires OAuth but Netclaw cannot obtain a client +/// identity for it. The message is operator-facing and names the remedy. +/// +internal sealed class McpOAuthRegistrationException(string message, Exception? innerException = null) + : InvalidOperationException(message, innerException); + +/// +/// RFC 7591 dynamic client registration, owned by Netclaw rather than the MCP SDK. +/// +/// The SDK hard-codes token_endpoint_auth_method: "client_secret_post" in its +/// registration request and never consults the authorization server's advertised +/// token_endpoint_auth_methods_supported (csharp-sdk#1611; unfixed in 1.4.1, +/// every 2.0 prerelease, and main — PR #1615 only covers the token request). Servers +/// that accept public clients only, such as TextForge, reject that body with +/// 400 invalid_client_metadata, which makes SDK-driven registration impossible +/// against them. Netclaw registers instead and seeds +/// , which +/// short-circuits the SDK's registration path entirely. +/// +internal sealed class McpOAuthClientRegistrar( + HttpClient httpClient, + ILogger logger) +{ + /// + /// What the SDK falls back to when an authorization server advertises no + /// token_endpoint_auth_methods_supported. Netclaw matches it so the + /// registered method and the method used at the token endpoint cannot diverge. + /// + private const string SdkDefaultAuthMethod = "client_secret_post"; + + /// + /// Registers a client for and returns its identity. + /// Returns null when the server advertises no OAuth protected-resource + /// metadata, which is how an unauthenticated MCP server is recognized. + /// + public async Task TryRegisterAsync( + McpServerName serverName, + string endpoint, + Uri redirectUri, + CancellationToken cancellationToken) + { + var resource = new Uri(endpoint); + var authorizationServer = await TryDiscoverAuthorizationServerAsync(resource, cancellationToken); + if (authorizationServer is null) + return null; + + var (issuer, registrationEndpoint, supportedAuthMethods) = authorizationServer.Value; + + if (string.IsNullOrWhiteSpace(registrationEndpoint)) + { + throw new McpOAuthRegistrationException( + $"MCP server '{serverName.Value}' requires OAuth, but its authorization server " + + $"({issuer}) does not support dynamic client registration. Register a client " + + $"manually and set OAuthClientId for '{serverName.Value}' in the MCP server config."); + } + + // Register with exactly the method ClientOAuthProvider will later select for the + // token request (TokenEndpointAuthMethodsSupported.FirstOrDefault()). SDK 1.4.1 + // exposes no way to override that selection, so matching it here is what keeps + // registration and token authentication in agreement. + var authMethod = supportedAuthMethods.FirstOrDefault() ?? SdkDefaultAuthMethod; + + var request = new Dictionary + { + ["client_name"] = "netclaw", + ["redirect_uris"] = new[] { redirectUri.ToString() }, + ["grant_types"] = new[] { "authorization_code", "refresh_token" }, + ["response_types"] = new[] { "code" }, + ["token_endpoint_auth_method"] = authMethod, + }; + + using var response = await httpClient.PostAsJsonAsync(registrationEndpoint, request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + // The provider's response body can carry authorization codes and secrets, so it + // rides on an inner exception. The daemon logs the whole tree; the operator-facing + // message is synthesized separately by McpClientManager.CreateSafeOAuthError and + // never echoes this text. + var providerDetail = new HttpRequestException( + $"Client registration POST to {registrationEndpoint} returned " + + $"{(int)response.StatusCode} {response.StatusCode}: {body}", + inner: null, + response.StatusCode); + + throw new McpOAuthRegistrationException( + $"MCP server '{serverName.Value}' rejected dynamic client registration: " + + $"HTTP {(int)response.StatusCode} {response.StatusCode}{DescribeOAuthError(body)}. " + + $"Register a client manually and set OAuthClientId for '{serverName.Value}' " + + "in the MCP server config.", + providerDetail); + } + + string? clientId; + string? clientSecret; + try + { + using var document = JsonDocument.Parse(body); + clientId = document.RootElement.TryGetProperty("client_id", out var idProperty) + ? idProperty.GetString() + : null; + clientSecret = document.RootElement.TryGetProperty("client_secret", out var secretProperty) + ? secretProperty.GetString() + : null; + } + catch (JsonException ex) + { + throw new McpOAuthRegistrationException( + $"MCP server '{serverName.Value}' returned an unreadable client registration response.", ex); + } + + if (string.IsNullOrWhiteSpace(clientId)) + { + throw new McpOAuthRegistrationException( + $"MCP server '{serverName.Value}' returned a client registration response without a client_id."); + } + + logger.LogInformation( + "Registered OAuth client for MCP server '{Name}' with {AuthMethod} at {Issuer}", + serverName.Value, + authMethod, + issuer); + + return new McpOAuthClientIdentity(clientId, clientSecret, DynamicClientRegistration: true); + } + + private async Task<(string Issuer, string? RegistrationEndpoint, IReadOnlyList AuthMethods)?> + TryDiscoverAuthorizationServerAsync(Uri resource, CancellationToken cancellationToken) + { + var origin = resource.GetLeftPart(UriPartial.Authority); + var path = resource.AbsolutePath.TrimEnd('/'); + + // RFC 9728 inserts the well-known segment after the host and keeps the resource + // path as a suffix. Fall back to the bare well-known path for servers that only + // publish the origin-level document. + var candidates = string.IsNullOrEmpty(path) || path == "/" + ? new[] { $"{origin}/.well-known/oauth-protected-resource" } + : [$"{origin}/.well-known/oauth-protected-resource{path}", $"{origin}/.well-known/oauth-protected-resource"]; + + foreach (var candidate in candidates) + { + var issuer = await TryReadAuthorizationServerAsync(candidate, cancellationToken); + if (issuer is null) + continue; + + var metadataUrl = $"{issuer.TrimEnd('/')}/.well-known/oauth-authorization-server"; + JsonElement metadata; + try + { + metadata = await httpClient.GetFromJsonAsync(metadataUrl, cancellationToken); + } + catch (Exception ex) when (ex is HttpRequestException or JsonException or NotSupportedException) + { + throw new McpOAuthRegistrationException( + $"Authorization server '{issuer}' did not return usable metadata at {metadataUrl}.", ex); + } + + var registrationEndpoint = metadata.TryGetProperty("registration_endpoint", out var registration) + ? registration.GetString() + : null; + var authMethods = metadata.TryGetProperty("token_endpoint_auth_methods_supported", out var methods) + && methods.ValueKind == JsonValueKind.Array + ? methods.EnumerateArray().Select(m => m.GetString()).OfType().ToArray() + : []; + + return (issuer, registrationEndpoint, authMethods); + } + + return null; + } + + private async Task TryReadAuthorizationServerAsync(string url, CancellationToken cancellationToken) + { + try + { + var document = await httpClient.GetFromJsonAsync(url, cancellationToken); + if (!document.TryGetProperty("authorization_servers", out var servers) + || servers.ValueKind != JsonValueKind.Array) + return null; + + foreach (var server in servers.EnumerateArray()) + { + var value = server.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + + return null; + } + catch (Exception ex) when (ex is HttpRequestException or JsonException or NotSupportedException) + { + // Absent or unparseable protected-resource metadata is how an MCP server + // without OAuth presents itself; it is not an error. + logger.LogDebug(ex, "No OAuth protected-resource metadata at {Url}", url); + return null; + } + } + + private static string DescribeOAuthError(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return string.Empty; + + try + { + using var document = JsonDocument.Parse(body); + var error = document.RootElement.TryGetProperty("error", out var errorProperty) + ? errorProperty.GetString() + : null; + var description = document.RootElement.TryGetProperty("error_description", out var descriptionProperty) + ? descriptionProperty.GetString() + : null; + return (error, description) switch + { + (null, null) => string.Empty, + (not null, null) => $" ({error})", + (null, not null) => $" ({description})", + _ => $" ({error}: {description})", + }; + } + catch (JsonException) + { + // A non-JSON error body carries no field worth quoting back, and the raw + // response may contain provider detail that should stay in the daemon log. + return string.Empty; + } + } +} diff --git a/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs b/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs index 2edaaf009..4fd6e79d3 100644 --- a/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs +++ b/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs @@ -15,137 +15,70 @@ namespace Netclaw.Daemon.Mcp; -internal enum McpOAuthCredentialTarget -{ - Active, - Pending, -} +internal sealed class McpOAuthRetiredCredentialWriterException(string message) : InvalidOperationException(message); -internal sealed class McpOAuthStaleCredentialEpochException(string message) : InvalidOperationException(message); +internal sealed record McpOAuthClientIdentity( + string? ClientId, + string? ClientSecret, + bool DynamicClientRegistration); -internal sealed class McpOAuthClientContext( - string? clientId, - string? clientSecret, - bool dynamicClientRegistration, - string canonicalResource, - string? baseActiveEpoch) +/// +/// Per-connection SDK token cache. Unpublished candidates keep tokens local; +/// the published cache persists refreshes before returning to the SDK. +/// +internal sealed class McpOAuthTokenCache : ITokenCache { - private readonly object _sync = new(); - private string? _clientId = clientId; - private string? _clientSecret = clientSecret; - private bool _dynamicClientRegistration = dynamicClientRegistration; - private McpOAuthCredentialTarget _target = McpOAuthCredentialTarget.Active; - private string? _flowId; - private DateTimeOffset? _pendingExpiresAt; - private string? _expectedActiveEpoch = baseActiveEpoch; - private bool _withholdAccessToken; - private bool _publishedOwner; - private string _ownerEpoch = Guid.NewGuid().ToString("N"); - - public string CanonicalResource { get; } = canonicalResource; - - public string? BaseActiveEpoch { get; } = baseActiveEpoch; - - public string OwnerEpoch - { - get - { - lock (_sync) - return _ownerEpoch; - } - } + private readonly McpOAuthCredentialStore _store; - public McpOAuthClientIdentity SnapshotIdentity() + internal McpOAuthTokenCache( + McpOAuthCredentialStore store, + McpServerName serverName, + string canonicalResource, + McpOAuthClientIdentity identity, + McpOAuthTokenSet? credentials, + int baseRevision, + bool explicitAuthorization) { - lock (_sync) - return new McpOAuthClientIdentity(_clientId, _clientSecret, _dynamicClientRegistration); + _store = store; + ServerName = serverName; + CanonicalResource = canonicalResource; + Identity = identity; + Credentials = credentials; + BaseRevision = baseRevision; + ExplicitAuthorization = explicitAuthorization; } - public McpOAuthCredentialView SnapshotCredentialView() - { - lock (_sync) - { - return new McpOAuthCredentialView( - _target, - _flowId, - _pendingExpiresAt, - _expectedActiveEpoch, - _withholdAccessToken, - _publishedOwner, - _ownerEpoch); - } - } + internal McpServerName ServerName { get; } - public void ConfigureCredentialView( - McpOAuthCredentialTarget target, - string? flowId, - DateTimeOffset? pendingExpiresAt, - bool withholdAccessToken) - { - lock (_sync) - { - _target = target; - _flowId = flowId; - _pendingExpiresAt = pendingExpiresAt; - _withholdAccessToken = withholdAccessToken; - } - } + internal string CanonicalResource { get; } - public void CaptureDynamicRegistration(DynamicClientRegistrationResponse response) - { - lock (_sync) - { - _clientId = response.ClientId; - _clientSecret = response.ClientSecret; - _dynamicClientRegistration = true; - } - } + internal McpOAuthClientIdentity Identity { get; set; } - public void MarkTokensStored(McpOAuthCredentialTarget target, string committedEpoch) - { - lock (_sync) - { - _withholdAccessToken = false; - if (target is McpOAuthCredentialTarget.Active) - { - _expectedActiveEpoch = committedEpoch; - if (_publishedOwner) - _ownerEpoch = committedEpoch; - } - } - } + internal McpOAuthTokenSet? Credentials { get; set; } + + internal int BaseRevision { get; set; } + + internal bool ExplicitAuthorization { get; } + + internal bool Dirty { get; set; } - public void Activate() + internal bool Published { get; set; } + + internal bool Retired { get; set; } + + public ValueTask GetTokensAsync(CancellationToken cancellationToken) + => new(_store.ReadTokens(this, cancellationToken)); + + public ValueTask StoreTokensAsync(TokenContainer tokens, CancellationToken cancellationToken) { - lock (_sync) - { - _target = McpOAuthCredentialTarget.Active; - _flowId = null; - _pendingExpiresAt = null; - _expectedActiveEpoch = _ownerEpoch; - _withholdAccessToken = false; - _publishedOwner = true; - } + _store.StoreTokens(this, tokens, cancellationToken); + return default; } } -internal sealed record McpOAuthClientIdentity( - string? ClientId, - string? ClientSecret, - bool DynamicClientRegistration); - -internal sealed record McpOAuthCredentialView( - McpOAuthCredentialTarget Target, - string? FlowId, - DateTimeOffset? PendingExpiresAt, - string? ExpectedActiveEpoch, - bool WithholdAccessToken, - bool PublishedOwner, - string OwnerEpoch); - /// -/// Shared durable authority for every MCP SDK token cache. Epoch-conditional -/// transactions reject writes from retired connections and stale processes. +/// Durable authority for active MCP OAuth credentials. The daemon lifecycle +/// gate publishes one cache owner; unpublished authorization state stays local. /// internal sealed class McpOAuthCredentialStore { @@ -161,8 +94,7 @@ internal sealed class McpOAuthCredentialStore private readonly TimeProvider _timeProvider; private readonly ISecretsProtector _protector; private readonly ILogger _logger; - private readonly ConcurrentDictionary _credentials = new(); - private readonly ConcurrentDictionary _serverLocks = new(); + private readonly ConcurrentDictionary _servers = new(); public McpOAuthCredentialStore( NetclawPaths paths, @@ -174,7 +106,7 @@ public McpOAuthCredentialStore( _timeProvider = timeProvider; _protector = protector ?? throw new ArgumentNullException(nameof(protector)); _logger = logger; - LoadAndRecoverDurableState(); + LoadDurableState(); } public static string CanonicalizeResource(string endpoint) @@ -188,95 +120,106 @@ public static string CanonicalizeResource(string endpoint) return builder.Uri.AbsoluteUri; } - public McpOAuthClientContext CreateContext( + public McpOAuthTokenCache CreateTokenCache( McpServerName serverName, string resourceIdentity, string? configuredClientId, bool explicitAuthorization) { var canonicalResource = CanonicalizeResource(resourceIdentity); - lock (GetServerLock(serverName)) + var state = GetState(serverName); + lock (state.Sync) { - var envelope = GetEnvelope(serverName); - var active = IsBound(envelope.Active, canonicalResource) ? envelope.Active : null; - var baseActiveEpoch = envelope.Active?.CredentialEpoch; + var active = GetBoundOrMigrateLegacy( + serverName, + state, + canonicalResource, + configuredClientId); + McpOAuthClientIdentity identity; if (!string.IsNullOrWhiteSpace(configuredClientId)) { - return new McpOAuthClientContext( - configuredClientId, - null, - false, - canonicalResource, - baseActiveEpoch); + identity = new McpOAuthClientIdentity(configuredClientId, null, false); } - - var clientId = active is { DynamicClientRegistration: true } ? active.ClientId : null; - var clientSecret = active is { DynamicClientRegistration: true } ? active.ClientSecret?.Value : null; - var dynamic = !string.IsNullOrWhiteSpace(clientId); - if (explicitAuthorization - && dynamic - && string.Equals(envelope.RejectedDynamicClientId, clientId, StringComparison.Ordinal)) + else { - clientId = null; - clientSecret = null; - dynamic = false; + identity = active is { DynamicClientRegistration: true, ClientId: not null } + ? new McpOAuthClientIdentity(active.ClientId, active.ClientSecret?.Value, true) + : new McpOAuthClientIdentity(null, null, false); } - return new McpOAuthClientContext( - clientId, - clientSecret, - dynamic, + return new McpOAuthTokenCache( + this, + serverName, canonicalResource, - baseActiveEpoch); + identity, + explicitAuthorization ? null : active, + state.Revision, + explicitAuthorization); } } - public ITokenCache CreateTokenCache( - McpServerName serverName, - string resourceIdentity, - McpOAuthClientContext context, - McpOAuthCredentialTarget target, - string? flowId, - DateTimeOffset? pendingExpiresAt, - bool withholdAccessToken) + public void Publish(McpOAuthTokenCache cache, CancellationToken cancellationToken) { - var canonical = CanonicalizeResource(resourceIdentity); - if (!string.Equals(canonical, context.CanonicalResource, StringComparison.Ordinal)) - throw new InvalidOperationException("OAuth context resource does not match the token-cache resource."); - if (target is McpOAuthCredentialTarget.Pending - && (string.IsNullOrWhiteSpace(flowId) || pendingExpiresAt is null)) - throw new InvalidOperationException("Pending OAuth credentials require a flow identity and expiry."); - - context.ConfigureCredentialView(target, flowId, pendingExpiresAt, withholdAccessToken); - return new CredentialTokenCache(this, serverName, context); + var state = GetState(cache.ServerName); + lock (state.Sync) + { + ThrowIfRetired(cache); + if (cache.ExplicitAuthorization && cache.Credentials is null) + throw new InvalidOperationException("OAuth authorization completed without storing credentials."); + if (!cache.ExplicitAuthorization && cache.Dirty && cache.BaseRevision != state.Revision) + { + throw new McpOAuthRetiredCredentialWriterException( + "Active OAuth credentials changed while the replacement connection initialized."); + } + + if (cache.Dirty) + { + var replacement = Clone(cache.Credentials)!; + if (replacement.RefreshToken is null + && CanRetainRefreshToken(state.Active, replacement)) + replacement.RefreshToken = state.Active!.RefreshToken; + Persist(cache.ServerName, replacement, cancellationToken); + state.Active = replacement; + state.Revision++; + } + else + { + cache.Credentials = IsBound(state.Active, cache.CanonicalResource) + ? Clone(state.Active) + : null; + } + + if (state.PublishedCache is { } previous && !ReferenceEquals(previous, cache)) + previous.Retired = true; + state.PublishedCache = cache; + cache.BaseRevision = state.Revision; + cache.Published = true; + } } - public void CaptureDynamicRegistration( - McpServerName serverName, - McpOAuthClientContext context, - DynamicClientRegistrationResponse response, - CancellationToken cancellationToken) + public void Discard(McpOAuthTokenCache cache) { - ArgumentNullException.ThrowIfNull(response); - cancellationToken.ThrowIfCancellationRequested(); - lock (GetServerLock(serverName)) - context.CaptureDynamicRegistration(response); + var state = GetState(cache.ServerName); + lock (state.Sync) + { + if (!cache.Published) + cache.Retired = true; + } } public McpOAuthTokenSet? GetBoundActive(McpServerName serverName, string resourceIdentity) { var canonical = CanonicalizeResource(resourceIdentity); - lock (GetServerLock(serverName)) - { - var active = GetEnvelope(serverName).Active; - return IsBound(active, canonical) ? active : null; - } + var state = GetState(serverName); + lock (state.Sync) + return IsBound(state.Active, canonical) ? Clone(state.Active) : null; } public bool HasAnyActive(McpServerName serverName) { - lock (GetServerLock(serverName)) - return GetEnvelope(serverName).Active is not null; + var state = GetState(serverName); + lock (state.Sync) + return state.Active is not null; } public bool RequiresAuthorization(McpServerName serverName, string resourceIdentity) @@ -289,263 +232,126 @@ public bool RequiresAuthorization(McpServerName serverName, string resourceIdent } /// - /// Transfers active credential ownership to a newly published ordinary - /// connection. No record is created for a server that has no OAuth credentials. + /// Drops a client identity the authorization server has rejected as + /// invalid_client, so the next explicit authorization registers a fresh one. + /// Tokens are left alone. This replaces a persisted "rejected id" marker: a provider + /// that deletes a client registration would otherwise leave every future + /// authorization reusing an id the server will never accept again. /// - public void ClaimActiveEpoch( + public void ForgetClientIdentity( McpServerName serverName, - McpOAuthClientContext context, + string resourceIdentity, CancellationToken cancellationToken) { - lock (GetServerLock(serverName)) + var canonical = CanonicalizeResource(resourceIdentity); + var state = GetState(serverName); + lock (state.Sync) { - var current = GetEnvelope(serverName); - var view = context.SnapshotCredentialView(); - if (view.Target is not McpOAuthCredentialTarget.Active) - throw new InvalidOperationException("A pending OAuth view cannot claim the active epoch."); - - var committed = CommitEnvelope( - serverName, - current, - latest => - { - if (latest.Active is null) - return view.ExpectedActiveEpoch is null - ? EnvelopeMutation.Unchanged - : EnvelopeMutation.Stale("Active OAuth credentials were removed before publication."); - if (!IsBound(latest.Active, context.CanonicalResource)) - return EnvelopeMutation.Unchanged; - if (string.Equals(latest.Active.CredentialEpoch, view.OwnerEpoch, StringComparison.Ordinal)) - return EnvelopeMutation.Unchanged; - if (!EpochEquals(latest.Active.CredentialEpoch, view.ExpectedActiveEpoch)) - return EnvelopeMutation.Stale("Active OAuth credentials changed before candidate publication."); - - latest.Active.CredentialEpoch = view.OwnerEpoch; - return EnvelopeMutation.Changed; - }, - cancellationToken); + if (!IsBound(state.Active, canonical) + || state.Active is not { DynamicClientRegistration: true, ClientId: not null }) + return; + + var replacement = Clone(state.Active)!; + replacement.ClientId = null; + replacement.ClientSecret = null; + replacement.DynamicClientRegistration = false; + Persist(serverName, replacement, cancellationToken); + state.Active = replacement; + state.Revision++; + if (state.PublishedCache is { } published) + { + published.Credentials = Clone(replacement); + published.BaseRevision = state.Revision; + } - if (IsBound(committed.Active, context.CanonicalResource) - && string.Equals(committed.Active!.CredentialEpoch, view.OwnerEpoch, StringComparison.Ordinal)) - context.Activate(); + _logger.LogWarning( + "Discarded the rejected OAuth client identity for MCP server '{Name}'. " + + "The next authorization will register a new client.", + serverName.Value); } } - public void PromotePending( - McpServerName serverName, - McpOAuthClientContext context, - string flowId, - CancellationToken cancellationToken) + internal McpOAuthTokenSet? GetActiveForTests(McpServerName serverName) { - lock (GetServerLock(serverName)) - { - var current = GetEnvelope(serverName); - var view = context.SnapshotCredentialView(); - CommitEnvelope( - serverName, - current, - latest => - { - var pending = latest.Pending; - if (pending is null - || !string.Equals(pending.FlowId, flowId, StringComparison.Ordinal) - || !string.Equals(pending.Credentials.CredentialEpoch, view.OwnerEpoch, StringComparison.Ordinal)) - { - return EnvelopeMutation.Stale("Pending OAuth credentials no longer belong to this flow epoch."); - } - if (!EpochEquals(latest.Active?.CredentialEpoch, context.BaseActiveEpoch)) - return EnvelopeMutation.Stale("Active OAuth credentials changed while authorization was pending."); - - latest.Active = pending.Credentials; - latest.Pending = null; - latest.RejectedDynamicClientId = null; - return EnvelopeMutation.Changed; - }, - cancellationToken); - context.Activate(); - } + var state = GetState(serverName); + lock (state.Sync) + return Clone(state.Active); } - public void RemovePending(McpServerName serverName, string flowId, CancellationToken cancellationToken) + internal McpOAuthClientIdentity GetIdentity(McpOAuthTokenCache cache) { - lock (GetServerLock(serverName)) - { - var current = GetEnvelope(serverName); - CommitEnvelope( - serverName, - current, - latest => - { - if (latest.Pending is null - || !string.Equals(latest.Pending.FlowId, flowId, StringComparison.Ordinal)) - return EnvelopeMutation.Unchanged; - latest.Pending = null; - return EnvelopeMutation.Changed; - }, - cancellationToken); - } + var state = GetState(cache.ServerName); + lock (state.Sync) + return cache.Identity; } - public void MarkDynamicIdentityRejected( - McpServerName serverName, - string resourceIdentity, - string? configuredClientId, - CancellationToken cancellationToken) + internal TokenContainer? ReadTokens(McpOAuthTokenCache cache, CancellationToken cancellationToken) { - if (!string.IsNullOrWhiteSpace(configuredClientId)) - return; - - var canonical = CanonicalizeResource(resourceIdentity); - lock (GetServerLock(serverName)) + cancellationToken.ThrowIfCancellationRequested(); + var state = GetState(cache.ServerName); + lock (state.Sync) { - var current = GetEnvelope(serverName); - CommitEnvelope( - serverName, - current, - latest => - { - var active = latest.Active; - if (!IsBound(active, canonical) - || active is not { DynamicClientRegistration: true } - || string.IsNullOrWhiteSpace(active.ClientId)) - return EnvelopeMutation.Unchanged; - - latest.RejectedDynamicClientId = active.ClientId; - return EnvelopeMutation.Changed; - }, - cancellationToken); + if (!IsBound(cache.Credentials, cache.CanonicalResource)) + return null; + return ToTokenContainer(cache.Credentials!); } } - internal McpOAuthCredentialEnvelope GetEnvelopeForTests(McpServerName serverName) - { - lock (GetServerLock(serverName)) - return Clone(GetEnvelope(serverName)); - } - - private TokenContainer? ReadTokens( - McpServerName serverName, - McpOAuthClientContext context, + internal void StoreTokens( + McpOAuthTokenCache cache, + TokenContainer tokens, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - lock (GetServerLock(serverName)) + var state = GetState(cache.ServerName); + lock (state.Sync) { - var view = context.SnapshotCredentialView(); - if (view.WithholdAccessToken) - return null; - var envelope = GetEnvelope(serverName); - McpOAuthTokenSet? credentials; - if (view.Target is McpOAuthCredentialTarget.Pending) + ThrowIfRetired(cache); + if (cache.Published && !ReferenceEquals(state.PublishedCache, cache)) + throw new McpOAuthRetiredCredentialWriterException( + "A retired OAuth connection attempted to replace active credentials."); + if (!cache.Published + && !cache.ExplicitAuthorization + && cache.BaseRevision != state.Revision) { - var pending = envelope.Pending; - credentials = pending is not null - && string.Equals(pending.FlowId, view.FlowId, StringComparison.Ordinal) - && string.Equals(pending.Credentials.CredentialEpoch, view.OwnerEpoch, StringComparison.Ordinal) - ? pending.Credentials - : null; + throw new McpOAuthRetiredCredentialWriterException( + "Active OAuth credentials changed while the replacement connection initialized."); } - else + + var replacement = CreateReplacement(tokens, cache.Credentials, cache.Identity, cache.CanonicalResource); + if (cache.Published || !cache.ExplicitAuthorization) + if (cache.Published || !cache.ExplicitAuthorization) { - credentials = envelope.Active is not null - && EpochEquals(envelope.Active.CredentialEpoch, view.ExpectedActiveEpoch) - ? envelope.Active - : null; + Persist(cache.ServerName, replacement, cancellationToken); + state.Active = replacement; + state.Revision++; + cache.BaseRevision = state.Revision; + if (!cache.Published + && state.PublishedCache is { } published) + { + published.Credentials = Clone(replacement); + published.BaseRevision = state.Revision; + } } - if (!IsBound(credentials, context.CanonicalResource)) - return null; - return ToTokenContainer(credentials!); + cache.Credentials = replacement; + cache.Dirty = cache.ExplicitAuthorization && !cache.Published; } } - private void StoreTokens( - McpServerName serverName, - McpOAuthClientContext context, - TokenContainer tokens, - CancellationToken cancellationToken) + /// + /// Adopts a client identity obtained by . The + /// identity reaches disk with the first token store, which is also the first moment + /// it is known to work. + /// + internal void AdoptClientIdentity(McpOAuthTokenCache cache, McpOAuthClientIdentity identity) { - cancellationToken.ThrowIfCancellationRequested(); - lock (GetServerLock(serverName)) + ArgumentNullException.ThrowIfNull(identity); + var state = GetState(cache.ServerName); + lock (state.Sync) { - var current = GetEnvelope(serverName); - var view = context.SnapshotCredentialView(); - var identity = context.SnapshotIdentity(); - string? committedEpoch = null; - CommitEnvelope( - serverName, - current, - latest => - { - McpOAuthTokenSet? retainedFrom; - if (view.Target is McpOAuthCredentialTarget.Active) - { - if (!EpochEquals(latest.Active?.CredentialEpoch, view.ExpectedActiveEpoch)) - return EnvelopeMutation.Stale("A retired OAuth connection attempted to replace active credentials."); - retainedFrom = IsBound(latest.Active, context.CanonicalResource) - ? latest.Active - : null; - if (latest.Active is not null && retainedFrom is null) - return EnvelopeMutation.Stale("Active OAuth credentials are bound to another resource."); - } - else - { - if (string.IsNullOrWhiteSpace(view.FlowId) || view.PendingExpiresAt is null) - return EnvelopeMutation.Stale("Pending OAuth credential context is incomplete."); - if (!EpochEquals(latest.Active?.CredentialEpoch, context.BaseActiveEpoch)) - return EnvelopeMutation.Stale("Active OAuth credentials changed while authorization was pending."); - - if (latest.Pending is null) - { - retainedFrom = IsBound(latest.Active, context.CanonicalResource) - ? latest.Active - : null; - } - else if (string.Equals(latest.Pending.FlowId, view.FlowId, StringComparison.Ordinal) - && string.Equals( - latest.Pending.Credentials.CredentialEpoch, - view.OwnerEpoch, - StringComparison.Ordinal) - && IsBound(latest.Pending.Credentials, context.CanonicalResource)) - { - retainedFrom = latest.Pending.Credentials; - } - else - { - return EnvelopeMutation.Stale("Another OAuth flow owns the pending credential record."); - } - } - - var replacementEpoch = view.Target is McpOAuthCredentialTarget.Pending - ? view.OwnerEpoch - : view.PublishedOwner - ? Guid.NewGuid().ToString("N") - : view.ExpectedActiveEpoch ?? view.OwnerEpoch; - committedEpoch = replacementEpoch; - var replacement = CreateReplacement( - tokens, - retainedFrom, - identity, - context, - replacementEpoch); - if (view.Target is McpOAuthCredentialTarget.Active) - { - latest.Active = replacement; - } - else - { - latest.Pending = new McpOAuthPendingCredential - { - FlowId = view.FlowId!, - ExpiresAt = view.PendingExpiresAt!.Value, - Credentials = replacement, - }; - } - - return EnvelopeMutation.Changed; - }, - cancellationToken); - context.MarkTokensStored(view.Target, committedEpoch!); + ThrowIfRetired(cache); + cache.Identity = identity; } } @@ -553,16 +359,12 @@ private McpOAuthTokenSet CreateReplacement( TokenContainer tokens, McpOAuthTokenSet? retainedFrom, McpOAuthClientIdentity identity, - McpOAuthClientContext context, - string credentialEpoch) + string canonicalResource) { var obtainedAt = tokens.ObtainedAt == default ? _timeProvider.GetUtcNow() : tokens.ObtainedAt; - return new McpOAuthTokenSet + var replacement = new McpOAuthTokenSet { AccessToken = new SensitiveString(tokens.AccessToken), - RefreshToken = tokens.RefreshToken is not null - ? new SensitiveString(tokens.RefreshToken) - : retainedFrom?.RefreshToken, ExpiresAt = tokens.ExpiresIn is { } expiresIn ? obtainedAt.AddSeconds(expiresIn) : null, @@ -572,15 +374,33 @@ private McpOAuthTokenSet CreateReplacement( ClientId = identity.ClientId, ClientSecret = identity.ClientSecret is null ? null : new SensitiveString(identity.ClientSecret), DynamicClientRegistration = identity.DynamicClientRegistration, - ResourceIdentity = context.CanonicalResource, - CredentialEpoch = credentialEpoch, + ResourceIdentity = canonicalResource, }; + replacement.RefreshToken = tokens.RefreshToken is not null + ? new SensitiveString(tokens.RefreshToken) + : CanRetainRefreshToken(retainedFrom, replacement) + ? retainedFrom!.RefreshToken + : null; + return replacement; } - private static TokenContainer ToTokenContainer(McpOAuthTokenSet credentials) + private static bool CanRetainRefreshToken(McpOAuthTokenSet? current, McpOAuthTokenSet replacement) + => current?.RefreshToken is not null + && string.Equals(current.ResourceIdentity, replacement.ResourceIdentity, StringComparison.Ordinal) + && string.Equals(current.ClientId, replacement.ClientId, StringComparison.Ordinal) + && current.DynamicClientRegistration == replacement.DynamicClientRegistration; + + private TokenContainer ToTokenContainer(McpOAuthTokenSet credentials) { + // Records written before ObtainedAt existed deserialize it as 0001-01-01. Anchoring + // the lifetime at "now" keeps ExpiresAt authoritative; measuring from the default + // produces ~6.4e10 seconds, which saturates the int conversion to int.MaxValue and + // makes the SDK treat every migrated credential as permanently expired. + var obtainedAt = credentials.ObtainedAt == default + ? _timeProvider.GetUtcNow() + : credentials.ObtainedAt; var expiresIn = credentials.ExpiresAt is { } expiresAt - ? (int)Math.Max(0, (expiresAt - credentials.ObtainedAt).TotalSeconds) + ? (int)Math.Clamp((expiresAt - obtainedAt).TotalSeconds, 0, int.MaxValue) : (int?)null; return new TokenContainer { @@ -588,150 +408,197 @@ private static TokenContainer ToTokenContainer(McpOAuthTokenSet credentials) AccessToken = credentials.AccessToken.Value, RefreshToken = credentials.RefreshToken?.Value, ExpiresIn = expiresIn, - ObtainedAt = credentials.ObtainedAt, + ObtainedAt = obtainedAt, Scope = credentials.Scope, }; } - private McpOAuthCredentialEnvelope CommitEnvelope( + private void Persist( McpServerName serverName, - McpOAuthCredentialEnvelope current, - Func mutate, + McpOAuthTokenSet credentials, CancellationToken cancellationToken) { - var outcome = SecretsFileWriter.Update( + SecretsFileWriter.Update( _paths.SecretsPath, (root, _) => { var section = root[SectionKey]?.AsObject() ?? []; root[SectionKey] = section; - var latest = ReadEnvelope(section[serverName.Value]) ?? Clone(current); - var mutation = mutate(latest); - if (mutation.StaleReason is not null) - return (null, new EnvelopeCommitResult(latest, mutation.StaleReason)); - if (!mutation.HasChanges) - return (null, new EnvelopeCommitResult(latest, null)); - - section[serverName.Value] = JsonSerializer.SerializeToNode(latest, JsonOptions); - return (root, new EnvelopeCommitResult(latest, null)); + section[serverName.Value] = JsonSerializer.SerializeToNode(credentials, JsonOptions); + return (root, null); }, JsonOptions, _protector, cancellationToken); - - _credentials[serverName] = outcome.Envelope; - if (outcome.StaleReason is not null) - throw new McpOAuthStaleCredentialEpochException(outcome.StaleReason); - return outcome.Envelope; } - private void LoadAndRecoverDurableState() + private void LoadDurableState() { if (!File.Exists(_paths.SecretsPath)) return; - var loaded = SecretsFileWriter.Update>( - _paths.SecretsPath, - (root, _) => - { - var result = new Dictionary(); - var changed = false; - if (root[SectionKey] is not JsonObject section) - return (null, result); - - foreach (var (name, node) in section) + Dictionary loaded; + try + { + loaded = SecretsFileWriter.Update>( + _paths.SecretsPath, + (root, _) => { - if (node is null) - continue; - var envelope = ReadEnvelope(node); - if (envelope is null) - continue; - - // A pending record cannot resume without its broker flow. A - // promoted active record is complete durable state and remains - // usable after a crash between promotion and publication. - if (envelope.Pending is not null) - { - envelope.Pending = null; - changed = true; - } - if (envelope.Active is { ResourceIdentity: not null, CredentialEpoch: null }) + var result = new Dictionary(); + if (root[SectionKey] is not JsonObject section) + return (null, result); + foreach (var (name, node) in section) { - envelope.Active.CredentialEpoch = Guid.NewGuid().ToString("N"); - changed = true; + var credentials = node?.Deserialize(JsonOptions); + if (credentials is not null) + result[new McpServerName(name)] = credentials; } + return (null, result); + }, + JsonOptions, + _protector); + } + catch (Exception ex) + { + // This runs in a singleton constructor and decrypts the whole secrets file, so + // one unreadable leaf anywhere in it would otherwise take down daemon startup. + // Losing cached credentials costs a reauthorization; losing the daemon costs + // every channel, webhook, and schedule. + _logger.LogError(ex, + "Failed to load MCP OAuth credentials from {Path}. " + + "Affected MCP servers will require reauthorization.", + _paths.SecretsPath); + return; + } - if (changed) - section[name] = JsonSerializer.SerializeToNode(envelope, JsonOptions); - result[new McpServerName(name)] = envelope; - } - - return (changed ? root : null, result); - }, - JsonOptions, - _protector); - - foreach (var (name, envelope) in loaded) - _credentials[name] = envelope; + foreach (var (name, credentials) in loaded) + _servers[name] = new ServerCredentialState(credentials); if (loaded.Count > 0) _logger.LogDebug("Loaded MCP OAuth credentials for {Count} server(s)", loaded.Count); } - private object GetServerLock(McpServerName serverName) - => _serverLocks.GetOrAdd(serverName, static _ => new object()); - - private McpOAuthCredentialEnvelope GetEnvelope(McpServerName serverName) - => _credentials.GetOrAdd(serverName, static _ => new McpOAuthCredentialEnvelope()); + private ServerCredentialState GetState(McpServerName serverName) + => _servers.GetOrAdd(serverName, static _ => new ServerCredentialState(null)); - private static bool IsBound(McpOAuthTokenSet? credentials, string canonicalResource) - => credentials?.ResourceIdentity is { } binding - && string.Equals(binding, canonicalResource, StringComparison.Ordinal); - - private static bool EpochEquals(string? left, string? right) - => string.Equals(left, right, StringComparison.Ordinal); - - private static McpOAuthCredentialEnvelope? ReadEnvelope(JsonNode? node) + private McpOAuthTokenSet? GetBoundOrMigrateLegacy( + McpServerName serverName, + ServerCredentialState state, + string canonicalResource, + string? configuredClientId) { - if (node is not JsonObject obj) + if (IsBound(state.Active, canonicalResource)) + return Clone(state.Active); + if (state.Active is not { ResourceIdentity: null, McpServerUrl: not null } legacy) return null; - if (obj.ContainsKey(nameof(McpOAuthCredentialEnvelope.Active)) - || obj.ContainsKey(nameof(McpOAuthCredentialEnvelope.Pending)) - || obj.ContainsKey(nameof(McpOAuthCredentialEnvelope.RejectedDynamicClientId))) - return obj.Deserialize(JsonOptions); - var legacy = obj.Deserialize(JsonOptions); - return legacy is null ? null : new McpOAuthCredentialEnvelope { Active = legacy }; - } + string legacyResource; + try + { + legacyResource = CanonicalizeResource(legacy.McpServerUrl); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, + "Legacy MCP OAuth credentials for '{Name}' have an invalid resource binding", + serverName.Value); + return null; + } - private static McpOAuthCredentialEnvelope Clone(McpOAuthCredentialEnvelope envelope) - => JsonSerializer.Deserialize( - JsonSerializer.Serialize(envelope, JsonOptions), JsonOptions)!; + if (!IsEquivalentResource(legacyResource, canonicalResource)) + { + // Silence here reads as "no credentials" and sends the operator to + // reauthorization with no way to see why the stored ones were withheld. + _logger.LogWarning( + "Legacy MCP OAuth credentials for '{Name}' are bound to {LegacyResource}, " + + "which does not match the configured endpoint {ConfiguredResource}. " + + "Authorization is required for the configured endpoint.", + serverName.Value, + legacyResource, + canonicalResource); + return null; + } - private sealed record EnvelopeCommitResult( - McpOAuthCredentialEnvelope Envelope, - string? StaleReason); + var migrated = Clone(legacy)!; + migrated.ResourceIdentity = canonicalResource; + + // McpServerUrl stays populated. Dropping it made migration a one-way transform: an + // endpoint corrected afterwards had no second chance to match, and a rollback to a + // release that reads this field lost the binding entirely. + if (migrated.ObtainedAt == default) + migrated.ObtainedAt = _timeProvider.GetUtcNow(); + if (string.IsNullOrWhiteSpace(configuredClientId) + && !string.IsNullOrWhiteSpace(migrated.ClientId)) + migrated.DynamicClientRegistration = true; + Persist(serverName, migrated, CancellationToken.None); + state.Active = migrated; + state.Revision++; + _logger.LogInformation( + "Migrated legacy MCP OAuth credentials for '{Name}' after exact resource match", + serverName.Value); + return Clone(migrated); + } - private readonly record struct EnvelopeMutation(bool HasChanges, string? StaleReason) + /// + /// Whether a legacy McpServerUrl may be migrated onto the configured endpoint. + /// Legacy records stored the RFC 8707 resource indicator, not the endpoint, so an + /// ordinal match is stricter than the data supports. Scheme and authority must still + /// agree exactly — a stored token's audience is its origin, and bridging origins would + /// hand credentials to a different host. + /// + private static bool IsEquivalentResource(string legacy, string configured) { - public static EnvelopeMutation Changed => new(true, null); + if (string.Equals(legacy, configured, StringComparison.Ordinal)) + return true; + + if (!Uri.TryCreate(legacy, UriKind.Absolute, out var legacyUri) + || !Uri.TryCreate(configured, UriKind.Absolute, out var configuredUri)) + return false; + + if (!string.Equals(legacyUri.Scheme, configuredUri.Scheme, StringComparison.OrdinalIgnoreCase) + || !string.Equals(legacyUri.Authority, configuredUri.Authority, StringComparison.OrdinalIgnoreCase)) + return false; + + var legacyPath = legacyUri.AbsolutePath.TrimEnd('/'); + var configuredPath = configuredUri.AbsolutePath.TrimEnd('/'); + + // Trailing slash and path case describe the same endpoint. The query still has to + // agree — it can select a tenant, and a different tenant is a different resource. + if (string.Equals(legacyPath, configuredPath, StringComparison.OrdinalIgnoreCase) + && string.Equals(legacyUri.Query, configuredUri.Query, StringComparison.Ordinal)) + return true; + + // Providers commonly publish the resource indicator as the bare origin while the + // MCP endpoint sits on a path beneath it. That covers the whole origin, so it also + // covers the configured path and query. Only this narrowing direction is accepted; + // a path-scoped credential is never widened to a sibling path. + return legacyPath.Length == 0 && legacyUri.Query.Length == 0; + } - public static EnvelopeMutation Unchanged => new(false, null); + private static bool IsBound(McpOAuthTokenSet? credentials, string canonicalResource) + => credentials?.ResourceIdentity is { } binding + && string.Equals(binding, canonicalResource, StringComparison.Ordinal); - public static EnvelopeMutation Stale(string reason) => new(false, reason); + private static void ThrowIfRetired(McpOAuthTokenCache cache) + { + if (cache.Retired) + throw new McpOAuthRetiredCredentialWriterException( + "A retired OAuth connection attempted to replace active credentials."); } - private sealed class CredentialTokenCache( - McpOAuthCredentialStore store, - McpServerName serverName, - McpOAuthClientContext context) : ITokenCache + private static McpOAuthTokenSet? Clone(McpOAuthTokenSet? credentials) + => credentials is null + ? null + : JsonSerializer.Deserialize( + JsonSerializer.Serialize(credentials, JsonOptions), JsonOptions)!; + + private sealed class ServerCredentialState(McpOAuthTokenSet? active) { - public ValueTask GetTokensAsync(CancellationToken cancellationToken) - => new(store.ReadTokens(serverName, context, cancellationToken)); + public object Sync { get; } = new(); - public ValueTask StoreTokensAsync(TokenContainer tokens, CancellationToken cancellationToken) - { - store.StoreTokens(serverName, context, tokens, cancellationToken); - return default; - } + public McpOAuthTokenSet? Active { get; set; } = active; + + public int Revision { get; set; } + + public McpOAuthTokenCache? PublishedCache { get; set; } } } diff --git a/src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs b/src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs index c9fa20448..a776b8d08 100644 --- a/src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs +++ b/src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs @@ -21,7 +21,6 @@ internal sealed class McpOAuthFlowBroker : IDisposable internal static readonly TimeSpan FlowLifetime = TimeSpan.FromMinutes(5); private readonly object _sync = new(); - private readonly Dictionary _activeByServer = []; private readonly Dictionary _latestByServer = []; private readonly Dictionary _byState = new(StringComparer.Ordinal); private readonly TimeProvider _timeProvider; @@ -40,7 +39,7 @@ public McpOAuthFlowStart StartOrJoin(McpServerName serverName) { ObjectDisposedException.ThrowIf(_disposed, this); PruneTombstones(); - if (_activeByServer.TryGetValue(serverName, out var active) && !active.IsTerminal) + if (_latestByServer.TryGetValue(serverName, out var active) && !active.IsTerminal) return new McpOAuthFlowStart(active, false); var state = CreateOpaqueState(); @@ -51,7 +50,6 @@ public McpOAuthFlowStart StartOrJoin(McpServerName serverName) _timeProvider, _daemonCancellation, OnFlowExpired); - _activeByServer[serverName] = flow; _latestByServer[serverName] = flow; _byState[state] = flow; return new McpOAuthFlowStart(flow, true); @@ -61,7 +59,12 @@ public McpOAuthFlowStart StartOrJoin(McpServerName serverName) public bool TryGetActive(McpServerName serverName, out McpOAuthFlow? flow) { lock (_sync) - return _activeByServer.TryGetValue(serverName, out flow); + { + if (_latestByServer.TryGetValue(serverName, out flow) && !flow.IsTerminal) + return true; + flow = null; + return false; + } } public McpOAuthFlowStatus GetStatus(McpServerName serverName) @@ -102,7 +105,6 @@ public McpOAuthFlow GetForCallback(string state) flow.Fail(new McpErrorResponse( "Authorization expired. Start a new MCP authorization attempt.", "callback validation")); - RemoveActive(flow); throw new McpOAuthCallbackException("The authorization state has expired."); } @@ -113,22 +115,18 @@ public McpOAuthFlow GetForCallback(string state) } public void Complete(McpOAuthFlow flow) - { - flow.Complete(); - lock (_sync) - RemoveActive(flow); - } + => flow.Complete(); /// /// Claims the unexpired flow for the non-cancellable commit sequence: - /// durable promotion, cache activation, publication, then completion. + /// durable credential commit, cache publication, then completion. /// public void BeginCommit(McpOAuthFlow flow) { lock (_sync) { var now = _timeProvider.GetUtcNow(); - if (!_activeByServer.TryGetValue(flow.ServerName, out var active) + if (!_latestByServer.TryGetValue(flow.ServerName, out var active) || !ReferenceEquals(active, flow) || !flow.TryBeginCommit(now)) { @@ -137,7 +135,6 @@ public void BeginCommit(McpOAuthFlow flow) flow.Fail(new McpErrorResponse( "Authorization expired before credentials could publish. Start a new attempt.", "credential commit")); - RemoveActive(flow); } throw new McpOAuthOperationException(new McpErrorResponse( @@ -148,11 +145,7 @@ public void BeginCommit(McpOAuthFlow flow) } public void Fail(McpOAuthFlow flow, McpErrorResponse error) - { - flow.Fail(error); - lock (_sync) - RemoveActive(flow); - } + => flow.Fail(error); public void Dispose() { @@ -162,8 +155,7 @@ public void Dispose() if (_disposed) return; _disposed = true; - flows = _byState.Values.Distinct().ToList(); - _activeByServer.Clear(); + flows = _byState.Values.ToList(); _latestByServer.Clear(); _byState.Clear(); } @@ -184,17 +176,9 @@ private void OnFlowExpired(McpOAuthFlow flow) flow.Fail(new McpErrorResponse( "Authorization expired. Start a new MCP authorization attempt.", "authorization timeout")); - RemoveActive(flow); } } - private void RemoveActive(McpOAuthFlow flow) - { - if (_activeByServer.TryGetValue(flow.ServerName, out var current) - && ReferenceEquals(current, flow)) - _activeByServer.Remove(flow.ServerName); - } - private void PruneTombstones() { var cutoff = _timeProvider.GetUtcNow() - FlowLifetime; @@ -276,11 +260,11 @@ public McpOAuthFlowTerminal Terminal } } - public async Task WaitForAuthorizationUrlAsync(CancellationToken requestCancellation) - => await _authorizationUrl.Task.WaitAsync(requestCancellation); + public Task WaitForAuthorizationUrlAsync(CancellationToken requestCancellation) + => _authorizationUrl.Task.WaitAsync(requestCancellation); - public async Task WaitForTerminalAsync(CancellationToken requestCancellation) - => await _terminal.Task.WaitAsync(requestCancellation); + public Task WaitForTerminalAsync(CancellationToken requestCancellation) + => _terminal.Task.WaitAsync(requestCancellation); public async Task HandleAuthorizationRedirectAsync( Uri authorizationUri, diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index 297e8b178..38eb8eb1b 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -792,6 +792,10 @@ static void ConfigureDaemonServices( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>())); + services.AddHttpClient("McpOAuth").AddNetclawHeaders("mcp-oauth"); + services.AddSingleton(sp => new McpOAuthClientRegistrar( + sp.GetRequiredService().CreateClient("McpOAuth"), + sp.GetRequiredService>())); services.AddSingleton(sp => new McpOAuthFlowBroker( sp.GetRequiredService(), sp.GetRequiredService().ApplicationStopping)); diff --git a/src/Netclaw.Daemon/Security/BootstrapDeviceSeeder.cs b/src/Netclaw.Daemon/Security/BootstrapDeviceSeeder.cs index 7a1d2f5a1..ba715ce8c 100644 --- a/src/Netclaw.Daemon/Security/BootstrapDeviceSeeder.cs +++ b/src/Netclaw.Daemon/Security/BootstrapDeviceSeeder.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using System.Buffers.Text; using System.Security.Cryptography; +using System.Text.Json; using Microsoft.Extensions.Logging; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; @@ -52,7 +53,8 @@ public async Task EnsureSeededAsync(DaemonConfig config, CancellationToken if (devices.Count > 0) return false; - if (HasDeviceToken()) + var existingSecrets = LoadSecrets(); + if (HasDeviceToken(existingSecrets)) return false; var tokenBytes = RandomNumberGenerator.GetBytes(32); @@ -73,36 +75,8 @@ public async Task EnsureSeededAsync(DaemonConfig config, CancellationToken }; await _deviceRegistry.AddAsync(device, cancellationToken); - var committed = false; - try - { - committed = SecretsFileWriter.Update( - _paths.SecretsPath, - (root, _) => - { - if (root.TryGetPropertyValue("DeviceToken", out var existing) - && existing?.ToString() is { Length: > 0 }) - return (null, false); - - root["configVersion"] ??= 1; - root["DeviceToken"] = rawToken; - return (root, true); - }, - protector: _protector, - cancellationToken: cancellationToken); - } - catch (Exception ex) - { - await RollBackDeviceAsync(device.Name, ex); - throw; - } - - if (!committed) - { - await _deviceRegistry.RemoveAsync(device.Name, CancellationToken.None); - return false; - } - + existingSecrets["DeviceToken"] = rawToken; + SecretsFileWriter.Write(_paths.SecretsPath, existingSecrets, protector: _protector); _logger.LogInformation( "Seeded bootstrap paired device '{DeviceName}' for first non-local daemon start.", device.Name); @@ -112,35 +86,29 @@ public async Task EnsureSeededAsync(DaemonConfig config, CancellationToken public void MarkCompleted() => _bootstrapStateStore.MarkCompleted(_timeProvider); - private async Task RollBackDeviceAsync(string deviceName, Exception persistenceException) + private Dictionary LoadSecrets() { + if (!File.Exists(_paths.SecretsPath)) + return new Dictionary { ["configVersion"] = 1 }; + try { - await _deviceRegistry.RemoveAsync(deviceName, CancellationToken.None); + var text = File.ReadAllText(_paths.SecretsPath); + var decrypted = _protector is not null + ? SecretsFileWriter.DecryptJsonLeaves(text, _protector) + : text; + return JsonSerializer.Deserialize>(decrypted) + ?? new Dictionary { ["configVersion"] = 1 }; } - catch (Exception rollbackException) + catch (JsonException) { - throw new InvalidOperationException( - "Failed to persist bootstrap device token and failed to roll back the paired device.", - new AggregateException(persistenceException, rollbackException)); + return new Dictionary { ["configVersion"] = 1 }; } } - private bool HasDeviceToken() + private static bool HasDeviceToken(Dictionary secrets) { - if (!File.Exists(_paths.SecretsPath)) - return false; - - var tokenFound = SecretsFileWriter.Update( - _paths.SecretsPath, - (root, _) => - { - var found = root.TryGetPropertyValue("DeviceToken", out var existing) - && existing?.ToString() is { Length: > 0 }; - return (null, found); - }, - protector: _protector); - - return tokenFound; + return secrets.TryGetValue("DeviceToken", out var existing) + && existing?.ToString() is { Length: > 0 }; } } diff --git a/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs b/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs index ee1bea0db..11f1a3468 100644 --- a/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs +++ b/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs @@ -29,34 +29,38 @@ public static void PersistTokens( { paths.EnsureDirectoriesExist(); - SecretsFileWriter.Update( - paths.SecretsPath, - (root, _) => - { - var providers = root["Providers"]?.AsObject() ?? []; - root["Providers"] = providers; - - var providerNode = providers[providerName]?.AsObject() ?? []; - providers[providerName] = providerNode; - - providerNode["OAuthAccessToken"] = result.AccessToken.Value; - - // Preserve any previously-stored refresh token / account id when the new - // result omits them. An OAuth response that doesn't echo refresh_token means - // "keep using the existing one" (RFC 6749 §5.1), and a partial refresh that - // lacks the ChatGPT account id must not wipe a value the Codex backend still - // requires. (OAuthTokenExpiry below is still cleared on null — a stale expiry - // is worse than an absent one.) - if (result.RefreshToken is not null) - providerNode["OAuthRefreshToken"] = result.RefreshToken.Value; - - if (result.AccountId is not null) - providerNode["OAuthAccountId"] = result.AccountId.Value; - - return (root, true); - }, - new JsonSerializerOptions { WriteIndented = true }, - protector); + // Load existing secrets as a JSON object tree to preserve other entries + var existingJson = File.Exists(paths.SecretsPath) + ? File.ReadAllText(paths.SecretsPath) + : "{}"; + + var root = JsonNode.Parse(existingJson)?.AsObject() ?? []; + var providers = root["Providers"]?.AsObject() ?? []; + root["Providers"] = providers; + + var providerNode = providers[providerName]?.AsObject() ?? []; + providers[providerName] = providerNode; + + providerNode["OAuthAccessToken"] = result.AccessToken.Value; + + // Preserve any previously-stored refresh token / account id when the new + // result omits them. An OAuth response that doesn't echo refresh_token means + // "keep using the existing one" (RFC 6749 §5.1), and a partial refresh that + // lacks the ChatGPT account id must not wipe a value the Codex backend still + // requires. (OAuthTokenExpiry below is still cleared on null — a stale expiry + // is worse than an absent one.) + if (result.RefreshToken is not null) + providerNode["OAuthRefreshToken"] = result.RefreshToken.Value; + + if (result.AccountId is not null) + providerNode["OAuthAccountId"] = result.AccountId.Value; + + // OAuthTokenExpiry is NOT a secret and must NOT go in secrets.json. + // SecretsFileWriter encrypts the entire file, and encrypted DateTimeOffset + // values break IConfiguration binding (silently drops the provider entry). + // Write expiry to netclaw.json instead. + var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + SecretsFileWriter.Write(paths.SecretsPath, json, protector); PersistTokenExpiry(paths, providerName, result.ExpiresAt); } diff --git a/tests/Netclaw.SecretsLockProbe/Netclaw.SecretsLockProbe.csproj b/tests/Netclaw.SecretsLockProbe/Netclaw.SecretsLockProbe.csproj deleted file mode 100644 index ffa811758..000000000 --- a/tests/Netclaw.SecretsLockProbe/Netclaw.SecretsLockProbe.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - Exe - net10.0 - enable - enable - false - - - - - - - diff --git a/tests/Netclaw.SecretsLockProbe/Program.cs b/tests/Netclaw.SecretsLockProbe/Program.cs deleted file mode 100644 index e0f0eb410..000000000 --- a/tests/Netclaw.SecretsLockProbe/Program.cs +++ /dev/null @@ -1,38 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Configuration; - -if (args.Length != 2) -{ - Console.Error.WriteLine("Usage: Netclaw.SecretsLockProbe "); - return 2; -} - -try -{ - var committed = SecretsFileWriter.Update( - args[0], - (root, _) => - { - Console.Out.WriteLine("entered"); - Console.Out.Flush(); - - var command = Console.In.ReadLine(); - if (!string.Equals(command, "release", StringComparison.Ordinal)) - throw new InvalidOperationException($"Unexpected command '{command}'."); - - root["Child"] = args[1]; - return (root, true); - }); - - Console.Out.WriteLine(committed ? "committed" : "not-committed"); - return committed ? 0 : 3; -} -catch (Exception ex) -{ - Console.Error.WriteLine(ex); - return 1; -} From 3500e8cb5a57625f45c127f50d03990b26fd3612 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 27 Jul 2026 18:45:15 +0000 Subject: [PATCH 04/13] Drop a legacy-mismatch test duplicated by its theory LegacyResourceFromADifferentAudienceFailsClosed already covers https://mcp.example.com/tools as an InlineData case, with the same assertions MismatchedLegacyResourceFailsClosedWithoutChangingDisk made. --- .../Mcp/McpOAuthCredentialStoreTests.cs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs index bb2189533..4667c71c8 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs @@ -352,19 +352,6 @@ public void UnreadableSecretsFileLeavesTheDaemonStartable() Assert.Null(store.GetActiveForTests(ServerName)); } - [Fact] - public async Task MismatchedLegacyResourceFailsClosedWithoutChangingDisk() - { - var paths = Paths(); - WriteLegacyCredentials(paths, "https://mcp.example.com/tools"); - var store = CreateStore(paths); - var cache = store.CreateTokenCache(ServerName, Resource, null, false); - - Assert.Null(await cache.GetTokensAsync(CancellationToken.None)); - Assert.Null(store.GetIdentity(cache).ClientId); - Assert.Contains("legacy-access", File.ReadAllText(paths.SecretsPath), StringComparison.Ordinal); - } - [Fact] public async Task ForgettingARejectedClientIdentityKeepsTokensAndForcesReregistration() { From e94a85697d5faa8a2b805795c66a2c83a8b0c82b Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 27 Jul 2026 18:55:23 +0000 Subject: [PATCH 05/13] Correct the transactional-secrets claims to match what shipped Copilot flagged that the change claimed cross-process serialization of secrets.json while the implementation locks within one process. It was right, and the drift was wider than the one file. The comment on AcquireSecretsLock justified the in-process gate with "the CLI writes while it is stopped." Nothing enforces that. netclaw secrets set and netclaw provider add run against a live daemon and still write the file without taking the gate, so a CLI write can lose against a concurrent token refresh. That hazard is unchanged from before this branch, where every caller performed an unlocked read-modify-write, but the comment read as though it were covered. Corrected to match the code: - transactional-secrets spec no longer requires a cross-process lock or that every caller mutate inside the transaction, and its cross-process scenario is replaced by the symlinked-path case the tests actually cover. The remaining hazard is stated rather than implied away. - design goal and D4 amendment drop the cross-process wording and record why the lock and the caller migration are deferred together instead of half-done. - tasks 7.1 and 7.3 describe the in-process lock; 7.2 is reopened as partially delivered, naming the callers still doing unlocked writes. - tasks 1.1, 2.4, 2.5, 3.3 and 4.7 drop invocation-lease and drain language for what shipped; 4.6 and 4.8 are reopened as deferred with the drain work. --- .../simplify-mcp-oauth-lifecycle/design.md | 11 +++--- .../specs/transactional-secrets/spec.md | 35 +++++++++++-------- .../simplify-mcp-oauth-lifecycle/tasks.md | 20 +++++------ .../SecretsFileWriter.cs | 12 +++++-- 4 files changed, 47 insertions(+), 31 deletions(-) diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/design.md b/openspec/changes/simplify-mcp-oauth-lifecycle/design.md index 12369cd88..7c130019f 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/design.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/design.md @@ -69,7 +69,7 @@ cannot delegate: client lifecycle and durable local state. - Persist per-server token sets plus the DCR-issued client ID durably before publishing them in memory; bind them to the configured MCP resource identity; fail the connection loudly on persistence failure or binding mismatch. -- Serialize `secrets.json` read/modify/write with a cross-process lock so +- Serialize `secrets.json` read/modify/write with a path-scoped lock so concurrent writers to different sections cannot lose each other's update. - Derive the callback URI from `DaemonConfig.Port`. - Return structured, actionable OAuth diagnostics; never print a blank error. @@ -193,9 +193,12 @@ encryption, and `AtomicFile` replacement with permission hardening. **Amended during implementation:** the lock is in-process rather than a named cross-process mutex, and its key resolves only the immediate parent directory's -symlink rather than every path segment. The daemon is the sole runtime writer -and the CLI writes while it is stopped, so cross-process contention is a -separate, pre-existing concern. Caller migration is limited to the credential +symlink rather than every path segment. Nothing enforces that the CLI writes only +while the daemon is stopped, so a `netclaw secrets set` issued against a live +daemon can still lose against a concurrent token refresh — the same hazard as +before this change, when every caller performed an unlocked read-modify-write. +Closing it needs a cross-process lock *and* the remaining callers moved onto the +transaction; both are deferred together rather than half-done. Caller migration is limited to the credential store and the two TUI save paths that were overwriting daemon-written `McpOAuthTokens`; the remaining CLI and provider callers, and transactional rollback for `ProviderRenamer` and `BootstrapDeviceSeeder`, move to their own diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/specs/transactional-secrets/spec.md b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/transactional-secrets/spec.md index 88864bacd..c1053721b 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/specs/transactional-secrets/spec.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/transactional-secrets/spec.md @@ -5,17 +5,24 @@ ### Requirement: Secrets mutation is a serialized transaction The system SHALL provide a path-scoped transactional update operation for -secrets.json that derives its lock identity from the canonical path, then holds -one cross-process lock across reading the latest file, decryption of encrypted -leaves, JSON parsing, the caller's mutation, serialization, encryption, and -atomic file replacement with permission hardening. Every read-modify-write caller of -secrets.json SHALL express its owned field mutations against the latest -document read inside this transaction; acquiring the lock and then writing a -whole-file snapshot captured before the lock SHALL NOT satisfy this -requirement. Intentional whole-file replacement MAY retain the direct write -path but SHALL participate in the same path lock. -Existing encryption behavior and file-permission hardening SHALL be -preserved. +secrets.json that derives its lock identity from the canonical path — resolving a +symlinked parent directory so two spellings of one file share a lock — then holds +that lock across reading the latest file, decryption of encrypted leaves, JSON +parsing, the caller's mutation, serialization, encryption, and atomic file +replacement with permission hardening. A caller that adopts this transaction SHALL +express its owned field mutations against the latest document read inside it; +acquiring the lock and then writing a whole-file snapshot captured before the lock +SHALL NOT satisfy this requirement. Intentional whole-file replacement MAY retain +the direct write path but SHALL participate in the same path lock. Existing +encryption behavior and file-permission hardening SHALL be preserved. + +The lock is scoped to one process. Serializing writers in *different* processes, +and moving the remaining CLI and provider callers onto this transaction, are +deferred. Until both land, a `netclaw secrets set` or `netclaw provider add` +issued against a running daemon can still lose against a concurrent token +refresh. That hazard is unchanged from the previous release, where every caller +performed an unlocked read-modify-write, and this requirement SHALL NOT be read +as closing it. #### Scenario: Concurrent updates to different sections both survive @@ -27,14 +34,14 @@ preserved. #### Scenario: MCP token refresh races an operator secret update - **GIVEN** an MCP OAuth token refresh persists rotated credentials -- **AND** a CLI or config operation concurrently updates another secret section +- **AND** a config-editor or wizard save concurrently updates another secret section - **WHEN** both operations complete - **THEN** neither update is lost - **AND** the file remains well-formed, encrypted per existing policy, and permission-hardened -#### Scenario: Cross-process writers serialize +#### Scenario: Two spellings of one path share a lock -- **GIVEN** two processes attempt transactional updates against the same secrets path +- **GIVEN** two writers reach the same secrets file through different paths, one via a symlinked config directory - **WHEN** their transactions overlap - **THEN** the second transaction observes the first transaction's committed state before applying its mutation diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md b/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md index 3589ca5a1..ba8099f31 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md @@ -8,7 +8,7 @@ never `Thread.Sleep` or `Task.Delay` in orchestration. ## 1. PR 1 — Connection snapshot and generation model -- [x] 1.1 Define an immutable per-server connection snapshot type carrying the `McpClient`, its function/tool map, a monotonically increasing generation number, status metadata (including the `TimeProvider`-derived last error timestamp), and invocation lease state. Done when the published data is read-only and one lease owner can retire and drain the generation safely. +- [x] 1.1 Define an immutable per-server connection snapshot type carrying the `McpClient`, its function/tool map, a monotonically increasing generation number, and status metadata (including the `TimeProvider`-derived last error timestamp). Done when the published data is read-only. Invocation lease state is deferred with the drain work. - [x] 1.2 Replace `McpClientManager`'s separate client and tool dictionaries with one published snapshot reference per server. Done when no code path reads the client and its tools as independent dictionaries. - [x] 1.3 Update `ToolRegistry` and status reporting to read from the same snapshot so connection state, tool count, and error info always change together. Done when a single lifecycle operation is the only writer of all three. @@ -17,14 +17,14 @@ never `Thread.Sleep` or `Task.Delay` in orchestration. - [x] 2.1 Add a per-server async lifecycle gate guarding create, publish, replace, and teardown. Done when every lifecycle transition for a server passes through one gate. - [x] 2.2 Implement coalescing reconnect: capture the observed generation, enter the gate, re-read the published snapshot, and if a newer healthy generation already exists, reuse it and return. Done when concurrent reconnects that observed the same generation converge on one winner. - [x] 2.3 Build the candidate without removing the published connection; initialize it fully (`ListToolsAsync` plus function-map construction) before publication; on init failure dispose only the candidate and keep the published connection. Done when a failed candidate leaves the prior generation serving. -- [x] 2.4 Publish the candidate atomically as the next generation, retire the replaced generation so it accepts no new invocation leases, and dispose it exactly once after its final in-flight lease ends. Done when no routine replacement interrupts an in-flight invocation and no generation is leaked or double-disposed. -- [x] 2.5 Serialize shutdown: `StopAsync` enters each server's gate, marks shutdown, rejects new leases and reconnects, allows a bounded invocation drain, cancels any remainder with the daemon shutdown token, then removes and disposes the snapshot. Done when no connection can be published after shutdown starts and no client is disposed while its invocation is still executing. +- [x] 2.4 Publish the candidate atomically as the next generation and dispose the replaced generation exactly once. Done when no generation is leaked or double-disposed. Disposal happens as soon as the replacement publishes, so an in-flight invocation on the replaced client is cancelled rather than drained; draining is deferred. +- [x] 2.5 Serialize shutdown: `StopAsync` enters each server's gate, marks shutdown, rejects new reconnects, then removes and disposes the snapshot. Done when no connection can be published after shutdown starts. The bounded invocation drain is deferred, so a call in flight at shutdown is cancelled. ## 3. PR 1 — Invocation classification - [x] 3.1 Format tool-declared and application MCP errors as tool results with no teardown or reconnect. Done when a tool-declared error returns a result and leaves the connection published. - [x] 3.2 Propagate caller-token `OperationCanceledException` and unknown application exceptions with no teardown or retry. Done when cancellation reaches the caller and the shared connection stays live. -- [x] 3.3 Classify transport/session failures, return the failed invocation as an error without replay, release its snapshot lease, and only then request or await at most one coalesced reconnect for later calls. Done when an ambiguous failure never invokes the remote tool a second time and reconnect cannot wait on the caller's own lease. +- [x] 3.3 Classify transport/session failures, return the failed invocation as an error without replay, and only then request or await at most one coalesced reconnect for later calls. Done when an ambiguous failure never invokes the remote tool a second time. ## 4. PR 1 — Lifecycle and concurrency tests @@ -33,9 +33,9 @@ never `Thread.Sleep` or `Task.Delay` in orchestration. - [x] 4.3 Disposal-accounting test: instrument the fake client's dispose count to prove no client is leaked or disposed more than once across reconnects. - [x] 4.4 Non-teardown test: caller cancellation and tool-declared/application errors neither reconnect nor dispose the healthy shared connection. - [x] 4.5 Shutdown-vs-reconnect race test: with a reconnect in flight, shutdown begins; assert no connection is published after shutdown starts and every created client is disposed. -- [x] 4.6 Invocation-vs-replacement race test: hold a tool call on the prior generation while publishing a replacement; assert the call completes, the prior generation is not disposed early, and it is disposed exactly once after release. -- [x] 4.7 Self-reconnect drain test: a lease-holding invocation fails with a classified transport error and triggers reconnect; assert it releases its lease before awaiting replacement, returns without deadlock, and is not replayed. -- [x] 4.8 Shutdown-with-active-invocation test: hold a lease through shutdown, assert new leases are rejected, bounded drain occurs, cancellation releases the invocation, and disposal happens only after release. +- [ ] 4.6 Invocation-vs-replacement race test: hold a tool call on the prior generation while publishing a replacement; assert the call completes, the prior generation is not disposed early, and it is disposed exactly once after release. **Deferred** with the drain work — replacement currently disposes immediately, and `ReplacementDisposesTheRetiredClientAndDisposeWithoutStopClearsPublishedState` pins that behavior instead. +- [x] 4.7 Self-reconnect test: an invocation fails with a classified transport error and triggers reconnect; assert it returns without deadlock and is not replayed. Covered by `TransportFailure_ReconnectsForLaterCallsAndDoesNotReplay`; the lease-release assertion is deferred with the drain work. +- [ ] 4.8 Shutdown-with-active-invocation test: hold a lease through shutdown, assert new leases are rejected, bounded drain occurs, cancellation releases the invocation, and disposal happens only after release. **Deferred** with the drain work. ## 5. PR 1 — Issue tracking @@ -49,9 +49,9 @@ never `Thread.Sleep` or `Task.Delay` in orchestration. ## 7. PR 2 — Transactional secrets -- [x] 7.1 Add a path-scoped, cross-process-locked read/decrypt/mutate/encrypt/replace transaction to `SecretsFileWriter`, reusing the `WebhookRouteStore` named-mutex / SHA-256 path-hash pattern. Preserve existing encryption and file-permission hardening; whole-file `Write` participates in the same path lock. Done when canonical path resolution derives the lock identity and that lock spans the latest read through atomic replacement. -- [x] 7.2 Migrate every secrets.json read-modify-write caller onto the transaction using owned field mutations applied to the latest document under the lock. Long-lived config editors and wizards replay contribution actions instead of writing snapshots captured before the lock. Done when no caller does an unlocked or stale-snapshot read-modify-write against secrets.json. -- [x] 7.3 Concurrent cross-section preservation tests: two writers updating different sections both survive, unrelated sections are preserved, a second cross-process writer observes the first's committed state before mutating, and a config editor opened before an MCP refresh cannot overwrite the refreshed token when saving another section. +- [x] 7.1 Add a path-scoped, in-process-locked read/decrypt/mutate/encrypt/replace transaction to `SecretsFileWriter`. Preserve existing encryption and file-permission hardening; whole-file `Write` participates in the same path lock. Done when canonical path resolution derives the lock identity — resolving a symlinked parent so two spellings share a lock — and that lock spans the latest read through atomic replacement. Cross-process locking is deferred with the remaining caller migration. +- [ ] 7.2 Migrate every secrets.json read-modify-write caller onto the transaction using owned field mutations applied to the latest document under the lock. **Partially delivered**: the credential store and the long-lived config editor and wizard replay contribution actions instead of writing pre-lock snapshots, which is what stopped the TUI overwriting daemon-written `McpOAuthTokens`. The CLI and provider callers (`SecretsCommand`, `PairCommand`, `ProviderCommand`, `ProviderManagerViewModel`, `ExposureModeStepViewModel`, `ProviderCredentialWriter`, `OAuthTokenPersistence`) still do unlocked read-modify-write, as they did before this change; **deferred** with cross-process locking. +- [x] 7.3 Concurrent cross-section preservation tests: two writers updating different sections both survive, unrelated sections are preserved, a second writer reaching the file through a symlinked directory observes the first's committed state before mutating, and a config editor opened before an MCP refresh cannot overwrite the refreshed token when saving another section. - [x] 7.4 Loud-failure tests: a replacement/disk failure propagates to the caller, the prior file content stays intact, and no caller-visible state reports the update as persisted. ## 8. PR 2 — Credential store and persist-before-publish diff --git a/src/Netclaw.Configuration/SecretsFileWriter.cs b/src/Netclaw.Configuration/SecretsFileWriter.cs index b1b04024d..a5d3507a4 100644 --- a/src/Netclaw.Configuration/SecretsFileWriter.cs +++ b/src/Netclaw.Configuration/SecretsFileWriter.cs @@ -256,9 +256,15 @@ private static void CountNode(JsonNode node, ref int encrypted, ref int plaintex internal static void SetOwnerOnlyPermissions(string path) => AtomicFile.HardenOwnerOnly(path); /// - /// Serializes read-modify-write against one secrets file within this process. The daemon - /// is the only writer at runtime, and the CLI writes while it is stopped, so a - /// cross-process lock buys nothing that this does not already cover. + /// Serializes read-modify-write against one secrets file within this process. + /// + /// This does NOT serialize against another process. Nothing stops `netclaw secrets set` + /// or `netclaw provider add` from running while the daemon is live, and those paths still + /// write the file without taking this gate, so a CLI write can still lose against a + /// concurrent daemon token refresh. That hazard predates this type — every caller was an + /// unlocked read-modify-write before it existed — and closing it needs both a + /// cross-process lock and the remaining callers moved onto . Both are + /// deferred; do not read this gate as covering them. /// private static IDisposable AcquireSecretsLock(string secretsPath, CancellationToken cancellationToken) { From 3431f247f669fb72df3a9b77c61aa0ebcd5cfb79 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 27 Jul 2026 20:49:21 +0000 Subject: [PATCH 06/13] Accept the Windows exception type for a failed secrets replace CommitFailureLeavesActiveStateUntouched forces a write failure by putting a directory where secrets.json belongs. Unix reports that as IOException; Windows reports UnauthorizedAccessException, which derives from SystemException rather than IOException, so Test-windows-latest failed on an assertion the other two platforms passed. Assert on either file-access failure and report the actual type when neither matches. The behavior under test -- the replace fails, active credentials are untouched, and the candidate stays unpublished -- is unchanged. --- .../Mcp/McpOAuthCredentialStoreTests.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs index 4667c71c8..5aa7c39ae 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs @@ -65,7 +65,13 @@ public async Task CommitFailureLeavesActiveStateUntouched() var candidate = store.CreateTokenCache(ServerName, Resource, "static-client", true); await candidate.StoreTokensAsync(Tokens("not-published", null), CancellationToken.None); - Assert.ThrowsAny(() => store.Publish(candidate, CancellationToken.None)); + // A directory standing where the file should be surfaces as IOException on Unix and + // UnauthorizedAccessException on Windows, and the latter derives from SystemException + // rather than IOException. Either way the replace failed, which is what this asserts. + var failure = Assert.ThrowsAny(() => store.Publish(candidate, CancellationToken.None)); + Assert.True( + failure is IOException or UnauthorizedAccessException, + $"Expected a file-access failure, got {failure.GetType().FullName}: {failure.Message}"); Assert.Null(store.GetActiveForTests(ServerName)); Assert.False(candidate.Published); From 68fbf9e4411e4539598c58fe214af03b843c0572 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 28 Jul 2026 16:23:27 +0000 Subject: [PATCH 07/13] Remove an orphaned conditional left by the trapdoor deletion Stripping the RejectedDynamicClientId carry-forward removed the assignment but left its if header above the identical condition that guards the persist block: if (cache.Published || !cache.ExplicitAuthorization) if (cache.Published || !cache.ExplicitAuthorization) { Evaluating the same condition twice is harmless, which is why the compiler, slopwatch, the test suite and three CI platforms all passed over it. It still misrepresents the persistence rules to anyone reading them. --- src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs b/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs index 4fd6e79d3..d395bf157 100644 --- a/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs +++ b/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs @@ -320,7 +320,6 @@ internal void StoreTokens( var replacement = CreateReplacement(tokens, cache.Credentials, cache.Identity, cache.CanonicalResource); if (cache.Published || !cache.ExplicitAuthorization) - if (cache.Published || !cache.ExplicitAuthorization) { Persist(cache.ServerName, replacement, cancellationToken); state.Active = replacement; From 8f815d7c6d40681ef867dcb89083743aa6cd5f16 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 29 Jul 2026 14:56:01 +0000 Subject: [PATCH 08/13] Keep provider response bodies out of logs and telemetry The DCR failure path wrapped the raw provider response body in an inner HttpRequestException. That exception is logged, and daemon logs are OTLP-exported when telemetry is enabled, so a provider that echoes credentials in an error body would have them shipped off the machine. The inner exception turned out to be redundant. FindHttpStatus text-matches "HTTP " on the message, and the outer message already carries it, so the typed status was never load-bearing. Removing it drops the raw body from every log path and is a net deletion rather than added redaction machinery. The endpoint, the status, and the RFC 7591 error / error_description fields are still reported, which is the part an operator can act on. DescribeOAuthError already allowlisted those two fields for the operator-facing message; nothing new was needed to keep the rejection diagnosable. ProviderBodySecretsStayInDaemonLogAndOutOfPublicOAuthErrors asserted the opposite - that the body reached the log - so it now asserts secrets appear in neither the operator error nor the log, while the endpoint and status still do. The two spec lines that required the full body in the daemon log are amended to match. --- .../specs/mcp-oauth/spec.md | 5 ++-- .../Mcp/McpSdkOAuthFlowIntegrationTests.cs | 14 ++++++++++- .../Mcp/McpOAuthClientRegistrar.cs | 24 +++++++------------ 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md index 26a3b90ce..000487585 100644 --- a/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md @@ -40,7 +40,8 @@ operator-facing error naming `OAuthClientId` as the remedy. - **GIVEN** an authorization server that publishes no `registration_endpoint`, or whose registration endpoint rejects the request - **WHEN** the operator runs explicit authorization - **THEN** the failure names `OAuthClientId` as the way to supply a manually registered client -- **AND** the provider's response body reaches the daemon log but not the operator-facing message +- **AND** the daemon log records the registration endpoint, the HTTP status, and the RFC 7591 `error` / `error_description` fields +- **AND** the raw response body is not carried into any exception or log entry, because daemon logs are OTLP-exported when telemetry is enabled #### Scenario: Startup with bound cached tokens requires no metadata cache @@ -305,7 +306,7 @@ already returned. - **GIVEN** a provider that advertises dynamic client registration but rejects it with HTTP 403 and no body - **WHEN** the operator runs explicit authorization - **THEN** the CLI displays the failing operation and the HTTP status -- **AND** the daemon log contains the full response context +- **AND** the daemon log contains the registration endpoint and the HTTP status #### Scenario: Bodyless daemon error still yields CLI output diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs index 5be326314..188579516 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs @@ -559,8 +559,20 @@ public async Task ProviderBodySecretsStayInDaemonLogAndOutOfPublicOAuthErrors() Assert.DoesNotContain("oauth-code", error.Error.Error, StringComparison.Ordinal); Assert.DoesNotContain("token-value", terminal.Error?.Error, StringComparison.Ordinal); Assert.DoesNotContain("secret-value", terminal.Error?.Error, StringComparison.Ordinal); + + // Not the daemon log either. Logs are OTLP-exported when telemetry is enabled, so a + // provider that echoes credentials in an error body would otherwise have them shipped + // off the machine. + var logged = string.Join("\n", harness.Logger.Exceptions.Select(e => e.ToString())); + Assert.DoesNotContain("oauth-code", logged, StringComparison.Ordinal); + Assert.DoesNotContain("token-value", logged, StringComparison.Ordinal); + Assert.DoesNotContain("secret-value", logged, StringComparison.Ordinal); + + // The failing endpoint and status still reach the log, so the rejection stays + // diagnosable without the raw body. Assert.Contains(harness.Logger.Exceptions, exception => - exception.ToString().Contains("secret-value", StringComparison.Ordinal)); + exception.ToString().Contains("dynamic client registration", StringComparison.Ordinal) + && exception.ToString().Contains("HTTP 403", StringComparison.Ordinal)); } private static async Task CompleteManagerAuthorizationAsync( diff --git a/src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs b/src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs index 4a7d23e87..73297ea79 100644 --- a/src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs +++ b/src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs @@ -87,22 +87,16 @@ internal sealed class McpOAuthClientRegistrar( if (!response.IsSuccessStatusCode) { - // The provider's response body can carry authorization codes and secrets, so it - // rides on an inner exception. The daemon logs the whole tree; the operator-facing - // message is synthesized separately by McpClientManager.CreateSafeOAuthError and - // never echoes this text. - var providerDetail = new HttpRequestException( - $"Client registration POST to {registrationEndpoint} returned " + - $"{(int)response.StatusCode} {response.StatusCode}: {body}", - inner: null, - response.StatusCode); - + // Only the endpoint, the status, and the RFC 7591 error fields are reported. + // The raw body is deliberately not carried anywhere: this exception is logged, + // and daemon logs are OTLP-exported when telemetry is enabled, so an arbitrary + // provider blob would leave the machine. DescribeOAuthError allowlists the two + // standard fields, which is the part an operator can act on. throw new McpOAuthRegistrationException( - $"MCP server '{serverName.Value}' rejected dynamic client registration: " + - $"HTTP {(int)response.StatusCode} {response.StatusCode}{DescribeOAuthError(body)}. " + - $"Register a client manually and set OAuthClientId for '{serverName.Value}' " + - "in the MCP server config.", - providerDetail); + $"MCP server '{serverName.Value}' rejected dynamic client registration at " + + $"{registrationEndpoint}: HTTP {(int)response.StatusCode} {response.StatusCode}" + + $"{DescribeOAuthError(body)}. Register a client manually and set OAuthClientId " + + $"for '{serverName.Value}' in the MCP server config."); } string? clientId; From 50b236429b146079e435d57ba1f57093ac23352a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 29 Jul 2026 15:00:25 +0000 Subject: [PATCH 09/13] Do not bind a bare-origin legacy credential to a tenant-scoped endpoint IsEquivalentResource accepted a legacy bare-origin resource as equivalent to any endpoint on that origin, including one carrying a query. The reasoning in the comment was that an origin-wide audience also covers the path and query beneath it, which holds only when the bare origin came from the authorization server's protected-resource metadata. The previous release also fell back to the configured URL when the server published no resource at all, so a bare origin can equally mean the endpoint has since been repointed. The record cannot distinguish the two, and a query can select a tenant, so binding a credential of unknown scope to a tenant-scoped endpoint is not safe. Narrowing origin -> path still migrates when neither side carries a query, which is the shape providers actually publish. A configured query now falls through to fail-closed and requires authorization, matching what the spec already said about query having to agree. --- .../Mcp/McpOAuthCredentialStoreTests.cs | 25 ++++++++++++++++--- .../Mcp/McpOAuthCredentialStore.cs | 16 +++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs index 5aa7c39ae..ebb218a95 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs @@ -306,9 +306,6 @@ public async Task UnexpiredLegacyCredentialWithoutObtainedAtIsNotReportedExpired // Trailing slash and path case describe the same endpoint as Resource. [InlineData("https://mcp.example.com/tools/?tenant=one")] [InlineData("https://mcp.example.com/TOOLS?tenant=one")] - // Providers commonly publish the RFC 8707 resource indicator as the bare origin - // while the MCP endpoint sits on a path beneath it. - [InlineData("https://mcp.example.com")] public async Task LegacyResourceEquivalentToConfiguredEndpointMigrates(string legacyResource) { var paths = Paths(); @@ -331,6 +328,9 @@ public async Task LegacyResourceEquivalentToConfiguredEndpointMigrates(string le // The query can select a tenant, so a different one is a different resource. [InlineData("https://mcp.example.com/tools?tenant=two")] [InlineData("https://mcp.example.com/tools")] + // A bare origin cannot be distinguished from a pre-repoint configured URL, so it is + // not bound to an endpoint whose query may select a tenant. + [InlineData("https://mcp.example.com")] public async Task LegacyResourceFromADifferentAudienceFailsClosed(string legacyResource) { var paths = Paths(); @@ -344,6 +344,25 @@ public async Task LegacyResourceFromADifferentAudienceFailsClosed(string legacyR Assert.Contains("legacy-access", File.ReadAllText(paths.SecretsPath), StringComparison.Ordinal); } + [Fact] + public async Task LegacyBareOriginMigratesToAPathEndpointWithoutAQuery() + { + // The narrowing that providers actually produce: the authorization server declares + // the origin as the protected resource, the MCP endpoint sits on a path beneath it, + // and nothing about the binding is tenant-scoped. + const string endpoint = "https://mcp.example.com/tools"; + var paths = Paths(); + WriteLegacyCredentials(paths, "https://mcp.example.com"); + var store = CreateStore(paths); + + var cache = store.CreateTokenCache(ServerName, endpoint, null, false); + + Assert.Equal("legacy-access", (await cache.GetTokensAsync(CancellationToken.None))?.AccessToken); + Assert.Equal( + McpOAuthCredentialStore.CanonicalizeResource(endpoint), + store.GetActiveForTests(ServerName)?.ResourceIdentity); + } + [Fact] public void UnreadableSecretsFileLeavesTheDaemonStartable() { diff --git a/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs b/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs index d395bf157..cc3e49276 100644 --- a/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs +++ b/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs @@ -567,10 +567,18 @@ private static bool IsEquivalentResource(string legacy, string configured) return true; // Providers commonly publish the resource indicator as the bare origin while the - // MCP endpoint sits on a path beneath it. That covers the whole origin, so it also - // covers the configured path and query. Only this narrowing direction is accepted; - // a path-scoped credential is never widened to a sibling path. - return legacyPath.Length == 0 && legacyUri.Query.Length == 0; + // MCP endpoint sits on a path beneath it, so narrowing origin -> path is accepted. + // A path-scoped credential is never widened to a sibling path. + // + // The configured side must carry no query. A legacy bare origin is ambiguous: it + // means either the authorization server declared an origin-wide audience, or the + // previous release fell back to the configured URL because the server published no + // resource at all — in which case the endpoint has since been repointed. Those are + // indistinguishable in the record, and a query can select a tenant, so a credential + // of unknown scope is not bound to a tenant-scoped endpoint. + return legacyPath.Length == 0 + && legacyUri.Query.Length == 0 + && configuredUri.Query.Length == 0; } private static bool IsBound(McpOAuthTokenSet? credentials, string canonicalResource) From d5c0ad879931916d288e39ca866cfc9aff17d748 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 29 Jul 2026 15:13:57 +0000 Subject: [PATCH 10/13] Explain the cancellation guards, and drop the one that guards nothing The catch-and-rethrow around OperationCanceledException looks like a no-op. It is not, at the sites that precede a catch-all: OperationCanceledException is an Exception, so removing it lets a caller's abort be swallowed and returned as "Error: MCP tool 'x' failed: The operation was canceled." -- a normal-looking tool result the agent then acts on. Verified by deleting the clause in McpToolAdapter, which turns McpToolAdapterTests's cancellation case into "Assert.ThrowsAny() Failure: No exception was thrown". The filter carries its own weight. HttpClient timeouts arrive as TaskCanceledException, which derives from OperationCanceledException, while the caller's token is untouched. Those are faults, not caller intent, so they fall to the catch-all and return an actionable error rather than tearing down the caller's operation. Commented at the four sites where that reasoning applies: both McpToolAdapter execution paths and both MCP OAuth endpoints. McpClientManager.InvokeSharedAsync was not one of them. Every clause below its bare catch is filtered -- McpException that is not a transport failure, and Exception that is -- and IsTransportOrSessionFailure lists neither OperationCanceledException nor TaskCanceledException, so cancellation propagates with or without it. Removing it leaves all 913 daemon tests green, and CancellationAndApplicationErrors_DoNotReconnectOrDisposeHealthyClient still pins the behavior if a swallowing catch-all is ever added. Also reverts a guard-clause reflow in ToolRegistrationExtensions that changed no behavior. The PrepareMcpTools split stays: candidate-then-publish has to build adapters before deciding to publish, so building must not mutate the shared ToolRegistry. --- src/Netclaw.Actors/Tools/McpToolAdapter.cs | 13 ++++++++++++ .../Tools/ToolRegistrationExtensions.cs | 20 +++++++++---------- src/Netclaw.Daemon/Mcp/McpClientManager.cs | 4 ---- .../Mcp/McpEndpointRouteBuilderExtensions.cs | 7 +++++++ 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/Netclaw.Actors/Tools/McpToolAdapter.cs b/src/Netclaw.Actors/Tools/McpToolAdapter.cs index 136ddb49c..a5267be87 100644 --- a/src/Netclaw.Actors/Tools/McpToolAdapter.cs +++ b/src/Netclaw.Actors/Tools/McpToolAdapter.cs @@ -122,6 +122,19 @@ public async Task ExecuteAsync( var coerced = McpSchemaSanitizer.CoerceArguments(normalized, _rawSchema); return await _invoker.InvokeAsync(ServerName, _toolName, coerced, context, ct); } + // Cancellation must not become a tool result. OperationCanceledException is an + // Exception, so without this clause the catch-all below would swallow a caller's + // abort and hand the agent "Error: ... The operation was canceled." as if the tool + // had merely misbehaved, and the agent would carry on. + // + // The filter matters as much as the rethrow: HttpClient timeouts surface as + // TaskCanceledException, which derives from OperationCanceledException. Those fire + // while ct is NOT cancelled, and they are faults rather than caller intent, so they + // fall through to the catch-all and come back as an actionable error instead of + // tearing down the caller's operation. + // Same guard as ExecuteAsync: without this the catch-all below turns a caller's + // cancellation into a tool result, and the filter keeps HttpClient timeouts + // (TaskCanceledException) classified as faults rather than caller intent. catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; diff --git a/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs b/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs index 553f7b486..831efbc2f 100644 --- a/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs +++ b/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs @@ -174,21 +174,19 @@ public static IReadOnlyList PrepareMcpTools( logger); adapters.Add(adapter); - if (maxSchemaWarnChars <= 0 - || adapter.ParameterSchema.ValueKind == System.Text.Json.JsonValueKind.Undefined) - continue; - - var schemaChars = adapter.ParameterSchema.GetRawText().Length; - if (schemaChars > maxSchemaWarnChars) + if (maxSchemaWarnChars > 0 && adapter.ParameterSchema.ValueKind != System.Text.Json.JsonValueKind.Undefined) { - logger?.LogWarning( - "MCP tool '{ServerName}/{ToolName}' has an oversized schema ({SchemaChars} chars, threshold {Threshold}). " + - "This wastes context window budget when the tool is loaded. Consider asking the MCP server author to reduce schema verbosity.", - serverName, tool.Name, schemaChars, maxSchemaWarnChars); + var schemaChars = adapter.ParameterSchema.GetRawText().Length; + if (schemaChars > maxSchemaWarnChars) + { + logger?.LogWarning( + "MCP tool '{ServerName}/{ToolName}' has an oversized schema ({SchemaChars} chars, threshold {Threshold}). " + + "This wastes context window budget when the tool is loaded. Consider asking the MCP server author to reduce schema verbosity.", + serverName, tool.Name, schemaChars, maxSchemaWarnChars); + } } } return adapters; } - } diff --git a/src/Netclaw.Daemon/Mcp/McpClientManager.cs b/src/Netclaw.Daemon/Mcp/McpClientManager.cs index 8516846a3..6e96c254c 100644 --- a/src/Netclaw.Daemon/Mcp/McpClientManager.cs +++ b/src/Netclaw.Daemon/Mcp/McpClientManager.cs @@ -247,10 +247,6 @@ private async Task InvokeSharedAsync( arguments, ct); } - catch (OperationCanceledException) - { - throw; - } catch (McpException ex) when (!IsTransportOrSessionFailure(ex)) { return $"Error: MCP tool '{serverName.Value}/{toolName.Value}' failed: {ex.Message}"; diff --git a/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs index c25c146df..d546c53d3 100644 --- a/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs +++ b/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs @@ -62,6 +62,13 @@ public static IEndpointRouteBuilder MapMcpEndpoints(this IEndpointRouteBuilder a requestCancellation); return Results.Ok(started); } + // A disconnected client is not a server error. Without this clause the + // catch-all below would map the aborted request onto an error response and + // log it as a failure. The filter keeps genuine timeouts, which arrive as + // TaskCanceledException while the request token is still live, classified as + // faults rather than as the caller hanging up. + // Same guard as the start endpoint: a client that hung up must not be + // reported as a callback failure. catch (OperationCanceledException) when (requestCancellation.IsCancellationRequested) { throw; From 8eb72d5846a50337a7f0a2fd9b0b71805775b5b1 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 29 Jul 2026 16:03:23 +0000 Subject: [PATCH 11/13] Stop running caller code inside the tool registry's lock PublishMcpServerTools took an Action and invoked it while holding _sync. All three callers passed a single Volatile.Write, so nothing deadlocked, but the signature let any future caller take another lock or block inside the registry's critical section, and no type constrains that. The snapshot now arrives as a StrongBox slot plus a value. The registry performs the write itself and executes nothing it does not own, which removes the hazard class rather than documenting it. Note the reported mechanism does not hold: lock is Monitor and reentrant per thread, so a callback re-entering ToolRegistry on the same thread would re-acquire rather than deadlock. The real exposure was a callback taking a different lock or blocking. The comment on that method also overstated its guarantee. It claimed readers see either the old pair or the new pair and never a mix, but snapshot readers take no lock at all - Snapshot is a plain Volatile.Read - so only one direction is ordered. Rewritten to say what actually holds, and why the other direction does not matter: dispatch resolves tools from the snapshot, and the registry only supplies the model's advertised tool list. --- src/Netclaw.Actors/Tools/ToolRegistry.cs | 31 +++++++++++++++++----- src/Netclaw.Daemon/Mcp/McpClientManager.cs | 27 +++++++++++++------ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/Netclaw.Actors/Tools/ToolRegistry.cs b/src/Netclaw.Actors/Tools/ToolRegistry.cs index df9961470..e91d2e58a 100644 --- a/src/Netclaw.Actors/Tools/ToolRegistry.cs +++ b/src/Netclaw.Actors/Tools/ToolRegistry.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using System.Text; using System.Text.RegularExpressions; +using System.Runtime.CompilerServices; using Microsoft.Extensions.AI; using Netclaw.Configuration; using Netclaw.Tools; @@ -45,12 +46,31 @@ public void Replace(INetclawTool tool) } } - public void PublishMcpServerTools( + /// + /// Swaps one MCP server's tools and publishes the caller's connection snapshot in the + /// same critical section, so a caller that observes the new tools is guaranteed to + /// observe the snapshot that produced them. + /// + /// + /// The snapshot arrives as a slot plus a value rather than a callback. The write below + /// happens while _sync is held, and running caller-supplied code there would put + /// the registry's liveness at the mercy of whatever that code does — take another lock, + /// block, or grow one later. A slot lets this method perform the only write it needs + /// while executing nothing it does not own. + /// + /// The ordering guarantee is one-directional. Snapshot readers do not take + /// _sync, so a caller may still observe a new snapshot alongside tools it has + /// already read. That is harmless here: dispatch resolves tools from the snapshot, and + /// the registry only supplies the model's advertised tool list. + /// + public void PublishMcpServerTools( string serverName, IReadOnlyList tools, - Action publishSnapshot) + StrongBox snapshotSlot, + TSnapshot? snapshot) + where TSnapshot : class { - ArgumentNullException.ThrowIfNull(publishSnapshot); + ArgumentNullException.ThrowIfNull(snapshotSlot); lock (_sync) { @@ -59,10 +79,7 @@ public void PublishMcpServerTools( foreach (var tool in tools) _tools.Add(new ToolRegistration(tool, tool.GrantCategory)); - // Registry readers use this same lock. Publishing the manager - // snapshot here makes its volatile write the linearization point: - // readers can observe either the old pair or the new pair, not a mix. - publishSnapshot(); + Volatile.Write(ref snapshotSlot.Value, snapshot); } } diff --git a/src/Netclaw.Daemon/Mcp/McpClientManager.cs b/src/Netclaw.Daemon/Mcp/McpClientManager.cs index 6e96c254c..2cfddf726 100644 --- a/src/Netclaw.Daemon/Mcp/McpClientManager.cs +++ b/src/Netclaw.Daemon/Mcp/McpClientManager.cs @@ -6,6 +6,7 @@ using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.Net; +using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using Microsoft.Extensions.AI; using Microsoft.Extensions.Hosting; @@ -432,7 +433,8 @@ private async Task BuildAndPublishCandidateAsync( _toolRegistry.PublishMcpServerTools( current.Name.Value, publishedTools, - () => lifecycle.Publish(replacement)); + lifecycle.SnapshotSlot, + replacement); candidate = null; oauthCache = null; } @@ -607,10 +609,11 @@ private async Task StopServerAsync( try { var client = lifecycle.Snapshot?.Client; - _toolRegistry.PublishMcpServerTools( + _toolRegistry.PublishMcpServerTools( serverName.Value, [], - () => lifecycle.Publish(null)); + lifecycle.SnapshotSlot, + null); if (client is null) return; @@ -1139,10 +1142,11 @@ public void Dispose() { foreach (var (serverName, lifecycle) in _servers) { - _toolRegistry.PublishMcpServerTools( + _toolRegistry.PublishMcpServerTools( serverName.Value, [], - () => lifecycle.Publish(null)); + lifecycle.SnapshotSlot, + null); } } @@ -1209,13 +1213,20 @@ internal sealed record McpClientCandidate( internal sealed class McpServerLifecycle(McpServerSnapshot initialSnapshot) { - private McpServerSnapshot? _snapshot = initialSnapshot; + private readonly StrongBox _snapshot = new(initialSnapshot); public SemaphoreSlim Gate { get; } = new(1, 1); - public McpServerSnapshot? Snapshot => Volatile.Read(ref _snapshot); + public McpServerSnapshot? Snapshot => Volatile.Read(ref _snapshot.Value); + + /// + /// The slot writes while holding its + /// own lock, so publishing the snapshot and swapping the tools happen together without + /// the registry invoking code it does not own. + /// + public StrongBox SnapshotSlot => _snapshot; - public void Publish(McpServerSnapshot? snapshot) => Volatile.Write(ref _snapshot, snapshot); + public void Publish(McpServerSnapshot? snapshot) => Volatile.Write(ref _snapshot.Value, snapshot); } internal sealed record McpServerSnapshot( From 70da6ce5d65925857a08763ba0bbb65a5c060413 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 29 Jul 2026 23:47:09 +0000 Subject: [PATCH 12/13] Require ASD-STE100 Simplified Technical English for agent output Adds a Communication Standard section to the agent constitution. It applies to chat replies, commit messages, PR descriptions, code comments, docs, and spec text. The section states the rules that change how an agent writes: one idea per sentence, sentence length limits, active voice, simple tenses, no -ing word as a noun, keep articles, one meaning per word, and vertical lists for complex information. Two carve-outs keep the rule safe. STE does not apply to code identifiers, file paths, log text, or quoted material. STE does not override accuracy: an agent states a technical fact correctly even when the approved vocabulary cannot. CLAUDE.md is a symlink to AGENTS.md, so the edit went to AGENTS.md. This change is unrelated to the MCP OAuth work on this branch. --- AGENTS.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 266414faa..a5ebbb46a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,34 @@ Keep it small. Keep it durable. Keep it routing-focused. - Default to smallest safe change that advances MVP. - Prefer explicit tradeoffs over hidden complexity. +## Communication Standard + +Write all agent output in ASD-STE100 Simplified Technical English (STE). This +applies to chat replies, commit messages, PR descriptions, code comments, docs, +and spec text. + +Rules: + +- Give one sentence one idea. Keep an instruction to 20 words or fewer. Keep a + descriptive sentence to 25 words or fewer. +- Use the active voice. Name the actor that does the action. +- Use simple tenses: past, present, and future. Do not use perfect or + progressive tenses. +- Do not use an `-ing` word as a noun or as an adjective. Use a noun or a + relative clause. +- Keep the articles `a`, `an`, and `the`. Do not delete words to make a + sentence shorter. +- Give one word one meaning. Do not use the same word as a noun and as a verb. +- Keep a descriptive paragraph to six sentences or fewer. +- Put complex information in a vertical list. +- Put a warning or a caution before the step that it applies to. + +STE does not apply to code identifiers, file paths, log text, or quoted +material. Keep those exact. + +STE does not override accuracy. If the approved vocabulary cannot state a +technical fact correctly, state the fact correctly and keep the sentence short. + ## Current Product Direction - Netclaw is an open-source, self-hosted autonomous operations agent built on Akka.Agents. From 455b206ac6402dd5c09b8153cb06ee528a635b24 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 29 Jul 2026 23:56:07 +0000 Subject: [PATCH 13/13] Make the tool registry copy-on-write The registry is one global instance. Program.cs builds it once and registers it as a singleton, so every session actor shares it. Writes happen at startup, at MCP reconnect, and at shutdown. Reads happen on every tool call from every concurrent session. The lock-based version fit that shape badly. Each read took a lock, and GetRegistrationsSnapshot also copied the whole list, so the hot path paid for a rare writer. The registrations now live in a volatile array. A writer holds one lock, builds a replacement array, and publishes it in a single volatile write. A reader reads the field once and works on that array. Reads take no lock and copy nothing. Read paths that lock drop from 2 to 0. GetAllRegistrations wraps the shared array in a read-only view instead of copying it, so a cast cannot reach registry state. The StrongBox slot goes away with the locks. It existed to publish the connection snapshot inside the registry's lock, which paired the two writes for readers that took that same lock. Readers no longer take it, so the slot bought nothing. The caller now orders the two publications, which gives a better guarantee than the slot did. A server that comes up publishes the connection first and the tools second, so a tool the model can see is always dispatchable. A server that goes down removes the tools first and the connection second, so the model stops seeing a tool before dispatch loses it. ToolRegistry's diff against dev drops from +75 -21 to +65 -18. --- src/Netclaw.Actors/Tools/ToolRegistry.cs | 99 ++++++++++------------ src/Netclaw.Daemon/Mcp/McpClientManager.cs | 39 +++------ 2 files changed, 59 insertions(+), 79 deletions(-) diff --git a/src/Netclaw.Actors/Tools/ToolRegistry.cs b/src/Netclaw.Actors/Tools/ToolRegistry.cs index e91d2e58a..928b00175 100644 --- a/src/Netclaw.Actors/Tools/ToolRegistry.cs +++ b/src/Netclaw.Actors/Tools/ToolRegistry.cs @@ -5,7 +5,6 @@ // ----------------------------------------------------------------------- using System.Text; using System.Text.RegularExpressions; -using System.Runtime.CompilerServices; using Microsoft.Extensions.AI; using Netclaw.Configuration; using Netclaw.Tools; @@ -28,58 +27,52 @@ public sealed record McpServerSummary(string ServerName, string Description, int /// public sealed class ToolRegistry { - private readonly List _tools = []; - private readonly object _sync = new(); + // Copy-on-write. Registrations change at startup, at MCP reconnect, and at shutdown. + // Reads happen on every tool call from every concurrent session, so a reader takes no + // lock and copies nothing: it reads this field once and works on that array. A writer + // holds _writeSync, builds a replacement array, and publishes it in one volatile write, + // so a reader sees either the old set or the new set and never a partial swap. + private volatile ToolRegistration[] _tools = []; + private readonly object _writeSync = new(); public void Register(INetclawTool tool) { - lock (_sync) - _tools.Add(new ToolRegistration(tool, tool.GrantCategory)); + lock (_writeSync) + _tools = [.._tools, new ToolRegistration(tool, tool.GrantCategory)]; } public void Replace(INetclawTool tool) { - lock (_sync) + lock (_writeSync) { - _tools.RemoveAll(t => string.Equals(t.Tool.Name, tool.Name, StringComparison.Ordinal)); - _tools.Add(new ToolRegistration(tool, tool.GrantCategory)); + _tools = + [ + .._tools.Where(t => !string.Equals(t.Tool.Name, tool.Name, StringComparison.Ordinal)), + new ToolRegistration(tool, tool.GrantCategory), + ]; } } /// - /// Swaps one MCP server's tools and publishes the caller's connection snapshot in the - /// same critical section, so a caller that observes the new tools is guaranteed to - /// observe the snapshot that produced them. + /// Replaces every tool of one MCP server in a single publication. Pass an empty list to + /// remove the server. A reader never sees the server with a partial tool set. /// /// - /// The snapshot arrives as a slot plus a value rather than a callback. The write below - /// happens while _sync is held, and running caller-supplied code there would put - /// the registry's liveness at the mercy of whatever that code does — take another lock, - /// block, or grow one later. A slot lets this method perform the only write it needs - /// while executing nothing it does not own. - /// - /// The ordering guarantee is one-directional. Snapshot readers do not take - /// _sync, so a caller may still observe a new snapshot alongside tools it has - /// already read. That is harmless here: dispatch resolves tools from the snapshot, and - /// the registry only supplies the model's advertised tool list. + /// The caller owns the order against its own connection state. Publish the connection + /// first and the tools second when a server comes up, so a tool the model can see is + /// always dispatchable. Remove the tools first and the connection second when a server + /// goes down, so the model stops seeing a tool before dispatch loses it. /// - public void PublishMcpServerTools( - string serverName, - IReadOnlyList tools, - StrongBox snapshotSlot, - TSnapshot? snapshot) - where TSnapshot : class + public void PublishMcpServerTools(string serverName, IReadOnlyList tools) { - ArgumentNullException.ThrowIfNull(snapshotSlot); - - lock (_sync) + lock (_writeSync) { - _tools.RemoveAll(t => t.Tool is McpToolAdapter mcp - && string.Equals(mcp.ServerName, serverName, StringComparison.OrdinalIgnoreCase)); - foreach (var tool in tools) - _tools.Add(new ToolRegistration(tool, tool.GrantCategory)); - - Volatile.Write(ref snapshotSlot.Value, snapshot); + _tools = + [ + .._tools.Where(t => t.Tool is not McpToolAdapter mcp + || !string.Equals(mcp.ServerName, serverName, StringComparison.OrdinalIgnoreCase)), + ..tools.Select(t => new ToolRegistration(t, t.GrantCategory)), + ]; } } @@ -88,8 +81,8 @@ public void PublishMcpServerTools( /// public void Register(AITool tool, string grantCategory) { - lock (_sync) - _tools.Add(new ToolRegistration(new AIToolAdapter(tool, grantCategory), grantCategory)); + lock (_writeSync) + _tools = [.._tools, new ToolRegistration(new AIToolAdapter(tool, grantCategory), grantCategory)]; } /// All registered tools as AITool for ChatOptions.Tools. @@ -137,15 +130,15 @@ public string ToCanonicalName(string name) => private ToolRegistration? FindRegistration(string name) { - lock (_sync) - { - var direct = _tools.FirstOrDefault(t => t.Tool.Name == name); - if (direct is not null) - return direct; - return _tools.FirstOrDefault(t => - t.Tool is McpToolAdapter mcp - && string.Equals(mcp.LlmFacingName.Value, name, StringComparison.Ordinal)); - } + // One volatile read. Both passes below scan the same array, so a concurrent + // publication cannot make the canonical pass and the alias pass disagree. + var tools = _tools; + var direct = tools.FirstOrDefault(t => t.Tool.Name == name); + if (direct is not null) + return direct; + return tools.FirstOrDefault(t => + t.Tool is McpToolAdapter mcp + && string.Equals(mcp.LlmFacingName.Value, name, StringComparison.Ordinal)); } /// @@ -161,7 +154,9 @@ public IReadOnlyList GetAlwaysLoadedTools() => /// /// Returns all registered tool registrations (for search and dynamic loading). /// - public IReadOnlyList GetAllRegistrations() => GetRegistrationsSnapshot(); + // Wrapped, not copied: callers receive the shared array behind a read-only view so a + // cast cannot reach the registry's state. + public IReadOnlyList GetAllRegistrations() => Array.AsReadOnly(_tools); /// /// Returns tools discovered from one MCP server. @@ -476,11 +471,9 @@ private static int LevenshteinDistance(string source, string target) return d[rows - 1, cols - 1]; } - private IReadOnlyList GetRegistrationsSnapshot() - { - lock (_sync) - return _tools.ToList(); - } + // The published array is never mutated after publication, so a reader can use it + // directly. This replaces a lock plus a full list copy on every read. + private IReadOnlyList GetRegistrationsSnapshot() => _tools; /// /// Adapter to wrap bare instances (e.g. test fakes) as . diff --git a/src/Netclaw.Daemon/Mcp/McpClientManager.cs b/src/Netclaw.Daemon/Mcp/McpClientManager.cs index 2cfddf726..61a1c430c 100644 --- a/src/Netclaw.Daemon/Mcp/McpClientManager.cs +++ b/src/Netclaw.Daemon/Mcp/McpClientManager.cs @@ -6,7 +6,6 @@ using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.Net; -using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using Microsoft.Extensions.AI; using Microsoft.Extensions.Hosting; @@ -430,11 +429,10 @@ private async Task BuildAndPublishCandidateAsync( functions, checked(current.Generation + 1), connectedStatus); - _toolRegistry.PublishMcpServerTools( - current.Name.Value, - publishedTools, - lifecycle.SnapshotSlot, - replacement); + // Connection first, tools second. A tool the model can see is then always + // dispatchable, because dispatch resolves it from this snapshot. + lifecycle.Publish(replacement); + _toolRegistry.PublishMcpServerTools(current.Name.Value, publishedTools); candidate = null; oauthCache = null; } @@ -609,11 +607,10 @@ private async Task StopServerAsync( try { var client = lifecycle.Snapshot?.Client; - _toolRegistry.PublishMcpServerTools( - serverName.Value, - [], - lifecycle.SnapshotSlot, - null); + // Tools first, connection second. The model stops seeing the server's tools + // before dispatch loses the snapshot that resolves them. + _toolRegistry.PublishMcpServerTools(serverName.Value, []); + lifecycle.Publish(null); if (client is null) return; @@ -1142,11 +1139,8 @@ public void Dispose() { foreach (var (serverName, lifecycle) in _servers) { - _toolRegistry.PublishMcpServerTools( - serverName.Value, - [], - lifecycle.SnapshotSlot, - null); + _toolRegistry.PublishMcpServerTools(serverName.Value, []); + lifecycle.Publish(null); } } @@ -1213,20 +1207,13 @@ internal sealed record McpClientCandidate( internal sealed class McpServerLifecycle(McpServerSnapshot initialSnapshot) { - private readonly StrongBox _snapshot = new(initialSnapshot); + private McpServerSnapshot? _snapshot = initialSnapshot; public SemaphoreSlim Gate { get; } = new(1, 1); - public McpServerSnapshot? Snapshot => Volatile.Read(ref _snapshot.Value); - - /// - /// The slot writes while holding its - /// own lock, so publishing the snapshot and swapping the tools happen together without - /// the registry invoking code it does not own. - /// - public StrongBox SnapshotSlot => _snapshot; + public McpServerSnapshot? Snapshot => Volatile.Read(ref _snapshot); - public void Publish(McpServerSnapshot? snapshot) => Volatile.Write(ref _snapshot.Value, snapshot); + public void Publish(McpServerSnapshot? snapshot) => Volatile.Write(ref _snapshot, snapshot); } internal sealed record McpServerSnapshot(