From 3ea5cc1af52a3d56dc8de343133cd3cd7a30c951 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 5 Aug 2026 22:21:30 +0000 Subject: [PATCH 1/2] feat(mcp): detect OAuth at add time and guide auth before permissions (#1772) --- src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs | 165 +++++++++++++++++- .../Mcp/McpOAuthProbeTests.cs | 104 +++++++++++ src/Netclaw.Cli/Mcp/McpCommand.cs | 123 ++++++++++++- src/Netclaw.Cli/Mcp/McpOAuthProbe.cs | 152 ++++++++++++++++ 4 files changed, 531 insertions(+), 13 deletions(-) create mode 100644 src/Netclaw.Cli.Tests/Mcp/McpOAuthProbeTests.cs create mode 100644 src/Netclaw.Cli/Mcp/McpOAuthProbe.cs diff --git a/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs index e65dc2086..8722ac56b 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -33,6 +33,15 @@ public void Dispose() _dir.Dispose(); } + /// + /// An HTTP client factory whose well-known metadata probes always 404, so + /// mcp add tests never touch the network and keep the standard + /// permissions-only output. + /// + private static Func NoProbeClientFactory() + => () => new HttpClient(new FakeHttpMessageHandler( + _ => new HttpResponseMessage(HttpStatusCode.NotFound))); + [Fact] public async Task Add_StdioServer_WritesConfig() { @@ -52,7 +61,7 @@ public async Task Add_StdioServer_WritesConfig() public async Task Add_HttpServer_WritesConfig() { var args = new[] { "mcp", "add", "--transport", "http", "textforge", "https://textforge.net/mcp" }; - var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); Assert.Equal(0, exitCode); @@ -111,7 +120,7 @@ public async Task Add_WithHeader_WritesSecretsFile() public async Task Add_WritesEmptyGrantsAndApprovalDefaultsAcrossAudiences() { var args = new[] { "mcp", "add", "--transport", "http", "notion", "https://mcp.notion.com/mcp" }; - var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); Assert.Equal(0, exitCode); @@ -214,7 +223,7 @@ public async Task Add_DoesNotMutateExistingServers() File.WriteAllText(_paths.NetclawConfigPath, JsonSerializer.Serialize(initial)); var args = new[] { "mcp", "add", "--transport", "http", "new-server", "https://new.example/mcp" }; - var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); Assert.Equal(0, exitCode); @@ -272,7 +281,7 @@ public async Task Add_CreatesApprovalPolicySectionWhenMissing() File.WriteAllText(_paths.NetclawConfigPath, JsonSerializer.Serialize(initial)); var args = new[] { "mcp", "add", "--transport", "http", "notion", "https://mcp.notion.com/mcp" }; - var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); Assert.Equal(0, exitCode); @@ -291,6 +300,147 @@ public async Task Add_CreatesApprovalPolicySectionWhenMissing() Assert.Equal("All", personal.GetProperty("McpServersMode").GetString()); } + // ── Add-time OAuth detection and guidance ── + + private static Func OAuthProbeClientFactory( + bool withRegistrationEndpoint = true, + bool withProtectedResource = true) + { + var handler = new FakeHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (withProtectedResource && url.EndsWith("/.well-known/oauth-protected-resource/mcp", StringComparison.Ordinal)) + { + return FakeHttpMessageHandler.JsonResponse(new + { + resource = "https://mcp.notion.com/mcp", + authorization_servers = new[] { "https://mcp.notion.com" }, + scopes_supported = new[] { "default" }, + resource_name = "Notion MCP (Beta)" + }); + } + if (url.EndsWith("/.well-known/oauth-authorization-server", StringComparison.Ordinal)) + { + var registrationEndpoint = withRegistrationEndpoint ? "https://mcp.notion.com/register" : null; + return FakeHttpMessageHandler.JsonResponse(new + { + issuer = "https://mcp.notion.com", + registration_endpoint = registrationEndpoint, + token_endpoint_auth_methods_supported = new[] { "none" } + }); + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + return () => new HttpClient(handler); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Add_HttpOAuthServer_GuidesAuthFirst(bool withRegistrationEndpoint) + { + var args = new[] { "mcp", "add", "--transport", "http", "notion", "https://mcp.notion.com/mcp" }; + var exitCode = await McpCommand.RunAsync( + args, _paths, output: _output, httpClientFactory: OAuthProbeClientFactory(withRegistrationEndpoint)); + + Assert.Equal(0, exitCode); + + var output = _output.ToString(); + Assert.Contains("Added MCP server 'notion' (http)", output); + Assert.Contains("Detected: this server requires OAuth authorization", output); + + if (withRegistrationEndpoint) + { + Assert.Contains("Automatic client registration is supported.", output); + } + else + { + Assert.Contains("Re-add with a pre-registered client", output); + Assert.Contains("--client-id ", output); + } + + var authIdx = output.IndexOf("netclaw mcp auth notion", StringComparison.Ordinal); + var permissionsIdx = output.LastIndexOf("netclaw mcp permissions", StringComparison.Ordinal); + Assert.True(authIdx >= 0, "output should name the auth step"); + Assert.True(permissionsIdx > authIdx, "auth step should come before the permissions step"); + } + + [Fact] + public async Task Add_HttpServerWithoutOAuthMetadata_KeepsPermissionsOnlyGuidance() + { + var args = new[] { "mcp", "add", "--transport", "http", "plain", "https://plain.example/mcp" }; + var exitCode = await McpCommand.RunAsync( + args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); + + Assert.Equal(0, exitCode); + + var output = _output.ToString(); + Assert.DoesNotContain("Detected:", output); + Assert.DoesNotContain("netclaw mcp auth", output); + Assert.Contains("Next: run `netclaw mcp permissions`", output); + } + + [Fact] + public async Task Add_HttpServerWithAuthorizationHeader_SkipsProbe() + { + var args = new[] { "mcp", "add", "--transport", "http", "--header", "Authorization: Bearer test-token", "myapi", "https://api.example.com/mcp" }; + var exitCode = await McpCommand.RunAsync( + args, _paths, output: _output, httpClientFactory: () => new HttpClient( + new FakeHttpMessageHandler(_ => throw new InvalidOperationException("probe must not run for header-auth servers")))); + + Assert.Equal(0, exitCode); + Assert.DoesNotContain("Detected:", _output.ToString()); + Assert.DoesNotContain("netclaw mcp auth", _output.ToString()); + } + + [Fact] + public async Task Add_WithAuthFlag_NoDaemon_PrintsFallbackHint() + { + var args = new[] { "mcp", "add", "--auth", "--transport", "http", "notion", "https://mcp.notion.com/mcp" }; + var exitCode = await McpCommand.RunAsync( + args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); + + Assert.Equal(0, exitCode); + + var output = _output.ToString(); + Assert.Contains("Next:", output); + Assert.Contains("1. Authorize: netclaw mcp auth notion", output); + Assert.Contains("--auth: daemon API not available. Run `netclaw mcp auth notion` once the daemon is running.", output); + } + + [Fact] + public async Task Add_WithAuthFlag_DaemonRejects_PropagatesAuthErrorForAddedServer() + { + var args = new[] { "mcp", "add", "--auth", "--transport", "http", "notion", "https://mcp.notion.com/mcp" }; + var daemonApi = CreateDaemonApi(request => request.RequestUri!.AbsolutePath switch + { + "/api/mcp/oauth/start/notion" => new HttpResponseMessage(HttpStatusCode.Forbidden), + _ => new HttpResponseMessage(HttpStatusCode.NotFound), + }); + + var exitCode = await McpCommand.RunAsync( + args, _paths, daemonApi, output: _output, httpClientFactory: NoProbeClientFactory()); + + // The auth flow must target the added server ('notion'), not the '--auth' + // flag position — a wrong name would print "MCP server '--auth' not found." + Assert.Equal(1, exitCode); + Assert.Contains("HTTP 403 Forbidden", _output.ToString()); + Assert.Contains("notion", _output.ToString()); + } + + [Fact] + public async Task Add_WithAuthFlag_OnStdio_Ignored() + { + var args = new[] { "mcp", "add", "--auth", "--transport", "stdio", "local", "--", "npx", "-y", "@local/mcp" }; + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); + + Assert.Equal(0, exitCode); + + var output = _output.ToString(); + Assert.Contains("--auth ignored: OAuth is only for HTTP/SSE servers.", output); + Assert.Contains("netclaw mcp permissions", output); + } + [Fact] public async Task List_NoServers_ShowsEmptyMessage() { @@ -352,7 +502,7 @@ public async Task List_WhenDaemonDoesNotTrackServer_ShowsRestartHint() { await McpCommand.RunAsync( ["mcp", "add", "--transport", "http", "textforge", "https://textforge.net/mcp"], - _paths, output: _output); + _paths, output: _output, httpClientFactory: NoProbeClientFactory()); var daemonApi = CreateDaemonApi(request => request.RequestUri!.AbsolutePath switch { @@ -549,7 +699,8 @@ public async Task Auth_EmptyErrorBodyFallsBackToHttpStatusAndReason() await McpCommand.RunAsync( ["mcp", "add", "--transport", "http", "oauth", "https://mcp.example/mcp"], _paths, - output: _output); + output: _output, + httpClientFactory: NoProbeClientFactory()); var daemonApi = CreateDaemonApi(request => request.RequestUri!.AbsolutePath switch { "/api/mcp/oauth/start/oauth" => new HttpResponseMessage(HttpStatusCode.Forbidden), diff --git a/src/Netclaw.Cli.Tests/Mcp/McpOAuthProbeTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpOAuthProbeTests.cs new file mode 100644 index 000000000..08e58d737 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Mcp/McpOAuthProbeTests.cs @@ -0,0 +1,104 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using Netclaw.Cli.Mcp; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Mcp; + +public sealed class McpOAuthProbeTests +{ + public static TheoryData DetectionCases => new() + { + { true, true, true, true }, // protected-resource + registration endpoint + { true, false, true, false }, // protected-resource, no registration + { false, false, false, false }, // no metadata at all + }; + + [Theory] + [MemberData(nameof(DetectionCases))] + public async Task Detect_VariesByMetadata( + bool withProtectedResource, + bool withRegistration, + bool expectedOAuth, + bool expectedDynamic) + { + var handler = new FakeHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (withProtectedResource && url.EndsWith("/.well-known/oauth-protected-resource/mcp", StringComparison.Ordinal)) + { + return FakeHttpMessageHandler.JsonResponse(new + { + resource = "https://mcp.example/mcp", + authorization_servers = new[] { "https://auth.example" } + }); + } + if (withRegistration && url.EndsWith("/.well-known/oauth-authorization-server", StringComparison.Ordinal)) + { + return FakeHttpMessageHandler.JsonResponse(new + { + issuer = "https://auth.example", + registration_endpoint = "https://auth.example/register" + }); + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + using var client = new HttpClient(handler); + + var result = await McpOAuthProbe.DetectAsync("https://mcp.example/mcp", client, TestContext.Current.CancellationToken); + + if (expectedOAuth) + { + Assert.NotNull(result); + Assert.True(result.OAuthRequired); + Assert.Equal(expectedDynamic, result.DynamicRegistrationAvailable); + } + else + { + Assert.Null(result); + } + } + + [Theory] + [InlineData(true)] // path-suffixed well-known document + [InlineData(false)] // origin-level fallback + public async Task Detect_FindsMetadataAtBothWellKnownLocations(bool pathSuffixed) + { + var suffix = pathSuffixed ? "/mcp" : string.Empty; + var handler = new FakeHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.EndsWith($"/.well-known/oauth-protected-resource{suffix}", StringComparison.Ordinal)) + { + return FakeHttpMessageHandler.JsonResponse(new + { + resource = "https://mcp.example", + authorization_servers = new[] { "https://auth.example" } + }); + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + using var client = new HttpClient(handler); + + var result = await McpOAuthProbe.DetectAsync("https://mcp.example/mcp", client, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.True(result.OAuthRequired); + } + + [Fact] + public async Task Detect_UnreachableEndpoint_ReturnsNull() + { + var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("no such host")); + using var client = new HttpClient(handler); + + var result = await McpOAuthProbe.DetectAsync("https://mcp.example/mcp", client, TestContext.Current.CancellationToken); + + Assert.Null(result); + } +} diff --git a/src/Netclaw.Cli/Mcp/McpCommand.cs b/src/Netclaw.Cli/Mcp/McpCommand.cs index 8319c0f05..eb27aa855 100644 --- a/src/Netclaw.Cli/Mcp/McpCommand.cs +++ b/src/Netclaw.Cli/Mcp/McpCommand.cs @@ -1,9 +1,10 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- using System.Diagnostics; +using System.Net.Http; using System.Net.Http.Json; using System.Net.Sockets; using System.Text; @@ -39,14 +40,14 @@ internal readonly record struct McpProbeResult( /// internal static class McpCommand { - public static async Task RunAsync(string[] args, NetclawPaths paths, DaemonApi? daemonApi = null, TextWriter? output = null) + public static async Task RunAsync(string[] args, NetclawPaths paths, DaemonApi? daemonApi = null, TextWriter? output = null, Func? httpClientFactory = null) { var writer = output ?? Console.Out; var subcommand = args.Length > 1 ? args[1] : "help"; return subcommand switch { - "add" => RunAdd(args, paths, writer), + "add" => await RunAddAsync(args, paths, writer, daemonApi, httpClientFactory), "auth" => await RunAuthAsync(args, paths, daemonApi, writer), "list" => await RunListAsync(paths, daemonApi, writer), "get" => RunGet(args, paths, writer), @@ -60,15 +61,21 @@ public static async Task RunAsync(string[] args, NetclawPaths paths, Daemon }; } - internal static int RunAdd(string[] args, NetclawPaths paths, TextWriter writer) + internal static async Task RunAddAsync( + string[] args, + NetclawPaths paths, + TextWriter writer, + DaemonApi? daemonApi = null, + Func? httpClientFactory = null) { - // Parse: netclaw mcp add [--transport ] [--client-id ] [--scope ] [--env KEY=VALUE]... [--header "Key: Value"]... [--grant-all] [command/url] [-- args...] + // Parse: netclaw mcp add [--transport ] [--client-id ] [--scope ] [--env KEY=VALUE]... [--header "Key: Value"]... [--grant-all] [--auth] [command/url] [-- args...] string? transport = null; string? oauthClientId = null; string? oauthScope = null; var envVars = new Dictionary(); var headers = new Dictionary(); var grantAll = false; + var runAuth = false; string? commandOrUrl = null; string[]? commandArgs = null; @@ -96,6 +103,12 @@ internal static int RunAdd(string[] args, NetclawPaths paths, TextWriter writer) continue; } + if (args[i] == "--auth") + { + runAuth = true; + continue; + } + if (args[i] is "--transport" or "-t" && i + 1 < args.Length) { transport = args[++i]; @@ -239,10 +252,102 @@ internal static int RunAdd(string[] args, NetclawPaths paths, TextWriter writer) writer.WriteLine(" until you opt in via `netclaw mcp permissions`."); } writer.WriteLine("Approval defaults: Personal=Auto, Team=Approval, Public=Deny"); - writer.WriteLine($"Next: run `netclaw mcp permissions` to grant tools and adjust approvals for '{serverName.Value}'."); + + // OAuth-protected servers cannot connect until they are authorized, and the + // permissions TUI needs a connected server to list tools. When the endpoint + // advertises OAuth (or the operator explicitly asked with --auth), surface + // the auth step before the permissions step. + var probe = await ProbeOAuthRequirementAsync(transport, headers, commandOrUrl, httpClientFactory); + var showAuthFirst = transport is not "stdio" && (probe?.OAuthRequired == true || runAuth); + if (showAuthFirst) + { + writer.WriteLine(); + if (probe?.OAuthRequired == true) + { + if (probe.DynamicRegistrationAvailable) + { + writer.WriteLine("Detected: this server requires OAuth authorization."); + writer.WriteLine(" Automatic client registration is supported."); + } + else + { + writer.WriteLine("Detected: this server requires OAuth authorization, but does not support"); + writer.WriteLine(" automatic client registration. Re-add with a pre-registered client:"); + writer.WriteLine($" netclaw mcp add --transport {transport} --client-id " + + $"[--scope ] {serverName.Value} {commandOrUrl}"); + } + } + writer.WriteLine(); + writer.WriteLine("Next:"); + writer.WriteLine($" 1. Authorize: netclaw mcp auth {serverName.Value}"); + writer.WriteLine(" 2. Grant tools: netclaw mcp permissions"); + } + else + { + writer.WriteLine($"Next: run `netclaw mcp permissions` to grant tools and adjust approvals for '{serverName.Value}'."); + } + + if (runAuth && transport is not "stdio") + { + if (daemonApi is null) + { + writer.WriteLine(); + writer.WriteLine("--auth: daemon API not available. Run `netclaw mcp auth " + + $"{serverName.Value}` once the daemon is running."); + } + else + { + writer.WriteLine(); + return await RunAuthAsync(["mcp", "auth", serverName.Value], paths, daemonApi, writer); + } + } + else if (runAuth && transport is "stdio") + { + writer.WriteLine(); + writer.WriteLine("--auth ignored: OAuth is only for HTTP/SSE servers."); + } + return 0; } + /// + /// Best-effort OAuth capability probe for a newly added server. Skips stdio + /// transports and servers that carry an explicit Authorization header (those + /// use static credentials, not OAuth). Returns null when the endpoint + /// does not advertise OAuth or the probe fails, so callers can print the + /// standard permissions-only guidance. + /// + private static async Task ProbeOAuthRequirementAsync( + string transport, + Dictionary headers, + string? commandOrUrl, + Func? httpClientFactory) + { + if (transport is "stdio" || string.IsNullOrWhiteSpace(commandOrUrl)) + return null; + + if (headers.Keys.Any(key => string.Equals(key, "Authorization", StringComparison.OrdinalIgnoreCase))) + return null; + + var client = httpClientFactory?.Invoke() + ?? new HttpClient { Timeout = TimeSpan.FromSeconds(3) }; + + try + { + return await McpOAuthProbe.DetectAsync(commandOrUrl, client, CancellationToken.None); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException or UriFormatException) + { + // Never let a probe failure fail the add. + return null; + } + finally + { + if (httpClientFactory is null) + client.Dispose(); + } + } + /// /// Writes secure defaults for a newly added MCP server across all audience /// profiles. Personal gets Auto approval and no per-tool grants @@ -1350,6 +1455,12 @@ private static int WriteHelp(TextWriter writer) writer.WriteLine(" --grant-all CI escape hatch. Skip the empty-grants writes and leave tool"); writer.WriteLine(" grants null (legacy \"all pass\" behavior). Approval defaults"); writer.WriteLine(" (Personal=Approval, Team=Approval, Public=Deny) are still written."); + writer.WriteLine(" --auth Start the OAuth flow immediately after adding (HTTP/SSE only)."); + writer.WriteLine(" --client-id Pre-registered OAuth client ID for servers that do not support"); + writer.WriteLine(" dynamic client registration."); + writer.WriteLine(); + writer.WriteLine("On add, HTTP/SSE servers are probed for RFC 9728 OAuth metadata. When the"); + writer.WriteLine("server requires OAuth, the output leads with the authorization step."); writer.WriteLine(); writer.WriteLine("Examples:"); writer.WriteLine(" netclaw mcp add --transport stdio memorizer -- npx -y @memorizer/mcp-server"); diff --git a/src/Netclaw.Cli/Mcp/McpOAuthProbe.cs b/src/Netclaw.Cli/Mcp/McpOAuthProbe.cs new file mode 100644 index 000000000..82365c340 --- /dev/null +++ b/src/Netclaw.Cli/Mcp/McpOAuthProbe.cs @@ -0,0 +1,152 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; + +namespace Netclaw.Cli.Mcp; + +/// +/// Result of a best-effort OAuth capability probe against an MCP server endpoint. +/// is true when the server advertises RFC 9728 +/// protected-resource metadata naming at least one authorization server. +/// is true when that authorization +/// server publishes a registration endpoint (RFC 7591), which lets +/// netclaw mcp auth register a client automatically instead of requiring +/// a pre-registered --client-id. +/// +internal sealed record McpOAuthProbeResult(bool OAuthRequired, bool DynamicRegistrationAvailable); + +/// +/// Discovers whether an HTTP/SSE MCP endpoint requires OAuth authorization, and +/// whether its authorization server supports dynamic client registration. +/// Best-effort: any probe failure yields null so callers can degrade to +/// their pre-probe behavior instead of failing the enclosing command. +/// +internal static class McpOAuthProbe +{ + private const string ProtectedResourceSegment = "/.well-known/oauth-protected-resource"; + private const string AuthorizationServerSegment = "/.well-known/oauth-authorization-server"; + + /// + /// Probes for OAuth requirements. Returns + /// null when the endpoint publishes no usable protected-resource + /// metadata (or when any request fails), meaning OAuth could not be + /// positively detected. + /// + public static async Task DetectAsync( + string endpointUrl, + HttpClient client, + CancellationToken ct) + { + var issuer = await DiscoverIssuerAsync(endpointUrl, client, ct); + if (issuer is null) + return null; + + var dynamicRegistration = await DiscoverDynamicRegistrationAsync(issuer, client, ct); + return new McpOAuthProbeResult(OAuthRequired: true, DynamicRegistrationAvailable: dynamicRegistration); + } + + /// + /// RFC 9728: the protected-resource metadata document lives at + /// /.well-known/oauth-protected-resource on the resource's origin, + /// with the resource path appended as a suffix. Try the path-suffixed form + /// first, then fall back to the origin-level document. + /// + private static async Task DiscoverIssuerAsync( + string endpointUrl, + HttpClient client, + CancellationToken ct) + { + var resource = new Uri(endpointUrl); + var origin = resource.GetLeftPart(UriPartial.Authority); + var path = resource.AbsolutePath.TrimEnd('/'); + + var candidates = string.IsNullOrEmpty(path) || path == "/" + ? new[] { $"{origin}{ProtectedResourceSegment}" } + : [$"{origin}{ProtectedResourceSegment}{path}", $"{origin}{ProtectedResourceSegment}"]; + + foreach (var candidate in candidates) + { + using var document = await TryGetJsonAsync(client, candidate, ct); + if (document is null) + continue; + + if (document.RootElement.TryGetProperty("authorization_servers", out var servers) + && servers.ValueKind == JsonValueKind.Array + && servers.GetArrayLength() > 0) + { + foreach (var server in servers.EnumerateArray()) + { + if (server.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(server.GetString())) + return server.GetString(); + } + } + } + + return null; + } + + /// + /// RFC 8414: the authorization-server metadata document lives at + /// /.well-known/oauth-authorization-server on the issuer's origin. + /// A non-empty registration_endpoint means dynamic client + /// registration (RFC 7591) is available. + /// + private static async Task DiscoverDynamicRegistrationAsync( + string issuer, + HttpClient client, + CancellationToken ct) + { + var authServer = new Uri(issuer); + var origin = authServer.GetLeftPart(UriPartial.Authority); + var path = authServer.AbsolutePath.TrimEnd('/'); + + var candidates = string.IsNullOrEmpty(path) || path == "/" + ? new[] { $"{origin}{AuthorizationServerSegment}" } + : [$"{origin}{AuthorizationServerSegment}{path}", $"{origin}{AuthorizationServerSegment}"]; + + foreach (var candidate in candidates) + { + using var document = await TryGetJsonAsync(client, candidate, ct); + if (document is null) + continue; + + if (document.RootElement.TryGetProperty("registration_endpoint", out var registration) + && registration.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(registration.GetString())) + { + return true; + } + } + + return false; + } + + private static async Task TryGetJsonAsync( + HttpClient client, + string url, + CancellationToken ct) + { + try + { + using var response = await client.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) + return null; + + var body = await response.Content.ReadAsStringAsync(ct); + return JsonDocument.Parse(body); + } + catch (Exception ex) when (ex is HttpRequestException + or TaskCanceledException + or OperationCanceledException + or JsonException + or NotSupportedException) + { + // Best-effort probe: an unreachable or malformed endpoint means + // "could not detect OAuth", never a failure of the caller. + return null; + } + } +} From c7cd4d55d0e8493e4b4eed12bbb320381a5ce439 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 6 Aug 2026 04:03:15 +0000 Subject: [PATCH 2/2] refactor(mcp): drop CLI OAuth probe; print unconditional auth hint at add The daemon owns RFC 9728/8414 OAuth discovery through McpOAuthClientRegistrar. The CLI must not run a second, client-side discovery. This commit removes McpOAuthProbe and the add-time probe call in McpCommand. netclaw mcp add no longer probes the endpoint. It now prints an unconditional hint for HTTP/SSE servers added without an Authorization header: run netclaw mcp auth first if the server needs OAuth. stdio servers and servers with an explicit Authorization header keep the permissions-only guidance. The --auth flag keeps its behavior. It still starts the OAuth flow through the daemon after add. Deletes: - src/Netclaw.Cli/Mcp/McpOAuthProbe.cs - src/Netclaw.Cli.Tests/Mcp/McpOAuthProbeTests.cs Refs #1772, #1773. --- src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs | 127 ++++----------- .../Mcp/McpOAuthProbeTests.cs | 104 ------------ src/Netclaw.Cli/Mcp/McpCommand.cs | 89 +++------- src/Netclaw.Cli/Mcp/McpOAuthProbe.cs | 152 ------------------ 4 files changed, 50 insertions(+), 422 deletions(-) delete mode 100644 src/Netclaw.Cli.Tests/Mcp/McpOAuthProbeTests.cs delete mode 100644 src/Netclaw.Cli/Mcp/McpOAuthProbe.cs diff --git a/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs index 8722ac56b..5869db920 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs @@ -33,15 +33,6 @@ public void Dispose() _dir.Dispose(); } - /// - /// An HTTP client factory whose well-known metadata probes always 404, so - /// mcp add tests never touch the network and keep the standard - /// permissions-only output. - /// - private static Func NoProbeClientFactory() - => () => new HttpClient(new FakeHttpMessageHandler( - _ => new HttpResponseMessage(HttpStatusCode.NotFound))); - [Fact] public async Task Add_StdioServer_WritesConfig() { @@ -61,7 +52,7 @@ public async Task Add_StdioServer_WritesConfig() public async Task Add_HttpServer_WritesConfig() { var args = new[] { "mcp", "add", "--transport", "http", "textforge", "https://textforge.net/mcp" }; - var exitCode = await McpCommand.RunAsync(args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); Assert.Equal(0, exitCode); @@ -120,7 +111,7 @@ public async Task Add_WithHeader_WritesSecretsFile() public async Task Add_WritesEmptyGrantsAndApprovalDefaultsAcrossAudiences() { var args = new[] { "mcp", "add", "--transport", "http", "notion", "https://mcp.notion.com/mcp" }; - var exitCode = await McpCommand.RunAsync(args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); Assert.Equal(0, exitCode); @@ -223,7 +214,7 @@ public async Task Add_DoesNotMutateExistingServers() File.WriteAllText(_paths.NetclawConfigPath, JsonSerializer.Serialize(initial)); var args = new[] { "mcp", "add", "--transport", "http", "new-server", "https://new.example/mcp" }; - var exitCode = await McpCommand.RunAsync(args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); Assert.Equal(0, exitCode); @@ -281,7 +272,7 @@ public async Task Add_CreatesApprovalPolicySectionWhenMissing() File.WriteAllText(_paths.NetclawConfigPath, JsonSerializer.Serialize(initial)); var args = new[] { "mcp", "add", "--transport", "http", "notion", "https://mcp.notion.com/mcp" }; - var exitCode = await McpCommand.RunAsync(args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); Assert.Equal(0, exitCode); @@ -300,111 +291,56 @@ public async Task Add_CreatesApprovalPolicySectionWhenMissing() Assert.Equal("All", personal.GetProperty("McpServersMode").GetString()); } - // ── Add-time OAuth detection and guidance ── - - private static Func OAuthProbeClientFactory( - bool withRegistrationEndpoint = true, - bool withProtectedResource = true) - { - var handler = new FakeHttpMessageHandler(request => - { - var url = request.RequestUri!.ToString(); - if (withProtectedResource && url.EndsWith("/.well-known/oauth-protected-resource/mcp", StringComparison.Ordinal)) - { - return FakeHttpMessageHandler.JsonResponse(new - { - resource = "https://mcp.notion.com/mcp", - authorization_servers = new[] { "https://mcp.notion.com" }, - scopes_supported = new[] { "default" }, - resource_name = "Notion MCP (Beta)" - }); - } - if (url.EndsWith("/.well-known/oauth-authorization-server", StringComparison.Ordinal)) - { - var registrationEndpoint = withRegistrationEndpoint ? "https://mcp.notion.com/register" : null; - return FakeHttpMessageHandler.JsonResponse(new - { - issuer = "https://mcp.notion.com", - registration_endpoint = registrationEndpoint, - token_endpoint_auth_methods_supported = new[] { "none" } - }); - } - return new HttpResponseMessage(HttpStatusCode.NotFound); - }); - return () => new HttpClient(handler); - } + // ── Add-time unconditional OAuth hint ── + // + // The daemon owns OAuth discovery (RFC 9728/8414, via McpOAuthClientRegistrar). + // The CLI does not probe the endpoint; it prints an unconditional hint for any + // HTTP/SSE server added without an explicit Authorization header. [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task Add_HttpOAuthServer_GuidesAuthFirst(bool withRegistrationEndpoint) + [InlineData("stdio")] + [InlineData("http-with-header")] + public async Task Add_DoesNotPrintOAuthHint_ForStdioOrExplicitAuthorizationHeader(string scenario) { - var args = new[] { "mcp", "add", "--transport", "http", "notion", "https://mcp.notion.com/mcp" }; - var exitCode = await McpCommand.RunAsync( - args, _paths, output: _output, httpClientFactory: OAuthProbeClientFactory(withRegistrationEndpoint)); - - Assert.Equal(0, exitCode); - - var output = _output.ToString(); - Assert.Contains("Added MCP server 'notion' (http)", output); - Assert.Contains("Detected: this server requires OAuth authorization", output); - - if (withRegistrationEndpoint) - { - Assert.Contains("Automatic client registration is supported.", output); - } - else - { - Assert.Contains("Re-add with a pre-registered client", output); - Assert.Contains("--client-id ", output); - } + var args = scenario is "stdio" + ? new[] { "mcp", "add", "--transport", "stdio", "local", "--", "npx", "-y", "@local/mcp" } + : new[] { "mcp", "add", "--transport", "http", "--header", "Authorization: Bearer test-token", "myapi", "https://api.example.com/mcp" }; - var authIdx = output.IndexOf("netclaw mcp auth notion", StringComparison.Ordinal); - var permissionsIdx = output.LastIndexOf("netclaw mcp permissions", StringComparison.Ordinal); - Assert.True(authIdx >= 0, "output should name the auth step"); - Assert.True(permissionsIdx > authIdx, "auth step should come before the permissions step"); - } - - [Fact] - public async Task Add_HttpServerWithoutOAuthMetadata_KeepsPermissionsOnlyGuidance() - { - var args = new[] { "mcp", "add", "--transport", "http", "plain", "https://plain.example/mcp" }; - var exitCode = await McpCommand.RunAsync( - args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); Assert.Equal(0, exitCode); var output = _output.ToString(); - Assert.DoesNotContain("Detected:", output); + Assert.DoesNotContain("Next steps:", output); Assert.DoesNotContain("netclaw mcp auth", output); Assert.Contains("Next: run `netclaw mcp permissions`", output); } [Fact] - public async Task Add_HttpServerWithAuthorizationHeader_SkipsProbe() + public async Task Add_HttpServerWithoutAuthorizationHeader_PrintsUnconditionalAuthHint() { - var args = new[] { "mcp", "add", "--transport", "http", "--header", "Authorization: Bearer test-token", "myapi", "https://api.example.com/mcp" }; - var exitCode = await McpCommand.RunAsync( - args, _paths, output: _output, httpClientFactory: () => new HttpClient( - new FakeHttpMessageHandler(_ => throw new InvalidOperationException("probe must not run for header-auth servers")))); + var args = new[] { "mcp", "add", "--transport", "http", "plain", "https://plain.example/mcp" }; + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); Assert.Equal(0, exitCode); - Assert.DoesNotContain("Detected:", _output.ToString()); - Assert.DoesNotContain("netclaw mcp auth", _output.ToString()); + + var output = _output.ToString(); + Assert.Contains("Next steps:", output); + Assert.Contains("If this server requires OAuth, authorize first: netclaw mcp auth plain", output); + Assert.Contains("Then grant tools: netclaw mcp permissions", output); } [Fact] public async Task Add_WithAuthFlag_NoDaemon_PrintsFallbackHint() { var args = new[] { "mcp", "add", "--auth", "--transport", "http", "notion", "https://mcp.notion.com/mcp" }; - var exitCode = await McpCommand.RunAsync( - args, _paths, output: _output, httpClientFactory: NoProbeClientFactory()); + var exitCode = await McpCommand.RunAsync(args, _paths, output: _output); Assert.Equal(0, exitCode); var output = _output.ToString(); - Assert.Contains("Next:", output); - Assert.Contains("1. Authorize: netclaw mcp auth notion", output); + Assert.Contains("Next steps:", output); + Assert.Contains("authorize first: netclaw mcp auth notion", output); Assert.Contains("--auth: daemon API not available. Run `netclaw mcp auth notion` once the daemon is running.", output); } @@ -419,7 +355,7 @@ public async Task Add_WithAuthFlag_DaemonRejects_PropagatesAuthErrorForAddedServ }); var exitCode = await McpCommand.RunAsync( - args, _paths, daemonApi, output: _output, httpClientFactory: NoProbeClientFactory()); + args, _paths, daemonApi, output: _output); // The auth flow must target the added server ('notion'), not the '--auth' // flag position — a wrong name would print "MCP server '--auth' not found." @@ -502,7 +438,7 @@ public async Task List_WhenDaemonDoesNotTrackServer_ShowsRestartHint() { await McpCommand.RunAsync( ["mcp", "add", "--transport", "http", "textforge", "https://textforge.net/mcp"], - _paths, output: _output, httpClientFactory: NoProbeClientFactory()); + _paths, output: _output); var daemonApi = CreateDaemonApi(request => request.RequestUri!.AbsolutePath switch { @@ -699,8 +635,7 @@ public async Task Auth_EmptyErrorBodyFallsBackToHttpStatusAndReason() await McpCommand.RunAsync( ["mcp", "add", "--transport", "http", "oauth", "https://mcp.example/mcp"], _paths, - output: _output, - httpClientFactory: NoProbeClientFactory()); + output: _output); var daemonApi = CreateDaemonApi(request => request.RequestUri!.AbsolutePath switch { "/api/mcp/oauth/start/oauth" => new HttpResponseMessage(HttpStatusCode.Forbidden), diff --git a/src/Netclaw.Cli.Tests/Mcp/McpOAuthProbeTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpOAuthProbeTests.cs deleted file mode 100644 index 08e58d737..000000000 --- a/src/Netclaw.Cli.Tests/Mcp/McpOAuthProbeTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Net; -using Netclaw.Cli.Mcp; -using Netclaw.Tests.Utilities; -using Xunit; - -namespace Netclaw.Cli.Tests.Mcp; - -public sealed class McpOAuthProbeTests -{ - public static TheoryData DetectionCases => new() - { - { true, true, true, true }, // protected-resource + registration endpoint - { true, false, true, false }, // protected-resource, no registration - { false, false, false, false }, // no metadata at all - }; - - [Theory] - [MemberData(nameof(DetectionCases))] - public async Task Detect_VariesByMetadata( - bool withProtectedResource, - bool withRegistration, - bool expectedOAuth, - bool expectedDynamic) - { - var handler = new FakeHttpMessageHandler(request => - { - var url = request.RequestUri!.ToString(); - if (withProtectedResource && url.EndsWith("/.well-known/oauth-protected-resource/mcp", StringComparison.Ordinal)) - { - return FakeHttpMessageHandler.JsonResponse(new - { - resource = "https://mcp.example/mcp", - authorization_servers = new[] { "https://auth.example" } - }); - } - if (withRegistration && url.EndsWith("/.well-known/oauth-authorization-server", StringComparison.Ordinal)) - { - return FakeHttpMessageHandler.JsonResponse(new - { - issuer = "https://auth.example", - registration_endpoint = "https://auth.example/register" - }); - } - return new HttpResponseMessage(HttpStatusCode.NotFound); - }); - using var client = new HttpClient(handler); - - var result = await McpOAuthProbe.DetectAsync("https://mcp.example/mcp", client, TestContext.Current.CancellationToken); - - if (expectedOAuth) - { - Assert.NotNull(result); - Assert.True(result.OAuthRequired); - Assert.Equal(expectedDynamic, result.DynamicRegistrationAvailable); - } - else - { - Assert.Null(result); - } - } - - [Theory] - [InlineData(true)] // path-suffixed well-known document - [InlineData(false)] // origin-level fallback - public async Task Detect_FindsMetadataAtBothWellKnownLocations(bool pathSuffixed) - { - var suffix = pathSuffixed ? "/mcp" : string.Empty; - var handler = new FakeHttpMessageHandler(request => - { - var url = request.RequestUri!.ToString(); - if (url.EndsWith($"/.well-known/oauth-protected-resource{suffix}", StringComparison.Ordinal)) - { - return FakeHttpMessageHandler.JsonResponse(new - { - resource = "https://mcp.example", - authorization_servers = new[] { "https://auth.example" } - }); - } - return new HttpResponseMessage(HttpStatusCode.NotFound); - }); - using var client = new HttpClient(handler); - - var result = await McpOAuthProbe.DetectAsync("https://mcp.example/mcp", client, TestContext.Current.CancellationToken); - - Assert.NotNull(result); - Assert.True(result.OAuthRequired); - } - - [Fact] - public async Task Detect_UnreachableEndpoint_ReturnsNull() - { - var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("no such host")); - using var client = new HttpClient(handler); - - var result = await McpOAuthProbe.DetectAsync("https://mcp.example/mcp", client, TestContext.Current.CancellationToken); - - Assert.Null(result); - } -} diff --git a/src/Netclaw.Cli/Mcp/McpCommand.cs b/src/Netclaw.Cli/Mcp/McpCommand.cs index eb27aa855..8e1d43ae5 100644 --- a/src/Netclaw.Cli/Mcp/McpCommand.cs +++ b/src/Netclaw.Cli/Mcp/McpCommand.cs @@ -40,14 +40,14 @@ internal readonly record struct McpProbeResult( /// internal static class McpCommand { - public static async Task RunAsync(string[] args, NetclawPaths paths, DaemonApi? daemonApi = null, TextWriter? output = null, Func? httpClientFactory = null) + public static async Task RunAsync(string[] args, NetclawPaths paths, DaemonApi? daemonApi = null, TextWriter? output = null) { var writer = output ?? Console.Out; var subcommand = args.Length > 1 ? args[1] : "help"; return subcommand switch { - "add" => await RunAddAsync(args, paths, writer, daemonApi, httpClientFactory), + "add" => await RunAddAsync(args, paths, writer, daemonApi), "auth" => await RunAuthAsync(args, paths, daemonApi, writer), "list" => await RunListAsync(paths, daemonApi, writer), "get" => RunGet(args, paths, writer), @@ -65,8 +65,7 @@ internal static async Task RunAddAsync( string[] args, NetclawPaths paths, TextWriter writer, - DaemonApi? daemonApi = null, - Func? httpClientFactory = null) + DaemonApi? daemonApi = null) { // Parse: netclaw mcp add [--transport ] [--client-id ] [--scope ] [--env KEY=VALUE]... [--header "Key: Value"]... [--grant-all] [--auth] [command/url] [-- args...] string? transport = null; @@ -253,34 +252,22 @@ internal static async Task RunAddAsync( } writer.WriteLine("Approval defaults: Personal=Auto, Team=Approval, Public=Deny"); - // OAuth-protected servers cannot connect until they are authorized, and the - // permissions TUI needs a connected server to list tools. When the endpoint - // advertises OAuth (or the operator explicitly asked with --auth), surface - // the auth step before the permissions step. - var probe = await ProbeOAuthRequirementAsync(transport, headers, commandOrUrl, httpClientFactory); - var showAuthFirst = transport is not "stdio" && (probe?.OAuthRequired == true || runAuth); - if (showAuthFirst) + // The daemon owns OAuth discovery (RFC 9728/8414, via McpOAuthClientRegistrar). + // The CLI does not probe the endpoint, so it cannot know in advance whether a + // given HTTP/SSE server requires OAuth. Print the hint unconditionally for any + // HTTP/SSE server that has no explicit Authorization header: stdio servers run + // local commands and never use OAuth, and a server with a static Authorization + // header is already using its own credentials. + var hasAuthorizationHeader = headers.Keys.Any( + key => string.Equals(key, "Authorization", StringComparison.OrdinalIgnoreCase)); + var showOAuthHint = transport is not "stdio" && !hasAuthorizationHeader; + + if (showOAuthHint) { writer.WriteLine(); - if (probe?.OAuthRequired == true) - { - if (probe.DynamicRegistrationAvailable) - { - writer.WriteLine("Detected: this server requires OAuth authorization."); - writer.WriteLine(" Automatic client registration is supported."); - } - else - { - writer.WriteLine("Detected: this server requires OAuth authorization, but does not support"); - writer.WriteLine(" automatic client registration. Re-add with a pre-registered client:"); - writer.WriteLine($" netclaw mcp add --transport {transport} --client-id " - + $"[--scope ] {serverName.Value} {commandOrUrl}"); - } - } - writer.WriteLine(); - writer.WriteLine("Next:"); - writer.WriteLine($" 1. Authorize: netclaw mcp auth {serverName.Value}"); - writer.WriteLine(" 2. Grant tools: netclaw mcp permissions"); + writer.WriteLine("Next steps:"); + writer.WriteLine($" - If this server requires OAuth, authorize first: netclaw mcp auth {serverName.Value}"); + writer.WriteLine(" - Then grant tools: netclaw mcp permissions"); } else { @@ -310,44 +297,6 @@ internal static async Task RunAddAsync( return 0; } - /// - /// Best-effort OAuth capability probe for a newly added server. Skips stdio - /// transports and servers that carry an explicit Authorization header (those - /// use static credentials, not OAuth). Returns null when the endpoint - /// does not advertise OAuth or the probe fails, so callers can print the - /// standard permissions-only guidance. - /// - private static async Task ProbeOAuthRequirementAsync( - string transport, - Dictionary headers, - string? commandOrUrl, - Func? httpClientFactory) - { - if (transport is "stdio" || string.IsNullOrWhiteSpace(commandOrUrl)) - return null; - - if (headers.Keys.Any(key => string.Equals(key, "Authorization", StringComparison.OrdinalIgnoreCase))) - return null; - - var client = httpClientFactory?.Invoke() - ?? new HttpClient { Timeout = TimeSpan.FromSeconds(3) }; - - try - { - return await McpOAuthProbe.DetectAsync(commandOrUrl, client, CancellationToken.None); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException or UriFormatException) - { - // Never let a probe failure fail the add. - return null; - } - finally - { - if (httpClientFactory is null) - client.Dispose(); - } - } - /// /// Writes secure defaults for a newly added MCP server across all audience /// profiles. Personal gets Auto approval and no per-tool grants @@ -1459,8 +1408,8 @@ private static int WriteHelp(TextWriter writer) writer.WriteLine(" --client-id Pre-registered OAuth client ID for servers that do not support"); writer.WriteLine(" dynamic client registration."); writer.WriteLine(); - writer.WriteLine("On add, HTTP/SSE servers are probed for RFC 9728 OAuth metadata. When the"); - writer.WriteLine("server requires OAuth, the output leads with the authorization step."); + writer.WriteLine("On add, HTTP/SSE servers without an Authorization header print a hint to run"); + writer.WriteLine("`netclaw mcp auth` first. The daemon detects OAuth requirements at auth time."); writer.WriteLine(); writer.WriteLine("Examples:"); writer.WriteLine(" netclaw mcp add --transport stdio memorizer -- npx -y @memorizer/mcp-server"); diff --git a/src/Netclaw.Cli/Mcp/McpOAuthProbe.cs b/src/Netclaw.Cli/Mcp/McpOAuthProbe.cs deleted file mode 100644 index 82365c340..000000000 --- a/src/Netclaw.Cli/Mcp/McpOAuthProbe.cs +++ /dev/null @@ -1,152 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Text.Json; - -namespace Netclaw.Cli.Mcp; - -/// -/// Result of a best-effort OAuth capability probe against an MCP server endpoint. -/// is true when the server advertises RFC 9728 -/// protected-resource metadata naming at least one authorization server. -/// is true when that authorization -/// server publishes a registration endpoint (RFC 7591), which lets -/// netclaw mcp auth register a client automatically instead of requiring -/// a pre-registered --client-id. -/// -internal sealed record McpOAuthProbeResult(bool OAuthRequired, bool DynamicRegistrationAvailable); - -/// -/// Discovers whether an HTTP/SSE MCP endpoint requires OAuth authorization, and -/// whether its authorization server supports dynamic client registration. -/// Best-effort: any probe failure yields null so callers can degrade to -/// their pre-probe behavior instead of failing the enclosing command. -/// -internal static class McpOAuthProbe -{ - private const string ProtectedResourceSegment = "/.well-known/oauth-protected-resource"; - private const string AuthorizationServerSegment = "/.well-known/oauth-authorization-server"; - - /// - /// Probes for OAuth requirements. Returns - /// null when the endpoint publishes no usable protected-resource - /// metadata (or when any request fails), meaning OAuth could not be - /// positively detected. - /// - public static async Task DetectAsync( - string endpointUrl, - HttpClient client, - CancellationToken ct) - { - var issuer = await DiscoverIssuerAsync(endpointUrl, client, ct); - if (issuer is null) - return null; - - var dynamicRegistration = await DiscoverDynamicRegistrationAsync(issuer, client, ct); - return new McpOAuthProbeResult(OAuthRequired: true, DynamicRegistrationAvailable: dynamicRegistration); - } - - /// - /// RFC 9728: the protected-resource metadata document lives at - /// /.well-known/oauth-protected-resource on the resource's origin, - /// with the resource path appended as a suffix. Try the path-suffixed form - /// first, then fall back to the origin-level document. - /// - private static async Task DiscoverIssuerAsync( - string endpointUrl, - HttpClient client, - CancellationToken ct) - { - var resource = new Uri(endpointUrl); - var origin = resource.GetLeftPart(UriPartial.Authority); - var path = resource.AbsolutePath.TrimEnd('/'); - - var candidates = string.IsNullOrEmpty(path) || path == "/" - ? new[] { $"{origin}{ProtectedResourceSegment}" } - : [$"{origin}{ProtectedResourceSegment}{path}", $"{origin}{ProtectedResourceSegment}"]; - - foreach (var candidate in candidates) - { - using var document = await TryGetJsonAsync(client, candidate, ct); - if (document is null) - continue; - - if (document.RootElement.TryGetProperty("authorization_servers", out var servers) - && servers.ValueKind == JsonValueKind.Array - && servers.GetArrayLength() > 0) - { - foreach (var server in servers.EnumerateArray()) - { - if (server.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(server.GetString())) - return server.GetString(); - } - } - } - - return null; - } - - /// - /// RFC 8414: the authorization-server metadata document lives at - /// /.well-known/oauth-authorization-server on the issuer's origin. - /// A non-empty registration_endpoint means dynamic client - /// registration (RFC 7591) is available. - /// - private static async Task DiscoverDynamicRegistrationAsync( - string issuer, - HttpClient client, - CancellationToken ct) - { - var authServer = new Uri(issuer); - var origin = authServer.GetLeftPart(UriPartial.Authority); - var path = authServer.AbsolutePath.TrimEnd('/'); - - var candidates = string.IsNullOrEmpty(path) || path == "/" - ? new[] { $"{origin}{AuthorizationServerSegment}" } - : [$"{origin}{AuthorizationServerSegment}{path}", $"{origin}{AuthorizationServerSegment}"]; - - foreach (var candidate in candidates) - { - using var document = await TryGetJsonAsync(client, candidate, ct); - if (document is null) - continue; - - if (document.RootElement.TryGetProperty("registration_endpoint", out var registration) - && registration.ValueKind == JsonValueKind.String - && !string.IsNullOrWhiteSpace(registration.GetString())) - { - return true; - } - } - - return false; - } - - private static async Task TryGetJsonAsync( - HttpClient client, - string url, - CancellationToken ct) - { - try - { - using var response = await client.GetAsync(url, ct); - if (!response.IsSuccessStatusCode) - return null; - - var body = await response.Content.ReadAsStringAsync(ct); - return JsonDocument.Parse(body); - } - catch (Exception ex) when (ex is HttpRequestException - or TaskCanceledException - or OperationCanceledException - or JsonException - or NotSupportedException) - { - // Best-effort probe: an unreachable or malformed endpoint means - // "could not detect OAuth", never a failure of the caller. - return null; - } - } -}