Harden MCP OAuth lifecycle - #1708
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens Netclaw’s MCP OAuth and client lifecycle by removing the daemon’s bespoke OAuth protocol implementation, delegating OAuth discovery/PKCE/DCR/token handling to the MCP .NET SDK, and tightening daemon-owned lifecycle coordination, persistence, and operator diagnostics.
Changes:
- Replace Netclaw-owned MCP OAuth protocol code with an SDK-driven flow broker + durable credential store boundary, plus safer HTTP/CLI error surfacing.
- Add cross-process transactional secrets.json mutation (path-scoped mutex + atomic replace) and migrate secrets writers to replay changes against the latest locked document.
- Improve MCP tool publication consistency and cancellation/error behavior, plus broaden regression coverage (end-to-end + concurrency/locking scenarios).
Reviewed changes
Copilot reviewed 63 out of 63 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Netclaw.SecretsLockProbe/Program.cs | Cross-process probe used to validate secrets.json interprocess locking/serialization. |
| tests/Netclaw.SecretsLockProbe/Netclaw.SecretsLockProbe.csproj | Adds probe test project to the repo. |
| src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs | Switch provider token persistence to transactional SecretsFileWriter.Update. |
| src/Netclaw.Daemon/Security/BootstrapDeviceSeeder.cs | Persist bootstrap device token via transactional secrets update and add rollback semantics. |
| src/Netclaw.Daemon/Properties/AssemblyInfo.cs | Exposes daemon internals to CLI test assembly. |
| src/Netclaw.Daemon/Program.cs | Replaces McpOAuthService wiring with credential store + flow broker + runtime abstraction. |
| src/Netclaw.Daemon/Mcp/McpTokenCacheAdapter.cs | Removes legacy token-cache adapter used by the deleted manual OAuth stack. |
| src/Netclaw.Daemon/Mcp/McpOAuthService.cs | Removes daemon-owned OAuth discovery/DCR/PKCE/exchange/refresh implementation. |
| src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs | Introduces brokered, daemon-owned interactive OAuth flow state/lifetime coordination. |
| src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs | Updates MCP OAuth endpoints and status DTOs; adds structured error payloads and safer callback handling. |
| src/Netclaw.Daemon.Tests/Security/BootstrapDeviceSeederTests.cs | Adds regression coverage for transactional secrets commit/rollback behavior. |
| src/Netclaw.Daemon.Tests/Netclaw.Daemon.Tests.csproj | Adds MCP ASP.NET Core dependency for updated MCP/OAuth test coverage. |
| src/Netclaw.Daemon.Tests/Mcp/McpTokenCacheAdapterTests.cs | Removes tests for deleted McpTokenCacheAdapter. |
| src/Netclaw.Daemon.Tests/Mcp/McpSmokeHarness.cs | Rewires harness to use credential store + flow broker instead of McpOAuthService/PKCE HTTP. |
| src/Netclaw.Daemon.Tests/Mcp/McpReconnectionServiceTests.cs | Updates status shaping to include lastErrorAt timestamp. |
| src/Netclaw.Daemon.Tests/Mcp/McpOAuthServiceTests.cs | Removes tests for deleted McpOAuthService. |
| src/Netclaw.Daemon.Tests/Mcp/McpOAuthHeaderConflictTests.cs | Removes tests tied to old OAuth stack/header conflict logic. |
| src/Netclaw.Daemon.Tests/Mcp/McpOAuthFlowBrokerTests.cs | Adds unit coverage for broker semantics (coalescing, expiry, one-time state, cancellation). |
| src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs | Updates endpoint tests for new brokered flow + structured errors + lastErrorAt exposure. |
| src/Netclaw.Daemon.Tests/Mcp/McpClientManagerStatusTests.cs | Strengthens redaction/safe error formatting and verifies timestamp propagation. |
| src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs | Updates MCP health reporting integration to new credential/broker wiring. |
| src/Netclaw.Configuration/SensitiveString.cs | Updates converter docs to reflect new MCP OAuth credential store usage. |
| src/Netclaw.Configuration/SecretsFileWriter.cs | Adds path-scoped cross-process locking and transactional update API for secrets.json mutations. |
| src/Netclaw.Configuration/NetclawPaths.cs | Removes deprecated MCP OAuth metadata path. |
| src/Netclaw.Configuration/McpOAuthTokenSet.cs | Expands durable MCP OAuth credential model (active/pending envelope + binding/epoch fields). |
| src/Netclaw.Configuration/McpOAuthServerMetadata.cs | Removes obsolete discovery metadata cache type. |
| src/Netclaw.Configuration.Tests/SecretsFileWriterTests.cs | Adds concurrency, cross-process, and loud-failure regression tests for secrets transactions. |
| src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj | Adds non-output reference to lock-probe project for test execution. |
| src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs | Replays wizard secret contributions against latest locked secrets.json instead of stale snapshots. |
| src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs | Writes local device token via UpdateSecretsFile transaction helper. |
| src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs | Replays secret mutations against latest secrets.json on save (avoids overwriting concurrent updates). |
| src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs | Migrates provider secret writes/removals to transactional UpdateSecretsFile. |
| src/Netclaw.Cli/Secrets/SecretsCommand.cs | Migrates secrets set to transactional update. |
| src/Netclaw.Cli/Provider/ProviderRenamer.cs | Makes rename atomic-ish across config+secrets with rollback on failure; collision check moved into secrets transaction. |
| src/Netclaw.Cli/Provider/ProviderCommand.cs | Migrates provider remove secret deletion to transactional UpdateSecretsFile. |
| src/Netclaw.Cli/Mcp/McpCommand.cs | Improves daemon error propagation/formatting (structured errors, status fallback) and avoids blank error output. |
| src/Netclaw.Cli/Daemon/PairCommand.cs | Writes device token via transactional UpdateSecretsFile. |
| src/Netclaw.Cli/Config/ProviderCredentialWriter.cs | Migrates provider API key persistence to transactional UpdateSecretsFile. |
| src/Netclaw.Cli/Config/ConfigFileHelper.cs | Adds UpdateSecretsFile helpers bridging dictionary mutations onto SecretsFileWriter.Update. |
| src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs | Adds regression test for replaying wizard secret contributions against latest secrets.json. |
| src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs | Adds regression test for replaying config editor secret actions without clobbering refreshed MCP tokens. |
| src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs | Adds rollback/collision/failure-mode coverage for new rename flow. |
| src/Netclaw.Cli.Tests/Mcp/McpOAuthEndToEndTests.cs | End-to-end regression for bodyless DCR failures propagating through daemon serialization and CLI output. |
| src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs | Adds structured error parsing + empty/malformed body fallback tests. |
| src/Netclaw.Actors/Tools/ToolRegistry.cs | Adds synchronization and atomic MCP tool publication hook to keep registry and snapshot state consistent. |
| src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs | Splits MCP tool adapter construction from registration to support new atomic publication pattern. |
| src/Netclaw.Actors/Tools/McpToolAdapter.cs | Ensures caller cancellation propagates rather than being formatted as a tool error string. |
| src/Netclaw.Actors.Tests/Tools/McpToolAdapterTests.cs | Adds regression test verifying cancellation propagation behavior. |
| openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md | Tracks implementation tasks and completion state for this change. |
| openspec/changes/simplify-mcp-oauth-lifecycle/specs/transactional-secrets/spec.md | Defines requirements/scenarios for transactional secrets mutations. |
| openspec/changes/simplify-mcp-oauth-lifecycle/specs/netclaw-mcp/spec.md | Updates MCP lifecycle/diagnostic requirements for the new model. |
| openspec/changes/simplify-mcp-oauth-lifecycle/specs/mcp-oauth/spec.md | Defines SDK-owned OAuth ownership boundary, brokered interactive flow, and diagnostic requirements. |
| openspec/changes/simplify-mcp-oauth-lifecycle/proposal.md | Change proposal and rationale for the refactor. |
| openspec/changes/simplify-mcp-oauth-lifecycle/.openspec.yaml | Adds OpenSpec change metadata. |
| Netclaw.slnx | Adds the new lock-probe test project to the solution. |
| feeds/skills/.system/files/netclaw-operations/SKILL.md | Updates operational guidance for new MCP OAuth flow/diagnostics and bumps skill version. |
| docs/prd/PRD-006-mcp-tool-integration.md | Updates PRD requirements to reflect SDK-owned OAuth + concurrent lifecycle hardening. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Swapping Netclaw's OAuth implementation for the MCP C# SDK's invalidated two credentials that the previous release was using successfully. Both notion and textforge were connected minutes before the swap and reported "requires OAuth authorization" minutes after. Notion recovered by reauthorizing; textforge could not, because re-registration is impossible against it. Registration returns to Netclaw. ClientOAuthProvider hard-codes token_endpoint_auth_method: "client_secret_post" in its RFC 7591 request and only reads the server's advertised token_endpoint_auth_methods_supported afterwards, for the token request. A server accepting public clients only -- textforge advertises ["none"] -- rejects that with 400 invalid_client_metadata, and 1.4.1 exposes no hook to change it: DynamicClientRegistrationOptions has four properties and its ResponseDelegate fires only on success. This is csharp-sdk#1611, open since May and unfixed in 1.4.1, every 2.0 prerelease, and main; PR netclaw-dev#1615 covers only the token request. Netclaw's own registration sent "none" and worked, as does the TypeScript SDK, which negotiates from the advertised list. McpOAuthClientRegistrar registers with the same method the SDK will later select, so the two cannot diverge, and seeds ClientOAuthOptions.ClientId so the SDK's registration path never runs. A missing or failing registration_endpoint names OAuthClientId as the remedy, restoring the message removed with McpOAuthService. Legacy credentials now migrate instead of failing closed: - ObtainedAt is stamped on migration. Absent from pre-existing records, it deserialized to 0001-01-01, and ExpiresAt - ObtainedAt saturated the int conversion to int.MaxValue, so the SDK treated every migrated credential as permanently expired. No test caught this because no fixture set ExpiresAt. - McpServerUrl is retained rather than nulled, so migration can be repeated if an endpoint is corrected later and a rollback keeps its binding. - Resource equivalence tolerates a trailing slash, path case, and a bare-origin indicator. Scheme, host, port and query must agree; origin-to-path narrowing is accepted, a path-scoped credential is never widened to a sibling path. Rejections log both bindings instead of returning null silently. - LoadDurableState no longer throws from a singleton constructor, where one unreadable secrets leaf took down daemon startup. RejectedDynamicClientId is replaced by discarding the rejected identity. A persisted marker is a latch: it survives restart and, against a server that cannot complete registration, makes every later attempt fail identically. Discarding keeps the tokens, needs no extra field, and self-heals. Deferred to their own changes, with the deletions recorded in the proposal: - The invocation lease and drain layer. A replaced or shutting-down client is disposed once its replacement publishes, so an in-flight call is cancelled rather than drained. The pre-existing behavior was the same, so this is a non-improvement rather than a regression. - Migrating the remaining secrets.json callers and transactional rollback for ProviderRenamer and BootstrapDeviceSeeder. SecretsFileWriter.Update stays, with an in-process lock whose key resolves a symlinked config directory. The two TUI save paths keep their fix because they were overwriting daemon-written McpOAuthTokens. MCP production code is 2488 lines against 1370 before the branch. The increase is the diagnostics taxonomy for netclaw-dev#1475, durable client identity, resource binding with migration, and candidate-then-publish. Net-negative production LOC was predicted in the proposal and not achieved; the proposal now records the measured figure. Verified live: a credential stored 2026-05-13 reconnected with 23 tools after a binary swap, with no reauthorization and with the temporary OAuthClientId override removed. 6068 tests pass, slopwatch and header checks are clean. Refs netclaw-dev#1475, netclaw-dev#1696, netclaw-dev#297
LegacyResourceFromADifferentAudienceFailsClosed already covers https://mcp.example.com/tools as an InlineData case, with the same assertions MismatchedLegacyResourceFailsClosedWithoutChangingDisk made.
Copilot flagged that the change claimed cross-process serialization of secrets.json while the implementation locks within one process. It was right, and the drift was wider than the one file. The comment on AcquireSecretsLock justified the in-process gate with "the CLI writes while it is stopped." Nothing enforces that. netclaw secrets set and netclaw provider add run against a live daemon and still write the file without taking the gate, so a CLI write can lose against a concurrent token refresh. That hazard is unchanged from before this branch, where every caller performed an unlocked read-modify-write, but the comment read as though it were covered. Corrected to match the code: - transactional-secrets spec no longer requires a cross-process lock or that every caller mutate inside the transaction, and its cross-process scenario is replaced by the symlinked-path case the tests actually cover. The remaining hazard is stated rather than implied away. - design goal and D4 amendment drop the cross-process wording and record why the lock and the caller migration are deferred together instead of half-done. - tasks 7.1 and 7.3 describe the in-process lock; 7.2 is reopened as partially delivered, naming the callers still doing unlocked writes. - tasks 1.1, 2.4, 2.5, 3.3 and 4.7 drop invocation-lease and drain language for what shipped; 4.6 and 4.8 are reopened as deferred with the drain work.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/Netclaw.Configuration/SecretsFileWriter.cs:262
- AcquireSecretsLock only serializes secrets.json mutations within the current process (SemaphoreSlim). That still allows lost updates when two processes write concurrently (e.g., daemon refreshing MCP OAuth tokens while a CLI/config command updates another secrets section). This also conflicts with the PR’s stated goal of serializing secrets.json mutations across processes.
Consider using the existing cross-process named Mutex pattern (SHA-256 of canonicalized path) so the lock spans read/decrypt/mutate/encrypt/atomic-replace across processes, not just threads.
/// <summary>
/// Serializes read-modify-write against one secrets file <em>within this process</em>.
///
/// 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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/Netclaw.Configuration/SecretsFileWriter.cs:263
- The PR description says secrets.json mutations are serialized across processes, but the new gate explicitly states it only serializes within the current process and that cross-process locking + migrating remaining callers are deferred. This is a material discrepancy for operators relying on the PR summary (CLI writes can still race the daemon).
/// Serializes read-modify-write against one secrets file <em>within this process</em>.
///
/// 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
src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs:325
- There is a duplicated
if (cache.Published || !cache.ExplicitAuthorization)statement, which looks like an accidental copy/paste and adds noise in a security-sensitive persistence path.
CommitFailureLeavesActiveStateUntouched forces a write failure by putting a directory where secrets.json belongs. Unix reports that as IOException; Windows reports UnauthorizedAccessException, which derives from SystemException rather than IOException, so Test-windows-latest failed on an assertion the other two platforms passed. Assert on either file-access failure and report the actual type when neither matches. The behavior under test -- the replace fails, active credentials are untouched, and the candidate stays unpublished -- is unchanged.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
src/Netclaw.Daemon/Mcp/McpOAuthCredentialStore.cs:324
- Redundant nested
ifstatements here (if (cache.Published || !cache.ExplicitAuthorization)repeated twice) make the control flow harder to read and look accidental. Collapsing to a singleifkeeps the same behavior and avoids confusion.
src/Netclaw.Configuration/SecretsFileWriter.cs:263 - PR description says secrets.json mutations are serialized across processes, but this implementation (and its doc comment) explicitly states the lock only serializes within the current process. Please either update the PR description to match reality, or add cross-process locking (e.g., a named mutex keyed by the canonical path) and migrate remaining writers to use it.
/// Serializes read-modify-write against one secrets file <em>within this process</em>.
///
/// 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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs:25
- The PR description says the MCP SDK is the sole owner of OAuth discovery and dynamic client registration (DCR), but this new component performs protected-resource discovery + RFC 7591 registration in Netclaw. Please reconcile the PR description (or call out this intentional exception) so reviewers/operators don't assume DCR moved into the SDK runtime path.
src/Netclaw.Configuration/SecretsFileWriter.cs:263 - The PR description claims secrets.json mutations are serialized "across processes", but SecretsFileWriter's lock is explicitly in-process only (SemaphoreSlim in a static dictionary). Either update the PR description to match current behavior, or implement a cross-process lock (e.g., named mutex keyed by canonical path) if cross-process serialization is a hard requirement.
/// Serializes read-modify-write against one secrets file <em>within this process</em>.
///
/// 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
src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs:281
- If the MCP SDK cancels the redirect-delegate call (sdkCancellation) before the browser callback delivers a code, _delegateOwner remains claimed and the flow can get stuck in "authorization already in progress" until expiry. That blocks subsequent delegate invocations from owning the flow and can wedge explicit auth/reconnect attempts for up to 5 minutes.
Stripping the RejectedDynamicClientId carry-forward removed the assignment but
left its if header above the identical condition that guards the persist block:
if (cache.Published || !cache.ExplicitAuthorization)
if (cache.Published || !cache.ExplicitAuthorization)
{
Evaluating the same condition twice is harmless, which is why the compiler,
slopwatch, the test suite and three CI platforms all passed over it. It still
misrepresents the persistence rules to anyone reading them.
The DCR failure path wrapped the raw provider response body in an inner HttpRequestException. That exception is logged, and daemon logs are OTLP-exported when telemetry is enabled, so a provider that echoes credentials in an error body would have them shipped off the machine. The inner exception turned out to be redundant. FindHttpStatus text-matches "HTTP <code>" on the message, and the outer message already carries it, so the typed status was never load-bearing. Removing it drops the raw body from every log path and is a net deletion rather than added redaction machinery. The endpoint, the status, and the RFC 7591 error / error_description fields are still reported, which is the part an operator can act on. DescribeOAuthError already allowlisted those two fields for the operator-facing message; nothing new was needed to keep the rejection diagnosable. ProviderBodySecretsStayInDaemonLogAndOutOfPublicOAuthErrors asserted the opposite - that the body reached the log - so it now asserts secrets appear in neither the operator error nor the log, while the endpoint and status still do. The two spec lines that required the full body in the daemon log are amended to match.
IsEquivalentResource accepted a legacy bare-origin resource as equivalent to any endpoint on that origin, including one carrying a query. The reasoning in the comment was that an origin-wide audience also covers the path and query beneath it, which holds only when the bare origin came from the authorization server's protected-resource metadata. The previous release also fell back to the configured URL when the server published no resource at all, so a bare origin can equally mean the endpoint has since been repointed. The record cannot distinguish the two, and a query can select a tenant, so binding a credential of unknown scope to a tenant-scoped endpoint is not safe. Narrowing origin -> path still migrates when neither side carries a query, which is the shape providers actually publish. A configured query now falls through to fail-closed and requires authorization, matching what the spec already said about query having to agree.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
src/Netclaw.Configuration/SecretsFileWriter.cs:9
- Unused using directive
using System.Text;(noEncoding/StringBuilderusage in this file). Consider removing to keep the file warning-clean.
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
src/Netclaw.Configuration.Tests/SecretsFileWriterTests.cs:10
- Unused using directive
using System.Diagnostics;(no diagnostics types referenced). Consider removing to keep the test project warning-clean.
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using Netclaw.Configuration.Secrets;
src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs:204
TryReadAuthorizationServerAsynctreats anyHttpRequestException(including transient network errors or HTTP 5xx) as “no OAuth metadata”, which can misclassify an OAuth-protected server as unauthenticated and let the SDK’s own DCR path run (undoing the registrar’s purpose). Consider only treating 404 / invalid JSON as “no metadata” and failing explicit authorization loudly for other HTTP/network errors.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs:282
- HandleAuthorizationRedirectAsync permanently claims _delegateOwner via CompareExchange but never releases it if the SDK cancels the delegate before the callback delivers a code. In that case the flow stays Pending but future delegate invocations will throw McpOAuthAuthorizationInProgressException, effectively wedging the flow until expiry even though no owner is waiting anymore.
The catch-and-rethrow around OperationCanceledException looks like a no-op. It is not, at the sites that precede a catch-all: OperationCanceledException is an Exception, so removing it lets a caller's abort be swallowed and returned as "Error: MCP tool 'x' failed: The operation was canceled." -- a normal-looking tool result the agent then acts on. Verified by deleting the clause in McpToolAdapter, which turns McpToolAdapterTests's cancellation case into "Assert.ThrowsAny() Failure: No exception was thrown". The filter carries its own weight. HttpClient timeouts arrive as TaskCanceledException, which derives from OperationCanceledException, while the caller's token is untouched. Those are faults, not caller intent, so they fall to the catch-all and return an actionable error rather than tearing down the caller's operation. Commented at the four sites where that reasoning applies: both McpToolAdapter execution paths and both MCP OAuth endpoints. McpClientManager.InvokeSharedAsync was not one of them. Every clause below its bare catch is filtered -- McpException that is not a transport failure, and Exception that is -- and IsTransportOrSessionFailure lists neither OperationCanceledException nor TaskCanceledException, so cancellation propagates with or without it. Removing it leaves all 913 daemon tests green, and CancellationAndApplicationErrors_DoNotReconnectOrDisposeHealthyClient still pins the behavior if a swallowing catch-all is ever added. Also reverts a guard-clause reflow in ToolRegistrationExtensions that changed no behavior. The PrepareMcpTools split stays: candidate-then-publish has to build adapters before deciding to publish, so building must not mutate the shared ToolRegistry.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (6)
src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs:1
- The PR description says “CLI and HTTP contracts are unchanged,” but these public response DTOs have new fields (
LastErrorAt, structuredError, and extraMcpErrorResponsemembers). If external clients consume these endpoints, this is an API contract change; either (mandatory) update the PR description to reflect the contract change (and/or document versioning/compat expectations), or (optional) consider keeping backward-compatible shapes (e.g., additive fields are usually OK, but any changed status codes or client expectations should be explicitly called out).
src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs:1 PruneTombstones()usesflow.ExpiresAtas the pruning timestamp, butExpiresAtis set to “created-at + FlowLifetime”. That means a flow that completes quickly will typically be retained for ~2×FlowLifetime(untilnow - FlowLifetime >= created-at + FlowLifetime). (Mandatory) Either adjust the pruning condition to reflect the intended retention window (e.g., prune based on a terminal timestamp) or rename/comment the logic so the actual retention policy is unambiguous and matches the code.
src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs:1redirectUriis not used. If analyzers are enabled, this can create noise and also misses an opportunity to validate that the SDK-provided redirect matches the daemon’s expected callback (which would strengthen callback safety). (Optional) Either remove the parameter if it’s never needed, or use it for validation/logging (or explicitly discard it) to make the intent clear.
src/Netclaw.Actors/Tools/ToolRegistry.cs:80GetRegistrationsSnapshot()allocates a new list on every read, and many public APIs now call it. If these methods sit on hot paths (tool selection/suggestion/index generation), this can add noticeable allocation/GC pressure. (Optional) Consider maintaining a volatile immutable snapshot (e.g.,ImmutableArray<ToolRegistration>orToolRegistration[]) updated on writes, so reads are lock-free and allocation-free while preserving thread safety.
/// <summary>All registered tools as AITool for ChatOptions.Tools.</summary>
public IReadOnlyList<AITool> GetAllTools() =>
GetRegistrationsSnapshot().Select(t => t.Tool.ToAITool()).ToList();
src/Netclaw.Actors/Tools/ToolRegistry.cs:466
GetRegistrationsSnapshot()allocates a new list on every read, and many public APIs now call it. If these methods sit on hot paths (tool selection/suggestion/index generation), this can add noticeable allocation/GC pressure. (Optional) Consider maintaining a volatile immutable snapshot (e.g.,ImmutableArray<ToolRegistration>orToolRegistration[]) updated on writes, so reads are lock-free and allocation-free while preserving thread safety.
private IReadOnlyList<ToolRegistration> GetRegistrationsSnapshot()
{
lock (_sync)
return _tools.ToList();
}
src/Netclaw.Configuration/SecretsFileWriter.cs:277
Gatesis a static dictionary that never evicts entries. If tests or tooling create many temporarysecretsPathvalues in-process, this can grow unbounded over time. (Optional) If Netclaw can truly only ever use one secrets path per process, documenting that assumption here would help; otherwise consider an eviction strategy (or a different lock identity approach) to prevent long-lived growth.
private static readonly ConcurrentDictionary<string, SemaphoreSlim> Gates = new(StringComparer.Ordinal);
PublishMcpServerTools took an Action and invoked it while holding _sync. All three callers passed a single Volatile.Write, so nothing deadlocked, but the signature let any future caller take another lock or block inside the registry's critical section, and no type constrains that. The snapshot now arrives as a StrongBox slot plus a value. The registry performs the write itself and executes nothing it does not own, which removes the hazard class rather than documenting it. Note the reported mechanism does not hold: lock is Monitor and reentrant per thread, so a callback re-entering ToolRegistry on the same thread would re-acquire rather than deadlock. The real exposure was a callback taking a different lock or blocking. The comment on that method also overstated its guarantee. It claimed readers see either the old pair or the new pair and never a mix, but snapshot readers take no lock at all - Snapshot is a plain Volatile.Read - so only one direction is ordered. Rewritten to say what actually holds, and why the other direction does not matter: dispatch resolves tools from the snapshot, and the registry only supplies the model's advertised tool list.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs:227
- DescribeOAuthError includes provider-controlled
error/error_descriptiontext verbatim in the exception message. That message is logged (and OTLP-exported), so newlines or very long values can lead to log forging/noisy multi-line entries and unbounded log payloads. Sanitize these fields (collapse CR/LF to spaces, trim, and cap length) before formatting them into the message.
Adds a Communication Standard section to the agent constitution. It applies to chat replies, commit messages, PR descriptions, code comments, docs, and spec text. The section states the rules that change how an agent writes: one idea per sentence, sentence length limits, active voice, simple tenses, no -ing word as a noun, keep articles, one meaning per word, and vertical lists for complex information. Two carve-outs keep the rule safe. STE does not apply to code identifiers, file paths, log text, or quoted material. STE does not override accuracy: an agent states a technical fact correctly even when the approved vocabulary cannot. CLAUDE.md is a symlink to AGENTS.md, so the edit went to AGENTS.md. This change is unrelated to the MCP OAuth work on this branch.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 53 out of 53 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/Netclaw.Daemon/Mcp/McpOAuthFlowBroker.cs:365
EndLifetime()calls_expiryTimer.Change(...)without handlingObjectDisposedException. During daemon shutdown,McpOAuthFlowBroker.Dispose()can dispose the flow while another thread completes/fails it, which can throw here and surface as an unhandled shutdown-path exception.
src/Netclaw.Daemon/Mcp/McpOAuthClientRegistrar.cs:172token_endpoint_auth_methods_supportedparsing keeps empty/whitespace entries. If the auth server returns"none"with surrounding whitespace (or an empty string entry),FirstOrDefault()can select an invalid method and the DCR request will fail.
The registry is one global instance. Program.cs builds it once and registers it as a singleton, so every session actor shares it. Writes happen at startup, at MCP reconnect, and at shutdown. Reads happen on every tool call from every concurrent session. The lock-based version fit that shape badly. Each read took a lock, and GetRegistrationsSnapshot also copied the whole list, so the hot path paid for a rare writer. The registrations now live in a volatile array. A writer holds one lock, builds a replacement array, and publishes it in a single volatile write. A reader reads the field once and works on that array. Reads take no lock and copy nothing. Read paths that lock drop from 2 to 0. GetAllRegistrations wraps the shared array in a read-only view instead of copying it, so a cast cannot reach registry state. The StrongBox slot goes away with the locks. It existed to publish the connection snapshot inside the registry's lock, which paired the two writes for readers that took that same lock. Readers no longer take it, so the slot bought nothing. The caller now orders the two publications, which gives a better guarantee than the slot did. A server that comes up publishes the connection first and the tools second, so a tool the model can see is always dispatchable. A server that goes down removes the tools first and the connection second, so the model stops seeing a tool before dispatch loses it. ToolRegistry's diff against dev drops from +75 -21 to +65 -18.
SecretsFileWriter.Write, SecretsFileWriter.Update, OAuthTokenPersistence .PersistTokens and BootstrapDeviceSeeder all took a nullable ISecretsProtector. A null meant the writer skipped EncryptJsonLeaves, so secrets reached disk as plaintext. Nothing failed and nothing warned. The constitution names this case. A nullable security dependency consumed with a null test lets a null disable the protection. Here the protection is encryption of secrets.json. No production caller needed the null. All nine SecretsFileWriter call sites already passed a real protector, and the daemon registers ISecretsProtector as a singleton, so DI always supplied one to BootstrapDeviceSeeder. The default served test call sites only, which the constitution rejects as a reason. Tests that want plaintext fixtures now pass NullSecretsProtector by name. That states the intent instead of hiding it behind a default. Two overloads reorder their parameters, because C# forbids a required parameter after an optional one. The protector now precedes options in Update and in the dictionary overload of Write. This also closes a plaintext write on a live path. ProviderOAuthTokenRefreshService called PersistTokens with no protector, so every provider OAuth token refresh wrote the new access and refresh tokens unencrypted. A later protected write re-encrypted them, because EncryptJsonLeaves is idempotent over plaintext leaves, but the tokens sat exposed until then. Three tests pinned that behavior and failed on this change: - ProviderOAuthTokenRefreshServiceTests.GetValidAccessTokenAsync_ExpiredToken_RefreshesPersistsAndUpdatesEntry - ProviderOAuthRefreshingProbeTests.ProbeConfiguredAsync_ExpiredOAuthToken_RefreshesPersistsAndDelegatesWithFreshToken - CopilotTokenExchangerTests.GetToken_NamedProvider_ExpiredOAuthToken_RefreshesBeforeTokenExchange Each read secrets.json and asserted the refreshed token equalled its plaintext value. They now assert the plaintext does not appear on disk, then decrypt and assert the value. Three nullable protector parameters stay in the CLI. Each resolves protector ?? SecretsProtection.CreateProtector(paths), so a null selects a real protector rather than disabling encryption.
Summary
Swapping Netclaw's OAuth implementation for the MCP C# SDK's invalidated two credentials the previous release had been using successfully. Both
notionandtextforgewere connected minutes before a binary swap and reportedrequires OAuth authorizationminutes after. Notion recovered by reauthorizing; textforge could not, because re-registration is impossible against it.McpOAuthService,McpTokenCacheAdapter— 752 lines) is deleted, along with themcp-oauth-metadata.jsonruntime dependency.McpOAuthClientRegistrar. This is a deliberate exception to SDK ownership — see below.DaemonConfig.Port, and staticAuthorizationheader precedence.Why registration is not delegated to the SDK
ClientOAuthProvider.PerformDynamicClientRegistrationAsynchard-codestoken_endpoint_auth_method: "client_secret_post"and never consults the authorization server's advertisedtoken_endpoint_auth_methods_supported. A server accepting public clients only — TextForge advertises["none"]— rejects that with400 invalid_client_metadata, and 1.4.1 exposes no hook to change it:DynamicClientRegistrationOptionshas four properties and itsResponseDelegatefires 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 addsClientOAuthOptions.TokenEndpointAuthMethod, which covers the token request only — the registration body is untouched, so it does not fix this.Netclaw's own registration sent
"none"and worked, as does the TypeScript SDK, which negotiates from the advertised list. The registrar registers with the same method the SDK will later select for the token request, so the two cannot diverge, and seedsClientOAuthOptions.ClientIdso the SDK's registration path never runs. A missing or failingregistration_endpointnamesOAuthClientIdas the remedy.Server-side tracking: petabridge/llm-email-gateway#784.
Upgrade-path fixes
Three defects made the migration cosmetic:
ObtainedAtis stamped on migration. Absent from pre-existing records, it deserialized to0001-01-01, andExpiresAt - ObtainedAtsaturated theintconversion toint.MaxValue, so the SDK treated every migrated credential as permanently expired. No test caught it because no fixture setExpiresAt.McpServerUrlis retained rather than nulled, so migration can repeat if an endpoint is corrected later and a rollback keeps its binding.LoadDurableStatealso no longer throws from a singleton constructor, where one unreadable secrets leaf took down daemon startup.RejectedDynamicClientIdis replaced by discarding the rejected identity and keeping the tokens. A persisted marker is a latch: it survives restart and, against a server that cannot complete registration, makes every later attempt fail identically.Deferred, with the gaps recorded
These are unchecked tasks in
openspec/changes/simplify-mcp-oauth-lifecycle/tasks.md, not silent drops:SecretsFileWriter.Updatelocks within one process, and its key resolves a symlinked config directory.SecretsCommand,PairCommand,ProviderCommand,ProviderManagerViewModel,ExposureModeStepViewModel,ProviderCredentialWriterandOAuthTokenPersistencestill do unlocked read-modify-write, as they did before this change — so anetclaw secrets setissued against a running daemon can still lose against a concurrent token refresh. The credential store and the two TUI save paths did adopt the transaction, the latter because they were overwriting daemon-writtenMcpOAuthTokens.Size
MCP production code is 2488 lines against 1370 before this branch (
McpClientManager1256 + credential store 604 + flow broker 386 + registrar 242, vs manager 618 +McpOAuthService663 +McpTokenCacheAdapter89). The increase is the diagnostics taxonomy for #1475, durable client identity, resource binding with migration, and candidate-then-publish — none of which existed before. Net-negative production LOC was predicted in the proposal and not achieved; the proposal now records the measured figure rather than the prediction.Roughly 800 lines of work unrelated to MCP OAuth were removed from the branch and deferred to their own change: the cross-process mutex, the
Netclaw.SecretsLockProbeproject, and transactional rollback forProviderRenamerandBootstrapDeviceSeeder.Verification
Netclaw.Daemon.Tests: 907 passed, 0 failed.OAuthClientIdoverride removed. Daemon log recordsMigrated legacy MCP OAuth credentials for 'textforge' after exact resource match.Notes
ModelContextProtocol.Coreremains pinned to 1.4.1.McpOAuthServerMetadatatype andNetclawPaths.McpOAuthMetadataPathproperty are removed; CLI and HTTP contracts are unchanged.Fixes #1475