Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### MCP reliability

- **OAuth token exchanges request JSON responses.** This keeps GitHub-compatible token endpoints aligned with the MCP SDK decoder ([#2141](https://github.com/netclaw-dev/netclaw/issues/2141)).

## 0.27.0-beta.3 (2026-09-12)

Follow-up beta to 0.27.0-beta.2. Sessions now live in one versioned storage envelope with a managed temporary directory per process, system skills ship inside the daemon binary, and shell authorization completes in one place. Slack Socket Mode no longer drops silently, and `netclaw skill sync` runs an external source pass on demand.
Expand Down
6 changes: 5 additions & 1 deletion feeds/skills/.system/files/netclaw-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.74.3"
version: "2.74.4"
---

# Netclaw Operations
Expand Down Expand Up @@ -235,6 +235,10 @@ 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 requests JSON token responses from providers that negotiate the response
format, including GitHub. This request keeps the response compatible with the
MCP SDK token decoder.

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
Expand Down
7 changes: 7 additions & 0 deletions src/Netclaw.Configuration/Http/McpHttpClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// </copyright>
// -----------------------------------------------------------------------
using System.Net;
using System.Net.Http.Headers;

namespace Netclaw.Configuration.Http;

Expand Down Expand Up @@ -104,6 +105,12 @@ protected override async Task<HttpResponseMessage> SendAsync(
return await base.SendAsync(request, cancellationToken);
}

// MCP SDK 2.2.0 parses successful token responses as JSON. GitHub returns
// form data unless the request asks for JSON.
if (!request.Headers.Accept.Any(
value => string.Equals(value.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)))
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

var requestBody = await request.Content.ReadAsStringAsync(cancellationToken);
var response = await base.SendAsync(request, cancellationToken);

Expand Down
46 changes: 41 additions & 5 deletions src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,23 @@ public async Task ManagerExplicitAuthorization_PublishesOnlyAfterSdkExchangeAndT
harness.Credentials.GetActiveForTests(harness.ServerName)?.AccessToken.Value);
}

[Fact]
public async Task ManagerExplicitAuthorization_RequestsJsonFromNegotiatedTokenEndpoint()
{
var ct = TestContext.Current.CancellationToken;
await using var server = await FakeOAuthMcpServer.StartAsync(
ct,
negotiateTokenResponseWithAccept: true);
using var directory = new DisposableTempDir();
await using var harness = CreateManagerHarness(server, directory.Path);

await CompleteManagerAuthorizationAsync(server, harness, ct);

var tokenRequest = Assert.Single(server.TokenRequests);
Assert.True(tokenRequest.AcceptsJson);
Assert.Equal(McpConnectionState.Connected, harness.Manager.GetServerStatuses()[harness.ServerName].State);
}

[Fact]
public async Task ExplicitAuthorizationGivesTheOperatorTimeToFinishInTheBrowser()
{
Expand Down Expand Up @@ -968,14 +985,16 @@ public static async Task<FakeOAuthMcpServer> StartAsync(
bool requireOAuth = true,
string? acceptedBearer = null,
bool rejectDcrWithoutBody = false,
string? dcrRejectionBody = null)
string? dcrRejectionBody = null,
bool negotiateTokenResponseWithAccept = false)
{
var origin = new Uri("https://oauth-mcp.test");
var state = new FakeOAuthMcpServerState(
origin,
requireOAuth,
rejectDcrWithoutBody,
dcrRejectionBody);
dcrRejectionBody,
negotiateTokenResponseWithAccept);
if (acceptedBearer is not null)
state.AcceptBearer(acceptedBearer);
var builder = WebApplication.CreateBuilder();
Expand Down Expand Up @@ -1117,12 +1136,14 @@ public FakeOAuthMcpServerState(
Uri origin,
bool requireOAuth,
bool rejectDcrWithoutBody,
string? dcrRejectionBody)
string? dcrRejectionBody,
bool negotiateTokenResponseWithAccept)
{
Origin = origin;
RequireOAuth = requireOAuth;
RejectDcrWithoutBody = rejectDcrWithoutBody;
DcrRejectionBody = dcrRejectionBody;
NegotiateTokenResponseWithAccept = negotiateTokenResponseWithAccept;
McpEndpoint = new Uri(origin, "/mcp");
ProtectedResourceMetadataEndpoint = new Uri(origin, "/.well-known/oauth-protected-resource/mcp");
AuthorizationEndpoint = new Uri(origin, "/oauth/authorize");
Expand All @@ -1138,6 +1159,8 @@ public FakeOAuthMcpServerState(

private string? DcrRejectionBody { get; }

private bool NegotiateTokenResponseWithAccept { get; }

public Uri McpEndpoint { get; }

public Uri ProtectedResourceMetadataEndpoint { get; }
Expand Down Expand Up @@ -1302,6 +1325,9 @@ public async Task<IResult> HandleTokenAsync(HttpContext context)
if (!string.Equals(clientSecret, client.ClientSecret, StringComparison.Ordinal))
return Results.BadRequest("Invalid client_secret.");

var acceptsJson = context.Request.Headers.Accept.ToString()
.Contains("application/json", StringComparison.OrdinalIgnoreCase);

var redirectUri = form["redirect_uri"].ToString();
var codeVerifier = form["code_verifier"].ToString();
var pkceVerified = string.Equals(
Expand All @@ -1321,7 +1347,8 @@ public async Task<IResult> HandleTokenAsync(HttpContext context)
Resource: form["resource"].ToString(),
PkceVerified: pkceVerified,
IssuedAccessToken: issuedAccessToken,
IssuedRefreshToken: issuedRefreshToken);
IssuedRefreshToken: issuedRefreshToken,
AcceptsJson: acceptsJson);
_tokenRequests.Enqueue(observation);

if (!string.Equals(clientId, authorizationCode.ClientId, StringComparison.Ordinal)
Expand All @@ -1337,6 +1364,14 @@ public async Task<IResult> HandleTokenAsync(HttpContext context)

_acceptedAccessTokens[issuedAccessToken] = 0;
_refreshTokens[issuedRefreshToken] = clientId;
if (NegotiateTokenResponseWithAccept && !acceptsJson)
{
return Results.Text(
$"access_token={issuedAccessToken}&refresh_token={issuedRefreshToken}" +
$"&token_type=Bearer&expires_in=3600&scope={Uri.EscapeDataString(authorizationCode.Scope ?? string.Empty)}",
"application/x-www-form-urlencoded");
}

return Results.Json(new
{
access_token = issuedAccessToken,
Expand Down Expand Up @@ -1515,5 +1550,6 @@ private sealed record TokenRequestObservation(
string Resource,
bool PkceVerified,
string IssuedAccessToken,
string IssuedRefreshToken);
string IssuedRefreshToken,
bool AcceptsJson);
}
Loading