diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f0b54f800..f907ed836 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 5e2d70e25..9bb80f6a6 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.74.3" + version: "2.74.4" --- # Netclaw Operations @@ -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 diff --git a/src/Netclaw.Configuration/Http/McpHttpClientFactory.cs b/src/Netclaw.Configuration/Http/McpHttpClientFactory.cs index 4887c9856..172913330 100644 --- a/src/Netclaw.Configuration/Http/McpHttpClientFactory.cs +++ b/src/Netclaw.Configuration/Http/McpHttpClientFactory.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using System.Net; +using System.Net.Http.Headers; namespace Netclaw.Configuration.Http; @@ -104,6 +105,12 @@ protected override async Task 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); diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs index 370374e2f..c14471c13 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpSdkOAuthFlowIntegrationTests.cs @@ -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() { @@ -968,14 +985,16 @@ public static async Task 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(); @@ -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"); @@ -1138,6 +1159,8 @@ public FakeOAuthMcpServerState( private string? DcrRejectionBody { get; } + private bool NegotiateTokenResponseWithAccept { get; } + public Uri McpEndpoint { get; } public Uri ProtectedResourceMetadataEndpoint { get; } @@ -1302,6 +1325,9 @@ public async Task 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( @@ -1321,7 +1347,8 @@ public async Task 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) @@ -1337,6 +1364,14 @@ public async Task 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, @@ -1515,5 +1550,6 @@ private sealed record TokenRequestObservation( string Resource, bool PkceVerified, string IssuedAccessToken, - string IssuedRefreshToken); + string IssuedRefreshToken, + bool AcceptsJson); }