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. 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/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index e4143a137..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.35.0" + version: "2.38.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,106 @@ 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 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 + +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 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 +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 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 + +| 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, 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. + ## Approval Prompts MCP approval prompts show a bounded, redacted preview of the call arguments. 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..7c130019f --- /dev/null +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/design.md @@ -0,0 +1,529 @@ +## 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 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. + +**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:** 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 +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 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. 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 +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 +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 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 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. + +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 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 +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 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 + 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. + 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 + +**Decision:** Persistence and diagnostics follow a strict fail-loud ordering. + +- 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 + 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. +- 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 + 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 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 +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..e5969f7d8 --- /dev/null +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md @@ -0,0 +1,211 @@ +# 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; 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**: 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**: 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 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. +- **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. 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`, +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`: 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 — + 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, + and atomic publication. + - "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. 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 + 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 + 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 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 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). +- Candidate-local credentials for explicit authorization, committed once to + the sole durable active record immediately before successful 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. +- 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 + +- `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..000487585 --- /dev/null +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md @@ -0,0 +1,329 @@ +# mcp-oauth Specification + +## ADDED Requirements + +### 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 + +- **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 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 + +- **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 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. 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 +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** 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 + +- **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 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 +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 stores replacement credentials in its local cache +- **WHEN** candidate initialization or tool listing fails before publication +- **THEN** the candidate-local 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 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 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, 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** 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 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 + +### 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 + +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. + +#### 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** a configured `Authorization` header disables SDK OAuth challenge handling for that transport +- **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. 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 + +- **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 registration endpoint and the HTTP status + +#### 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: 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 +- **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..c1053721b --- /dev/null +++ b/openspec/changes/simplify-mcp-oauth-lifecycle/specs/transactional-secrets/spec.md @@ -0,0 +1,67 @@ +# 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 — 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 + +- **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 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: Two spellings of one path share a lock + +- **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 + +#### 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..ba8099f31 --- /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 + +- [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. + +## 2. PR 1 — Generation-aware lifecycle gate + +- [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 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, 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 + +- [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. +- [ ] 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 + +- [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 + +- [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 + +- [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 + +- [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 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 + +- [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 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. +- [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 + +- [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 + +- [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 + +- [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 + +- [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 + +- [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..a5267be87 100644 --- a/src/Netclaw.Actors/Tools/McpToolAdapter.cs +++ b/src/Netclaw.Actors/Tools/McpToolAdapter.cs @@ -122,6 +122,23 @@ 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; + } catch (Exception ex) { return $"Error: MCP tool '{Name}' failed: {ex.Message}"; @@ -144,6 +161,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..831efbc2f 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) @@ -163,9 +187,6 @@ public static ToolRegistry WithMcpTools( } } - 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..928b00175 100644 --- a/src/Netclaw.Actors/Tools/ToolRegistry.cs +++ b/src/Netclaw.Actors/Tools/ToolRegistry.cs @@ -27,17 +27,53 @@ public sealed record McpServerSummary(string ServerName, string Description, int /// public sealed class ToolRegistry { - private readonly List _tools = []; + // 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) { - _tools.Add(new ToolRegistration(tool, tool.GrantCategory)); + lock (_writeSync) + _tools = [.._tools, 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 (_writeSync) + { + _tools = + [ + .._tools.Where(t => !string.Equals(t.Tool.Name, tool.Name, StringComparison.Ordinal)), + new ToolRegistration(tool, tool.GrantCategory), + ]; + } + } + + /// + /// 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 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) + { + lock (_writeSync) + { + _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)), + ]; + } } /// @@ -45,16 +81,17 @@ public void Replace(INetclawTool tool) /// public void Register(AITool tool, string grantCategory) { - _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. 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,10 +130,13 @@ public string ToCanonicalName(string name) => private ToolRegistration? FindRegistration(string name) { - var direct = _tools.FirstOrDefault(t => t.Tool.Name == name); + // 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 => + return tools.FirstOrDefault(t => t.Tool is McpToolAdapter mcp && string.Equals(mcp.LlmFacingName.Value, name, StringComparison.Ordinal)); } @@ -106,7 +146,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 +154,9 @@ public IReadOnlyList GetAlwaysLoadedTools() => /// /// Returns all registered tool registrations (for search and dynamic loading). /// - public IReadOnlyList GetAllRegistrations() => _tools; + // 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. @@ -124,7 +166,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 +180,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 +206,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 +241,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 +269,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 +282,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 +471,10 @@ private static int LevenshteinDistance(string source, string target) return d[rows - 1, cols - 1]; } + // 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.Cli.Tests/Mcp/McpCommandTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs index 35433c2c8..ab95f21d2 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs @@ -540,6 +540,40 @@ public void TryEmitOsc52Copy_StringWriter_ReturnsFalse() Assert.Equal(string.Empty, output.ToString()); } + [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..8df1176b9 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Mcp/McpOAuthEndToEndTests.cs @@ -0,0 +1,234 @@ +// ----------------------------------------------------------------------- +// +// 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, + new McpOAuthClientRegistrar( + new HttpClient(new BodylessDcrHandler()) { BaseAddress = new Uri("https://oauth.test") }, + new RecordingLogger()), + 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 deleted file mode 100644 index a1bd61020..000000000 --- a/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs +++ /dev/null @@ -1,309 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Text.Json; -using Netclaw.Cli.Provider; -using Netclaw.Configuration; -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_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 })); - } -} 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/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/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/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/SecretsFileWriterTests.cs b/src/Netclaw.Configuration.Tests/SecretsFileWriterTests.cs index b2ad969e1..71ddf381a 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,131 @@ 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 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)); + } } 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..ae0e6af69 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,30 @@ 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; } } 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..a5d3507a4 100644 --- a/src/Netclaw.Configuration/SecretsFileWriter.cs +++ b/src/Netclaw.Configuration/SecretsFileWriter.cs @@ -3,6 +3,8 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Collections.Concurrent; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Netclaw.Configuration.Secrets; @@ -18,6 +20,8 @@ namespace Netclaw.Configuration; /// public static class SecretsFileWriter { + private static readonly TimeSpan SecretsLockTimeout = TimeSpan.FromSeconds(30); + private static readonly JsonSerializerOptions DefaultJsonOptions = new() { WriteIndented = true, @@ -28,6 +32,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 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 +254,51 @@ 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); + + /// + /// 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) + { + 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 readonly ConcurrentDictionary Gates = new(StringComparer.Ordinal); + + /// + /// 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(secretsPath); + var directory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("Secrets path has no parent directory."); + if (Directory.Exists(directory)) + { + directory = new DirectoryInfo(directory).ResolveLinkTarget(returnFinalTarget: true)?.FullName + ?? directory; + } + + var key = Path.Combine(directory, Path.GetFileName(fullPath)); + return OperatingSystem.IsWindows() ? key.ToUpperInvariant() : key; + } + + private sealed class SecretsLock(SemaphoreSlim gate) : IDisposable + { + public void Dispose() => gate.Release(); + } } 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..be6719e59 100644 --- a/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs +++ b/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs @@ -14,9 +14,11 @@ 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; +using Netclaw.Daemon.Tests.Mcp; using Netclaw.Daemon.Services; using Netclaw.Providers.OAuth; using Netclaw.Tools; @@ -283,22 +285,27 @@ 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, + McpOAuthTestDoubles.UnusedRegistrar(), + flowBroker, + new DaemonConfig(), NullNotificationSink.Instance, TimeProvider.System, - NullLogger.Instance); + new McpClientRuntime(), + NullLogger.Instance, + new SessionConfig()); await manager.StartAsync(CancellationToken.None); try @@ -325,11 +332,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..e1f5ef93b --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs @@ -0,0 +1,578 @@ +// ----------------------------------------------------------------------- +// +// 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 TransportFailure_ReconnectsForLaterCallsAndDoesNotReplay() + { + 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 CandidateInitializationAndDisposalFailures_AreLoudAndPriorToolsRemainPublished() + { + 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 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 ReplacementDisposesTheRetiredClientAndDisposeWithoutStopClearsPublishedState() + { + var runtime = new ControlledMcpClientRuntime(); + 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); + Assert.True(await harness.Manager.TryReconnectAsync(ServerName, TestContext.Current.CancellationToken)); + + // 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)); + } + 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, + McpOAuthTestDoubles.UnusedRegistrar(), + _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..4dc933a2a 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,72 @@ 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 WrappedRetiredCredentialWriterIsClassifiedAsCredentialPersistence() + { + var exception = new InvalidOperationException( + "SDK token cache callback failed.", + new McpOAuthRetiredCredentialWriterException("The prior connection no longer owns credentials.")); + + 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("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 27ce28cb0..a86a92441 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,21 @@ private async Task CreateAppAsync( servers, toolRegistry, new ToolConfig(), - oauthService, + credentialStore, + McpOAuthTestDoubles.UnusedRegistrar(), + 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 +144,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,31 +160,14 @@ 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); } - [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] @@ -222,65 +203,57 @@ 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() + public async Task GetStatuses_includes_lastErrorAt_for_connection_failure() { 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); + 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); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var response = await app.GetTestClient().GetAsync("/api/mcp/statuses", ct); 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 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()); + 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 GetOauthStatus_returns_200_with_status_field_for_known_server() + public async Task OAuthStatusByNameAndStateIncludeSameSafeStructuredTerminalError() { 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 _)); - } + 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); - [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 response = await client.GetAsync( + $"/api/mcp/oauth/status-by-state/{flow.State}", ct); var body = await response.Content.ReadFromJsonAsync(ct); - Assert.True(body.TryGetProperty("status", out _)); + 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 ───────────────────────────────── @@ -300,7 +273,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 +294,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 +314,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..ebb218a95 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Mcp/McpOAuthCredentialStoreTests.cs @@ -0,0 +1,506 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +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 AuthorizationCandidateStaysLocalUntilPublication() + { + 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 candidate.StoreTokensAsync(Tokens("candidate-access", "candidate-refresh"), CancellationToken.None); + + 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 PublishedCandidatePersistsRefreshesAndSurvivesRestart() + { + var paths = Paths(); + var store = CreateStore(paths); + 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 CommitFailureLeavesActiveStateUntouched() + { + var paths = Paths(); + Directory.CreateDirectory(paths.SecretsPath); + var store = CreateStore(paths); + var candidate = store.CreateTokenCache(ServerName, Resource, "static-client", true); + await candidate.StoreTokensAsync(Tokens("not-published", null), 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); + } + + [Fact] + public async Task OmittedRefreshTokenRetainsMatchingClientToken() + { + var store = CreateStore(); + var active = await PublishStaticAsync(store, "old-access", "keep-refresh"); + + await active.StoreTokensAsync(Tokens("new-access", null), CancellationToken.None); + + Assert.Equal("keep-refresh", store.GetActiveForTests(ServerName)?.RefreshToken?.Value); + } + + [Fact] + public async Task ExplicitAuthorizationSupersedesRefreshThatFinishesFirst() + { + var store = CreateStore(); + 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); + + store.Publish(candidate, CancellationToken.None); + + var committed = store.GetActiveForTests(ServerName); + Assert.Equal("authorized-access", committed?.AccessToken.Value); + Assert.Equal("raced-refresh", committed?.RefreshToken?.Value); + } + + [Fact] + public async Task OrdinaryCandidatePersistsRotatedTokenBeforePublication() + { + var paths = Paths(); + var store = CreateStore(paths); + var active = await PublishStaticAsync(store, "seed", "seed-refresh"); + var candidate = store.CreateTokenCache(ServerName, Resource, "static-client", false); + + await candidate.StoreTokensAsync(Tokens("rotated", "rotated-refresh"), CancellationToken.None); + store.Discard(candidate); + + Assert.Equal("rotated", CreateStore(paths).GetActiveForTests(ServerName)?.AccessToken.Value); + Assert.Equal("rotated", (await active.GetTokensAsync(CancellationToken.None))?.AccessToken); + } + + [Fact] + public async Task RetiredCacheCannotOverwritePublishedCredentials() + { + var store = CreateStore(); + var retired = await PublishStaticAsync(store, "generation-one", "refresh-one"); + var current = store.CreateTokenCache(ServerName, Resource, "static-client", false); + store.Publish(current, CancellationToken.None); + + 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 OrdinaryCandidateCannotOverwriteConcurrentRefresh() + { + 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 DynamicClientIdentityAndSecretSurviveRestart() + { + var paths = Paths(); + var store = CreateStore(paths); + 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.GetIdentity( + restarted.CreateTokenCache(ServerName, Resource, null, false)); + + Assert.Equal("dynamic-client", restored.ClientId); + Assert.Equal("dynamic-secret", restored.ClientSecret); + Assert.True(restored.DynamicClientRegistration); + } + + [Fact] + public async Task RawSecretsFileDoesNotContainOAuthSecrets() + { + var paths = Paths(); + var protector = SecretsProtection.CreateProtector(paths); + var store = new McpOAuthCredentialStore( + 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-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); + } + + [Fact] + public async Task LegacyMetadataFileRemainsUntouched() + { + var paths = Paths(); + var metadataPath = Path.Combine(paths.ConfigDirectory, "mcp-oauth-metadata.json"); + var original = Encoding.UTF8.GetBytes("{\n \"legacy\": true\n}\n"); + File.WriteAllBytes(metadataPath, original); + + await PublishStaticAsync(CreateStore(paths), "access", "refresh"); + + 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 PublishStaticAsync(store, "access", "refresh"); + + var cache = store.CreateTokenCache(ServerName, equivalent, null, false); + + Assert.Equal("access", (await cache.GetTokensAsync(CancellationToken.None))?.AccessToken); + } + + [Fact] + public async Task ChangedResourceWithholdsTokensAndDynamicIdentity() + { + var store = CreateStore(); + await PublishDynamicAsync(store); + + var cache = store.CreateTokenCache(ServerName, "https://other.example/mcp", null, false); + + Assert.Null(await cache.GetTokensAsync(CancellationToken.None)); + 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); + + Assert.Equal("configured-client", store.GetIdentity(cache).ClientId); + Assert.False(store.GetIdentity(cache).DynamicClientRegistration); + } + + [Fact] + public async Task ExactLegacyResourceMatchMigratesCredentialsBeforeUse() + { + var paths = Paths(); + WriteLegacyCredentials(paths, Resource); + var store = CreateStore(paths); + + 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 void LegacyMigrationFailureExposesNoCredentials() + { + 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 UnexpiredLegacyCredentialWithoutObtainedAtIsNotReportedExpired() + { + // 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)); + } + + [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")] + public async Task LegacyResourceEquivalentToConfiguredEndpointMigrates(string legacyResource) + { + var paths = Paths(); + WriteLegacyCredentials(paths, legacyResource); + var store = CreateStore(paths); + + var cache = store.CreateTokenCache(ServerName, Resource, null, false); + + Assert.Equal("legacy-access", (await cache.GetTokensAsync(CancellationToken.None))?.AccessToken); + Assert.Equal(Resource, store.GetActiveForTests(ServerName)?.ResourceIdentity); + } + + [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")] + // 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(); + WriteLegacyCredentials(paths, legacyResource); + 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 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() + { + // 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 store = CreateStore(paths); + + Assert.Null(store.GetActiveForTests(ServerName)); + } + + [Fact] + public async Task ForgettingARejectedClientIdentityKeepsTokensAndForcesReregistration() + { + 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 NewDynamicIdentityDoesNotInheritOldRefreshToken() + { + var store = CreateStore(); + 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 PublishStaticAsync( + McpOAuthCredentialStore store, + string accessToken, + string? refreshToken) + { + 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 PublishDynamicAsync(McpOAuthCredentialStore store) + { + 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() + { + 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; + } + + /// + /// 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(), + _time, + 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/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/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/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..188579516 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs @@ -0,0 +1,1290 @@ +// ----------------------------------------------------------------------- +// +// 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 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.GetActiveForTests(harness.ServerName); + Assert.NotNull(active); + Assert.Equal("client-1", active!.ClientId); + Assert.Equal("secret-client-1", active.ClientSecret?.Value); + 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.GetActiveForTests(harness.ServerName)?.AccessToken.Value); + } + + [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 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); + + 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 RuntimeFlowExpiryDiscardsCandidateCredentials() + { + 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 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.Null(harness.Credentials.GetActiveForTests(harness.ServerName)); + + time.Advance(McpOAuthFlowBroker.FlowLifetime); + Assert.Equal(McpOAuthFlowStatus.Failed, harness.Broker.GetStatusByState(started.State).Status); + Assert.Null(harness.Credentials.GetActiveForTests(harness.ServerName)); + barrier.Release.TrySetResult(true); + } + + [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.GetActiveForTests(harness.ServerName)!; + 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.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); + } + + [Fact] + public async Task ExplicitAuthorizationSupersedesActiveRefreshThatFinishesFirst() + { + 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.Completed, terminal.Status); + Assert.NotEqual( + "active-rotated-during-flow", + harness.Credentials.GetActiveForTests(harness.ServerName)?.AccessToken.Value); + } + + [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 RejectedClientIdentityIsDiscardedSoTheNextAuthorizationRegistersAfresh() + { + 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); + } + + // 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); + + Assert.Equal(McpOAuthFlowStatus.Failed, (await rejectedFlow.WaitForTerminalAsync(ct)).Status); + + // 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); + + 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.Completed, (await replacementFlow.WaitForTerminalAsync(ct)).Status); + Assert.Equal("client-2", harness.Credentials.GetActiveForTests(harness.ServerName)?.ClientId); + } + + [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.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] + 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.GetActiveForTests(harness.ServerName)?.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": { + "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 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); + + // 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("dynamic client registration", StringComparison.Ordinal) + && exception.ToString().Contains("HTTP 403", 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, + McpOAuthTestDoubles.RegistrarFor(server.CreateHttpClient()), + 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 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) + { + 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 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 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 AcceptBearer(string token) => _state.AcceptBearer(token); + + 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..cab29ce03 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,34 @@ 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, + McpOAuthTestDoubles.UnusedRegistrar(), + 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 deleted file mode 100644 index 6019c647c..000000000 --- a/src/Netclaw.Daemon.Tests/Security/BootstrapDeviceSeederTests.cs +++ /dev/null @@ -1,99 +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)); - } -} diff --git a/src/Netclaw.Daemon/Mcp/McpClientManager.cs b/src/Netclaw.Daemon/Mcp/McpClientManager.cs index 7f0e3c08e..61a1c430c 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; @@ -20,38 +24,68 @@ internal sealed class McpClientManager : IHostedService, IDisposable, IMcpToolIn private readonly Dictionary _serverEntries; private readonly ToolRegistry _toolRegistry; private readonly ToolConfig _toolConfig; - private readonly McpOAuthService _oauthService; + private readonly McpOAuthCredentialStore _credentialStore; + private readonly McpOAuthClientRegistrar _registrar; + 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, + McpOAuthClientRegistrar registrar, + 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; + _registrar = registrar; + _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 +93,118 @@ 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 Task StopAsync(CancellationToken cancellationToken) + { + lock (_shutdownSync) + { + if (_stopTask is not null) + return _stopTask; + + _stopping = true; + _lifetimeCancellation.Cancel(); + _stopTask = StopCoreAsync(cancellationToken); + return _stopTask; } } - public async Task StopAsync(CancellationToken cancellationToken) + private async Task StopCoreAsync(CancellationToken cancellationToken) { - foreach (var (name, client) in _clients) + 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; + 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) + { + 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)) { - 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}' 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 _); + var started = _flowBroker.StartOrJoin(serverName); + if (started.Created) + _ = RunExplicitAuthorizationAsync(lifecycle, entry, started.Flow); - 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 +216,6 @@ public async Task InvokeAsync( { var server = new McpServerName(serverName); var tool = new ToolName(toolName); - return await InvokeSharedAsync(server, tool, arguments, ct); } @@ -142,212 +225,511 @@ private async Task InvokeSharedAsync( IDictionary? arguments, CancellationToken ct) { - if (!TryGetSharedFunction(serverName, toolName.Value, out var function) || function is null) + var snapshot = TryGetConnectedSnapshot(serverName); + if (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 (!await TryReconnectAsync(serverName, ct)) + throw CreateUnavailableException(serverName, toolName); + + snapshot = TryGetConnectedSnapshot(serverName) + ?? 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); + + return await InvokeFunctionAsync( + function, + $"{serverName.Value}/{toolName.Value}", + arguments, + ct); } - catch (Exception ex) + catch (McpException ex) when (!IsTransportOrSessionFailure(ex)) + { + return $"Error: MCP tool '{serverName.Value}/{toolName.Value}' failed: {ex.Message}"; + } + catch (Exception ex) when (IsTransportOrSessionFailure(ex)) { - _logger.LogDebug(ex, - "MCP tool '{ToolName}' failed on shared client '{ServerName}', attempting reconnect", - toolName.Value, serverName.Value); + transportFailure = ex; + } - var reconnected = await TryReconnectAsync(serverName, ct); - if (!reconnected - || !TryGetSharedFunction(serverName, toolName.Value, out var retryFunction) - || retryFunction is null) - throw; + // 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."); + } - return await InvokeFunctionAsync(retryFunction, $"{serverName.Value}/{toolName.Value}", arguments, ct); + private McpServerSnapshot? TryGetConnectedSnapshot(McpServerName serverName) + { + lock (_shutdownSync) + { + if (_stopping || !_servers.TryGetValue(serverName, out var lifecycle)) + return null; + + var current = lifecycle.Snapshot; + return current is not null && current.IsConnected ? current : null; } } - // 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 async Task ReconnectAfterTransportFailureAsync( + McpServerName serverName, + McpServerSnapshot observed, + Exception failure) { - var aiArgs = arguments is { Count: > 0 } - ? new AIFunctionArguments(arguments) - : null; + if (!_serverEntries.TryGetValue(serverName.Value, out var entry) + || !_servers.TryGetValue(serverName, out var lifecycle)) + return; - 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); + _logger.LogDebug(failure, + "MCP transport/session failure on '{ServerName}'; reconnecting for later calls without replay", + serverName.Value); + + 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 bool TryGetSharedFunction(McpServerName serverName, string toolName, out AIFunction? function) + private async Task ReconnectAsync( + McpServerLifecycle lifecycle, + McpServerEntry entry, + McpServerSnapshot observed, + CancellationToken callerCancellation, + DateTimeOffset? triggeringFailureAt) { - function = null; + if (IsStopping) + return false; + + if (_flowBroker.TryGetActive(observed.Name, out _)) + return observed.IsConnected; - if (!_sharedToolFunctions.TryGetValue(serverName, out var serverTools)) + using var candidateCancellation = CancellationTokenSource.CreateLinkedTokenSource( + callerCancellation, _lifetimeCancellation.Token); + + try + { + await lifecycle.Gate.WaitAsync(candidateCancellation.Token); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { return false; + } + + try + { + if (IsStopping) + return false; + + var current = lifecycle.Snapshot; + if (current is null) + return false; - return serverTools.TryGetValue(toolName, out function); + // 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 (_flowBroker.TryGetActive(current.Name, out _)) + return current.IsConnected; + + return await BuildAndPublishCandidateAsync( + lifecycle, + entry, + current, + candidateCancellation.Token, + triggeringFailureAt, + null); + } + finally + { + lifecycle.Gate.Release(); + } } - private async Task ConnectAsync(McpServerName name, McpServerEntry entry, CancellationToken ct) + private async Task BuildAndPublishCandidateAsync( + McpServerLifecycle lifecycle, + McpServerEntry entry, + McpServerSnapshot current, + CancellationToken ct, + DateTimeOffset? triggeringFailureAt, + McpOAuthFlow? authorizationFlow) { - // 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; + McpClient? candidate = null; + McpOAuthTokenCache? oauthCache = null; + Exception? candidateFailure = null; 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); + var created = await CreateClientAsync(current.Name, entry, authorizationFlow, ct); + candidate = created.Client; + oauthCache = created.OAuthCache; + + 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) + { + if (_stopping) + return false; + if (authorizationFlow is null + && _flowBroker.TryGetActive(current.Name, out _)) + return current.IsConnected; - _toolRegistry.WithMcpTools(name.Value, tools, entry.GrantCategory, this, - _maxToolDescriptionChars, _maxToolSchemaWarnChars, _logger); + if (authorizationFlow is not null) + { + _flowBroker.BeginCommit(authorizationFlow); + if (oauthCache is null) + throw new InvalidOperationException("Interactive OAuth candidate has no token cache."); + } - _sharedToolFunctions[name] = sharedFunctions; - _clients[name] = client; - client = null; - _statuses[name] = new McpServerStatus(name, McpConnectionState.Connected, tools.Count, null); + if (oauthCache is not null) + _credentialStore.Publish(oauthCache, CancellationToken.None); + + replacement = new McpServerSnapshot( + current.Name, + candidate, + functions, + checked(current.Generation + 1), + connectedStatus); + // 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; + } - _logger.LogInformation("MCP server '{Name}' connected ({ToolCount} tools)", name.Value, tools.Count); + 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, + replacement.Generation, + tools.Count); + if (authorizationFlow is not null) + _flowBroker.Complete(authorizationFlow); return true; } + catch (OperationCanceledException ex) when (_lifetimeCancellation.IsCancellationRequested) + { + candidateFailure = ex; + return false; + } + catch (OperationCanceledException ex) + { + candidateFailure = ex; + throw; + } catch (Exception ex) { - if (_clients.TryRemove(name, out var existing)) + candidateFailure = 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) { - try - { - await existing.DisposeAsync(); - } - catch (Exception disposeEx) - { - _logger.LogDebug(disposeEx, - "Error disposing MCP client '{Name}' after failed connect rollback", name.Value); - } + _logger.LogWarning(ex, + "MCP server '{Name}' replacement failed; generation {Generation} remains connected", + current.Name.Value, + current.Generation); } - - _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; - - if (failureStatus.State is McpConnectionState.AwaitingAuth) + else { - _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"); + ReportConnectionFailure(current.Name, failureStatus, ex, hasCachedTokens, hasOAuthRuntimeHints); } - else 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}"); - } - } - else + if (authorizationFlow is not null) { - _logger.LogWarning(ex, "Failed to connect to MCP server '{Name}'", name.Value); - EmitDisconnectedAlert(name, $"MCP server '{name.Value}' connection failed: {failureStatus.ErrorMessage}"); + // 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, + "Explicit MCP OAuth candidate failed for server '{Name}' during {Operation} (provider status {ProviderStatus})", + current.Name.Value, + error.Operation, + error.Status); + _flowBroker.Fail(authorizationFlow, error); } return false; } finally { - if (client is not null) + if (oauthCache is not null) + _credentialStore.Discard(oauthCache); + 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); + _logger.LogError(disposalFailure, + "Error disposing unpublished MCP client '{Name}'", + current.Name.Value); + if (candidateFailure is not null) + { + throw new AggregateException( + "MCP candidate initialization and disposal both failed.", + candidateFailure, + disposalFailure); + } + throw; } } + } } - private async Task CreateClientAsync( - McpServerName name, + private async Task RunExplicitAuthorizationAsync( + McpServerLifecycle lifecycle, McpServerEntry entry, - CancellationToken ct, - bool updateStatusOnAuthFailure) + McpOAuthFlow flow) { - // 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 }) - { - _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"); - - return null; - } + if (IsStopping || lifecycle.Snapshot is not { } current) + return; + + await BuildAndPublishCandidateAsync( + lifecycle, + entry, + current, + cancellation.Token, + null, + flow); + } + 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); + _flowBroker.Fail(flow, error); + } + 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); + } + } + + 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 client = lifecycle.Snapshot?.Client; + // 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; + + // 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 + { + lifecycle.Gate.Release(); + } + } - var transport = CreateTransport(name, entry); + private async Task DisposeCandidateAsync(McpServerName name, McpClient candidate) + { + await _clientRuntime.DisposeAsync(candidate); + _logger.LogDebug("Unpublished MCP client '{Name}' disposed", name.Value); + } - return await McpClient.CreateAsync(transport, new McpClientOptions + private async Task DisposeReplacedAsync(McpServerName name, McpClient replaced) + { + try { - ClientInfo = new() + 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, + 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); + } + + private static InvalidOperationException CreateUnavailableException( + McpServerName serverName, + ToolName toolName) + => new($"MCP server '{serverName.Value}' is unavailable or tool '{toolName.Value}' is not registered."); + + private async Task CreateClientAsync( + McpServerName name, + McpServerEntry entry, + McpOAuthFlow? authorizationFlow, + CancellationToken ct) + { + McpOAuthTokenCache? oauthCache = null; + if (entry.Transport is not "stdio" && !HasConfiguredAuthorizationHeader(entry)) + { + oauthCache = _credentialStore.CreateTokenCache( + name, + entry.Url!, + entry.OAuthClientId, + authorizationFlow is not null); + } + + try + { + // 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) { - Name = "netclaw", - Title = "Netclaw", - Version = BuildInfo.Version, - WebsiteUrl = "https://netclaw.dev", - Description = "Open-source autonomous operations agent built on Akka.NET", - }, - }, cancellationToken: ct); + 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 + { + 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) + private IClientTransport CreateTransport( + McpServerName serverName, + McpServerEntry entry, + McpOAuthTokenCache? oauthCache, + McpOAuthFlow? authorizationFlow) { if (entry.Transport is "stdio") { - return new StdioClientTransport(new StdioClientTransportOptions + return _clientRuntime.CreateStdioTransport(new StdioClientTransportOptions { Command = entry.Command!, Arguments = entry.Arguments ?? [], @@ -357,92 +739,104 @@ 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(entry, oauthCache!, 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( + McpServerEntry entry, + McpOAuthTokenCache cache, + 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 = _credentialStore.GetIdentity(cache); return new ClientOAuthOptions { - RedirectUri = new Uri("http://127.0.0.1:5199/api/mcp/oauth/callback"), - ClientId = metadata?.ClientId ?? entry.OAuthClientId, + RedirectUri = BuildRedirectUri(), + 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), - DynamicClientRegistration = new DynamicClientRegistrationOptions - { - ClientName = "netclaw", - ResponseDelegate = (response, _) => - { - _oauthService.UpdateMetadataClientId(serverNameCapture, response.ClientId); - return Task.CompletedTask; - }, - }, + TokenCache = cache, + AuthorizationRedirectDelegate = authorizationFlow is null + ? static (_, _, _) => Task.FromResult(null) + : authorizationFlow.HandleAuthorizationRedirectAsync, + AdditionalAuthorizationParameters = authorizationFlow is null + ? new Dictionary() + : new Dictionary { ["state"] = authorizationFlow.State }, + + // 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) - => !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 +845,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, + 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."; } - internal static McpServerStatus CreateUnreachableStatus(McpServerName serverName, Exception ex) - => new(serverName, McpConnectionState.Unreachable, 0, - string.IsNullOrWhiteSpace(ex.Message) ? "Failed to reach MCP server." : ex.Message); + 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 +951,109 @@ 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; + + 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); + + internal static McpErrorResponse CreateSafeOAuthError(Exception ex, string fallbackOperation) + { + var status = FindHttpStatus(ex); + var exceptions = EnumerateExceptionTree(ex).ToArray(); + var operation = exceptions.Any(candidate => + candidate is McpOAuthRetiredCredentialWriterException + 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; + 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; + 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; - // Check inner exceptions for auth failures - if (ex.InnerException is not null) - return IsAuthFailure(ex.InnerException); + if (ex is not McpException) + return false; - 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 +1062,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 +1087,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 +1101,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 +1123,114 @@ private void LogToolDrift(McpServerName serverName, IList discove public void Dispose() { - foreach (var client in _clients.Values) + var emergencyCleanup = false; + lock (_shutdownSync) { - try { (client as IDisposable)?.Dispose(); } - catch (Exception ex) { _logger.LogDebug(ex, "Error disposing MCP client during shutdown"); } + if (_disposed) + return; + + _disposed = true; + _stopping = true; + _lifetimeCancellation.Cancel(); + emergencyCleanup = _stopTask is null; } - _clients.Clear(); - _sharedToolFunctions.Clear(); + if (emergencyCleanup) + { + foreach (var (serverName, lifecycle) in _servers) + { + _toolRegistry.PublishMcpServerTools(serverName.Value, []); + lifecycle.Publish(null); + } + } + + _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, + McpOAuthTokenCache? OAuthCache); + +internal sealed class McpServerLifecycle(McpServerSnapshot initialSnapshot) +{ + private McpServerSnapshot? _snapshot = initialSnapshot; + + public SemaphoreSlim Gate { get; } = new(1, 1); + + public McpServerSnapshot? Snapshot => Volatile.Read(ref _snapshot); + + public void Publish(McpServerSnapshot? snapshot) => Volatile.Write(ref _snapshot, snapshot); +} + +internal sealed record McpServerSnapshot( + McpServerName Name, + McpClient? Client, + IReadOnlyDictionary ToolFunctions, + long Generation, + McpServerStatus Status) +{ + private static readonly IReadOnlyDictionary EmptyFunctions = + new ReadOnlyDictionary(new Dictionary()); + + public bool IsConnected + => 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); } internal enum McpConnectionState @@ -615,4 +1246,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..d546c53d3 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,120 @@ 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); + } + // 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; + } + 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 +150,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 +169,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/McpOAuthClientRegistrar.cs b/src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs new file mode 100644 index 000000000..73297ea79 --- /dev/null +++ b/src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs @@ -0,0 +1,236 @@ +// ----------------------------------------------------------------------- +// +// 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) + { + // 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 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; + 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 new file mode 100644 index 000000000..cc3e49276 --- /dev/null +++ b/src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs @@ -0,0 +1,611 @@ +// ----------------------------------------------------------------------- +// +// 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 sealed class McpOAuthRetiredCredentialWriterException(string message) : InvalidOperationException(message); + +internal sealed record McpOAuthClientIdentity( + string? ClientId, + string? ClientSecret, + bool DynamicClientRegistration); + +/// +/// 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 McpOAuthCredentialStore _store; + + internal McpOAuthTokenCache( + McpOAuthCredentialStore store, + McpServerName serverName, + string canonicalResource, + McpOAuthClientIdentity identity, + McpOAuthTokenSet? credentials, + int baseRevision, + bool explicitAuthorization) + { + _store = store; + ServerName = serverName; + CanonicalResource = canonicalResource; + Identity = identity; + Credentials = credentials; + BaseRevision = baseRevision; + ExplicitAuthorization = explicitAuthorization; + } + + internal McpServerName ServerName { get; } + + internal string CanonicalResource { get; } + + internal McpOAuthClientIdentity Identity { get; set; } + + internal McpOAuthTokenSet? Credentials { get; set; } + + internal int BaseRevision { get; set; } + + internal bool ExplicitAuthorization { get; } + + internal bool Dirty { get; set; } + + 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) + { + _store.StoreTokens(this, tokens, cancellationToken); + return default; + } +} + +/// +/// Durable authority for active MCP OAuth credentials. The daemon lifecycle +/// gate publishes one cache owner; unpublished authorization state stays local. +/// +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 _servers = new(); + + public McpOAuthCredentialStore( + NetclawPaths paths, + TimeProvider timeProvider, + ISecretsProtector protector, + ILogger logger) + { + _paths = paths; + _timeProvider = timeProvider; + _protector = protector ?? throw new ArgumentNullException(nameof(protector)); + _logger = logger; + LoadDurableState(); + } + + 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 McpOAuthTokenCache CreateTokenCache( + McpServerName serverName, + string resourceIdentity, + string? configuredClientId, + bool explicitAuthorization) + { + var canonicalResource = CanonicalizeResource(resourceIdentity); + var state = GetState(serverName); + lock (state.Sync) + { + var active = GetBoundOrMigrateLegacy( + serverName, + state, + canonicalResource, + configuredClientId); + McpOAuthClientIdentity identity; + if (!string.IsNullOrWhiteSpace(configuredClientId)) + { + identity = new McpOAuthClientIdentity(configuredClientId, null, false); + } + else + { + identity = active is { DynamicClientRegistration: true, ClientId: not null } + ? new McpOAuthClientIdentity(active.ClientId, active.ClientSecret?.Value, true) + : new McpOAuthClientIdentity(null, null, false); + } + + return new McpOAuthTokenCache( + this, + serverName, + canonicalResource, + identity, + explicitAuthorization ? null : active, + state.Revision, + explicitAuthorization); + } + } + + public void Publish(McpOAuthTokenCache cache, CancellationToken cancellationToken) + { + 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 Discard(McpOAuthTokenCache cache) + { + 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); + var state = GetState(serverName); + lock (state.Sync) + return IsBound(state.Active, canonical) ? Clone(state.Active) : null; + } + + public bool HasAnyActive(McpServerName serverName) + { + var state = GetState(serverName); + lock (state.Sync) + return state.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; + } + + /// + /// 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 ForgetClientIdentity( + McpServerName serverName, + string resourceIdentity, + CancellationToken cancellationToken) + { + var canonical = CanonicalizeResource(resourceIdentity); + var state = GetState(serverName); + lock (state.Sync) + { + 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; + } + + _logger.LogWarning( + "Discarded the rejected OAuth client identity for MCP server '{Name}'. " + + "The next authorization will register a new client.", + serverName.Value); + } + } + + internal McpOAuthTokenSet? GetActiveForTests(McpServerName serverName) + { + var state = GetState(serverName); + lock (state.Sync) + return Clone(state.Active); + } + + internal McpOAuthClientIdentity GetIdentity(McpOAuthTokenCache cache) + { + var state = GetState(cache.ServerName); + lock (state.Sync) + return cache.Identity; + } + + internal TokenContainer? ReadTokens(McpOAuthTokenCache cache, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var state = GetState(cache.ServerName); + lock (state.Sync) + { + if (!IsBound(cache.Credentials, cache.CanonicalResource)) + return null; + return ToTokenContainer(cache.Credentials!); + } + } + + internal void StoreTokens( + McpOAuthTokenCache cache, + TokenContainer tokens, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var state = GetState(cache.ServerName); + lock (state.Sync) + { + 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) + { + throw new McpOAuthRetiredCredentialWriterException( + "Active OAuth credentials changed while the replacement connection initialized."); + } + + var replacement = CreateReplacement(tokens, cache.Credentials, cache.Identity, cache.CanonicalResource); + if (cache.Published || !cache.ExplicitAuthorization) + { + 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; + } + } + + cache.Credentials = replacement; + cache.Dirty = cache.ExplicitAuthorization && !cache.Published; + } + } + + /// + /// 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) + { + ArgumentNullException.ThrowIfNull(identity); + var state = GetState(cache.ServerName); + lock (state.Sync) + { + ThrowIfRetired(cache); + cache.Identity = identity; + } + } + + private McpOAuthTokenSet CreateReplacement( + TokenContainer tokens, + McpOAuthTokenSet? retainedFrom, + McpOAuthClientIdentity identity, + string canonicalResource) + { + var obtainedAt = tokens.ObtainedAt == default ? _timeProvider.GetUtcNow() : tokens.ObtainedAt; + var replacement = new McpOAuthTokenSet + { + AccessToken = new SensitiveString(tokens.AccessToken), + 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 = canonicalResource, + }; + replacement.RefreshToken = tokens.RefreshToken is not null + ? new SensitiveString(tokens.RefreshToken) + : CanRetainRefreshToken(retainedFrom, replacement) + ? retainedFrom!.RefreshToken + : null; + return replacement; + } + + 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.Clamp((expiresAt - obtainedAt).TotalSeconds, 0, int.MaxValue) + : (int?)null; + return new TokenContainer + { + TokenType = credentials.TokenType, + AccessToken = credentials.AccessToken.Value, + RefreshToken = credentials.RefreshToken?.Value, + ExpiresIn = expiresIn, + ObtainedAt = obtainedAt, + Scope = credentials.Scope, + }; + } + + private void Persist( + McpServerName serverName, + McpOAuthTokenSet credentials, + CancellationToken cancellationToken) + { + SecretsFileWriter.Update( + _paths.SecretsPath, + (root, _) => + { + var section = root[SectionKey]?.AsObject() ?? []; + root[SectionKey] = section; + section[serverName.Value] = JsonSerializer.SerializeToNode(credentials, JsonOptions); + return (root, null); + }, + JsonOptions, + _protector, + cancellationToken); + } + + private void LoadDurableState() + { + if (!File.Exists(_paths.SecretsPath)) + return; + + Dictionary loaded; + try + { + loaded = SecretsFileWriter.Update>( + _paths.SecretsPath, + (root, _) => + { + var result = new Dictionary(); + if (root[SectionKey] is not JsonObject section) + return (null, result); + foreach (var (name, node) in section) + { + 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; + } + + 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 ServerCredentialState GetState(McpServerName serverName) + => _servers.GetOrAdd(serverName, static _ => new ServerCredentialState(null)); + + private McpOAuthTokenSet? GetBoundOrMigrateLegacy( + McpServerName serverName, + ServerCredentialState state, + string canonicalResource, + string? configuredClientId) + { + if (IsBound(state.Active, canonicalResource)) + return Clone(state.Active); + if (state.Active is not { ResourceIdentity: null, McpServerUrl: not null } legacy) + return null; + + 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; + } + + 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; + } + + 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); + } + + /// + /// 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) + { + 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, 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) + => credentials?.ResourceIdentity is { } binding + && string.Equals(binding, canonicalResource, StringComparison.Ordinal); + + private static void ThrowIfRetired(McpOAuthTokenCache cache) + { + if (cache.Retired) + throw new McpOAuthRetiredCredentialWriterException( + "A retired OAuth connection attempted to replace active credentials."); + } + + private static McpOAuthTokenSet? Clone(McpOAuthTokenSet? credentials) + => credentials is null + ? null + : JsonSerializer.Deserialize( + JsonSerializer.Serialize(credentials, JsonOptions), JsonOptions)!; + + private sealed class ServerCredentialState(McpOAuthTokenSet? active) + { + public object Sync { get; } = new(); + + 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 new file mode 100644 index 000000000..a776b8d08 --- /dev/null +++ b/src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs @@ -0,0 +1,386 @@ +// ----------------------------------------------------------------------- +// +// 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 _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 (_latestByServer.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); + _latestByServer[serverName] = flow; + _byState[state] = flow; + return new McpOAuthFlowStart(flow, true); + } + } + + public bool TryGetActive(McpServerName serverName, out McpOAuthFlow? flow) + { + lock (_sync) + { + if (_latestByServer.TryGetValue(serverName, out flow) && !flow.IsTerminal) + return true; + flow = null; + return false; + } + } + + 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")); + 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(); + + /// + /// Claims the unexpired flow for the non-cancellable commit sequence: + /// durable credential commit, cache publication, then completion. + /// + public void BeginCommit(McpOAuthFlow flow) + { + lock (_sync) + { + var now = _timeProvider.GetUtcNow(); + if (!_latestByServer.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")); + } + + 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); + + public void Dispose() + { + List flows; + lock (_sync) + { + if (_disposed) + return; + _disposed = true; + flows = _byState.Values.ToList(); + _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")); + } + } + + 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 Task WaitForAuthorizationUrlAsync(CancellationToken requestCancellation) + => _authorizationUrl.Task.WaitAsync(requestCancellation); + + public Task WaitForTerminalAsync(CancellationToken requestCancellation) + => _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..38eb8eb1b 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -787,15 +787,19 @@ 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.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)); + 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")]