Skip to content

Make MCP tool-level auth failures visible, and unblock netclaw mcp auth - #1720

Merged
Aaronontheweb merged 6 commits into
netclaw-dev:devfrom
Aaronontheweb:fix/mcp-tool-auth-error-visibility
Jul 31, 2026
Merged

Make MCP tool-level auth failures visible, and unblock netclaw mcp auth#1720
Aaronontheweb merged 6 commits into
netclaw-dev:devfrom
Aaronontheweb:fix/mcp-tool-auth-error-visibility

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Collaborator

Follow-up to #1714. Four defects found by running the SDK 2.0 migration against live MCP servers, plus one compatibility fix.

An MCP server can report a failure inside a successful response, with isError: true. No exception reaches the transport layer, and that single fact caused three separate problems.

An expired credential was invisible

The daemon recorded only the length of the result. The server's own detail went to the model and never reached disk, so an operator whose agent said "Notion is auth-failing" had nothing to check. McpToolResultFormatter already extracted that detail; nothing logged it.

The detail is now logged at warning level, redacted first — an MCP error body can echo the arguments it rejected, and daemon logs leave the box when the OTLP exporter is on.

Status reported a healthy server while every call failed

A rejected credential arrives as a tool-level failure, not an HTTP 401. The transport stays healthy, so the server kept reporting Connected while every invocation failed. The one state that needs an operator action was the one state that status never showed.

A tool-level failure whose text reads as an authentication rejection now moves the server to AuthFailed, names netclaw mcp auth <name>, and raises the same operational alert a transport failure raises. The message match is shared with the exception path so both recognize the same wording.

The invocation error did not name the remedy

A call against a server awaiting authorization threw MCP server '<name>' is unavailable or tool '<tool>' is not registered. That text is what the agent repeats to the operator, and it reads as a broken server. It now names netclaw mcp auth <name> when the server state is AuthFailed or AwaitingAuth, and keeps the original wording for every other cause.

netclaw mcp auth failed five seconds after printing the URL

This one is a regression from #1714 and it blocked reauthorization completely.

SDK 2.0 probes server/discover before falling back to the initialize handshake, and bounds that probe at five seconds. A server that answers the probe with 401 sends the SDK into the authorization callback handler, which cannot return until the operator finishes in a browser. The probe timeout cancels that wait, the SDK falls back to initialize, gets a second 401, and calls the handler again for the same flow. The single-owner guard rejects the second call and the whole attempt dies.

The guard is correct and stays. Each call to InitiateAuthorizationCodeFlowAsync mints a fresh state, so a second call cannot reuse the URL the operator already has on screen. The fix is to stop the probe expiring while a person works: both connect timeouts now match the flow lifetime when a flow exists. A background reconnect keeps the SDK defaults, because its handler returns immediately and nothing waits.

Release 1.4.1 had no discovery probe, so one 401 produced one handler call.

Flow deadline

The CLI and the TUI each waited a hardcoded five minutes while the daemon enforced its own deadline. They agreed by coincidence. McpOAuthStartResponse now carries the flow's ExpiresAt and both clients wait until that moment.

Sharing the FlowLifetime constant was the other option and it is worse: the constant is internal to the daemon, and the CLI is an HTTP client of the daemon rather than a reference to it. A per-flow deadline also survives a lifetime that later varies by server.

The clients tolerate a daemon that does not send the field. The CLI and the daemon swap separately during an upgrade, so a newer CLI against an older daemon is a normal window rather than a broken install.

Validation

  • 6084 tests pass, 0 fail.
  • Every new test was verified in both directions: each fails when its fix is reverted.
  • dotnet slopwatch analyze, the copyright header check, and git diff --check are clean.
  • Live on Linux against real Notion and TextForge servers. netclaw mcp auth notion now completes, and the resulting record carries AuthorizationServer and TokenEndpointAuthMethod, so it refreshes without another authorization.

An MCP server can report a failure inside a successful response, with
`isError: true`. No exception reaches the transport layer, so two things went
wrong at once.

The daemon recorded only the length of the result. The server's own detail
went to the model and never reached disk, so an operator who saw the agent
report a problem had nothing to debug from. The daemon now logs the detail at
warning level. It redacts the text first, because an MCP error body can echo
the arguments it rejected and daemon logs leave the box when the OTLP exporter
is on.

A rejected credential arrives this way, not as an HTTP 401. The transport stays
healthy, so the server continued to report `Connected` while every call failed,
and the one state that needs an operator action was the one state that status
never showed. A tool-level failure whose text reads as an authentication
rejection now moves the server to `AuthFailed` and names
`netclaw mcp auth <name>`. It also raises the same operational alert that a
transport authentication failure raises.

The message match is shared with the exception path through
`IsAuthFailureMessage`, so both paths recognize the same wording. A tool-level
failure carries no exception and no HTTP status, so the wording is all there is.
`invalid_token` and `token expired` join the list.

