Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
165 changes: 158 additions & 7 deletions src/Netclaw.Cli.Tests/Mcp/McpCommandTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="McpCommandTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -33,6 +33,15 @@ public void Dispose()
_dir.Dispose();
}

/// <summary>
/// An HTTP client factory whose well-known metadata probes always 404, so
/// <c>mcp add</c> tests never touch the network and keep the standard
/// permissions-only output.
/// </summary>
private static Func<HttpClient> NoProbeClientFactory()
=> () => new HttpClient(new FakeHttpMessageHandler(
_ => new HttpResponseMessage(HttpStatusCode.NotFound)));

[Fact]
public async Task Add_StdioServer_WritesConfig()
{
Expand All @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand All @@ -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<HttpClient> 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 <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()
{
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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),
Expand Down
104 changes: 104 additions & 0 deletions src/Netclaw.Cli.Tests/Mcp/McpOAuthProbeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// -----------------------------------------------------------------------
// <copyright file="McpOAuthProbeTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
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<bool, bool, bool, bool> 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);
}
}
Loading
Loading