Two tests cover the two halves: an authentication failure moves the server out
of `Connected`, and a failure that is not about authentication reaches the log
while leaving the connection state alone. Both fail when the fix is reverted.

`RecordingLogger<T>` moves to the shared test doubles and now captures rendered
messages as well as exceptions.

Validation: 6081 tests pass and 0 fail. `dotnet slopwatch analyze`, the
copyright header check, and `git diff --check` are clean.
An invocation against a server that waits on authorization threw "MCP server
'<name>' is unavailable or tool '<tool>' is not registered". That message is
what the agent repeats to the operator. It reads as a broken server and sends
the operator to look for the wrong problem, while `netclaw mcp list` names the
correct remedy for the same condition.

The message now names `netclaw mcp auth <name>` when the server state is
`AuthFailed` or `AwaitingAuth`, and keeps the original text for every other
cause.

Validation: 6082 tests pass and 0 fail. `dotnet slopwatch analyze`, the
copyright header check, and `git diff --check` are clean.
`netclaw mcp auth <name>` failed about five seconds after it printed the
authorization URL, before an operator could reach the browser.

SDK 2.0 probes `server/discover` before it falls back to the `initialize`
handshake, and it bounds that probe at five seconds by default. A server that
answers the probe with 401 sends the SDK into the authorization callback
handler, which cannot return until the operator finishes. The probe timeout
cancels that wait, the SDK falls back to `initialize`, gets a second 401, and
calls the handler again for the same flow. The single-owner guard rejects the
second call, and the whole connection attempt fails.

The guard is correct: each call to `InitiateAuthorizationCodeFlowAsync`
generates a new `state` value, so a second call cannot reuse the URL the
operator already has. The fix is to stop the probe from expiring while a person
works. Both connect timeouts now match the flow lifetime when a flow exists.

A background reconnect keeps the SDK defaults. Its handler returns immediately,
nothing waits, and a longer timeout would only delay an unreachable server.

Release 1.4.1 had no discovery probe, so one 401 produced one handler call.
This defect arrived with the 2.0 upgrade.

Validation: 6084 tests pass and 0 fail. `dotnet slopwatch analyze`, the
copyright header check, and `git diff --check` are clean.
The CLI and the TUI each waited a hardcoded five minutes for an MCP OAuth flow
to complete. The daemon enforces its own deadline. The two agreed by
coincidence, and a change to the flow lifetime would have separated them
silently: a client that stops first reports a timeout for a flow the daemon is
still ready to complete.

`McpOAuthStartResponse` now carries the flow's `ExpiresAt`, and both clients
wait until that moment.

Sharing the constant was the other option and it is worse. `FlowLifetime` is
internal to the daemon, and the CLI is an HTTP client of the daemon rather than
a reference to it. Either a project reference or a hoisted constant would make
the dependency graph worse to keep one number in agreement. A per-flow deadline
in the response also survives a lifetime that later varies by server.

Validation: 6084 tests pass and 0 fail. `dotnet slopwatch analyze`, the
copyright header check, and `git diff --check` are clean.
The CLI and the TUI read `expiresAt` from the start response with a required
property access, which throws when the daemon does not send it. The CLI and the
daemon swap separately during an upgrade, so a newer CLI against an older
daemon is a normal window, and `netclaw mcp auth` would have failed for the
length of it.

Both clients now fall back to the five minute lifetime that an older daemon
enforces.

Validation: 6084 tests pass and 0 fail. `dotnet slopwatch analyze`, the
copyright header check, and `git diff --check` are clean.
Copilot AI review requested due to automatic review settings July 31, 2026 16:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Aaronontheweb Aaronontheweb added mcp Model context protocol server / client issues. bug Something isn't working labels Jul 31, 2026

@Aaronontheweb Aaronontheweb left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

// daemon is a normal window rather than a broken install, and crashing here
// would take out `netclaw mcp auth` for the length of it. Fall back to the
// lifetime that daemon enforces.
var deadline = startResult.TryGetProperty("expiresAt", out var expiresAt)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

: null;
var result = await _clientRuntime.InvokeAsync(function, aiArgs, ct);

if (McpToolResultFormatter.TryGetErrorDetail(result, out var detail))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

_logger.LogWarning(
"MCP tool '{Tool}' reported a failure: {Detail}",
qualifiedToolName,
SecretOutputRedactor.Redact(detail));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

/// Recognizes an authentication rejection from message text alone. A tool-level
/// failure carries no exception and no HTTP status, so the wording is all there is.
/// </summary>
private static bool IsAuthFailureMessage(string message)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kind of jank but as the comment says, the tool doesn't see the HTTP status codes

@Aaronontheweb
Aaronontheweb enabled auto-merge (squash) July 31, 2026 16:09
Copilot AI review requested due to automatic review settings July 31, 2026 16:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Aaronontheweb
Aaronontheweb merged commit 110890c into netclaw-dev:dev Jul 31, 2026
15 checks passed
@Aaronontheweb Aaronontheweb mentioned this pull request Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working mcp Model context protocol server / client issues.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants