Skip to content
Draft
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
10 changes: 10 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ private CopilotSession InitializeSession(
this);
session.RegisterTools(config.Tools ?? []);
session.RegisterPermissionHandler(config.OnPermissionRequest);
session.RegisterMcpAuthHandler(config.OnMcpAuthRequest);
session.RegisterCommands(config.Commands);
session.RegisterElicitationHandler(config.OnElicitationRequest);
session.RegisterExitPlanModeHandler(config.OnExitPlanModeRequest);
Expand Down Expand Up @@ -1050,6 +1051,11 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
$"session.create returned sessionId {response.SessionId} but the caller requested {localSessionId}.");
}

if (config.OnMcpAuthRequest is not null)
{
await session.Rpc.EventLog.RegisterInterestAsync("mcp.oauth_required", cancellationToken);
}

session.WorkspacePath = response.WorkspacePath;
session.SetCapabilities(response.Capabilities);
session.SetOpenCanvases(response.OpenCanvases);
Expand Down Expand Up @@ -1136,6 +1142,10 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
transformCallbacks,
hasHooks,
"CopilotClient.ResumeSessionAsync");
if (config.OnMcpAuthRequest is not null)
{
await session.Rpc.EventLog.RegisterInterestAsync("mcp.oauth_required", cancellationToken);
}

try
{
Expand Down
71 changes: 71 additions & 0 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
private readonly CopilotClient _parentClient;

private volatile Func<PermissionRequest, PermissionInvocation, Task<PermissionDecision>>? _permissionHandler;
private volatile Func<McpAuthContext, Task<McpAuthResult?>>? _mcpAuthHandler;
private volatile Func<UserInputRequest, UserInputInvocation, Task<UserInputResponse>>? _userInputHandler;
private volatile Func<ElicitationContext, Task<ElicitationResult>>? _elicitationHandler;
private volatile Func<ExitPlanModeRequest, ExitPlanModeInvocation, Task<ExitPlanModeResult>>? _exitPlanModeHandler;
Expand Down Expand Up @@ -561,6 +562,11 @@
_permissionHandler = handler;
}

internal void RegisterMcpAuthHandler(Func<McpAuthContext, Task<McpAuthResult?>>? handler)
{
_mcpAuthHandler = handler;
}

/// <summary>
/// Handles a permission request from the Copilot CLI.
/// </summary>
Expand Down Expand Up @@ -636,6 +642,37 @@
break;
}

case McpOauthRequiredEvent authEvent:
{
var data = authEvent.Data;
if (string.IsNullOrEmpty(data.RequestId))
return;

var handler = _mcpAuthHandler;
if (handler is null)
{
if (_logger.IsEnabled(LogLevel.Warning))
{
_logger.LogWarning(
"Received MCP OAuth request without a registered MCP auth handler. SessionId={SessionId}, RequestId={RequestId}",
SessionId,
data.RequestId);
}
return;
}

await ExecuteMcpAuthAndRespondAsync(data.RequestId, new McpAuthContext
{
SessionId = SessionId,
ServerName = data.ServerName,
ServerUrl = data.ServerUrl,
WwwAuthenticateParams = data.WwwAuthenticateParams,
ResourceMetadata = data.ResourceMetadata,
StaticClientConfig = data.StaticClientConfig
}, handler);
break;
}

case CommandExecuteEvent cmdEvent:
{
var data = cmdEvent.Data;
Expand Down Expand Up @@ -705,6 +742,40 @@
}
}

private async Task ExecuteMcpAuthAndRespondAsync(
string requestId,
McpAuthContext context,
Func<McpAuthContext, Task<McpAuthResult?>> handler)
{
try
{
var result = await handler(context);
McpOauthPendingRequestResponse response =
result is { Cancelled: false, Token: { } token }
? new McpOauthPendingRequestResponseToken
{
AccessToken = token.AccessToken,
TokenType = token.TokenType,
RefreshToken = token.RefreshToken,
ExpiresIn = token.ExpiresIn
}
: new McpOauthPendingRequestResponseCancelled();

await Rpc.Mcp.Oauth.HandlePendingRequestAsync(requestId, response);
}
catch (Exception)
{
try
{
await Rpc.Mcp.Oauth.HandlePendingRequestAsync(requestId, new McpOauthPendingRequestResponseCancelled());
}
catch (Exception rpcEx) when (rpcEx is IOException or ObjectDisposedException)
{
// Connection lost or RPC error — nothing we can do.
}
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
Comment on lines +766 to +776
}

/// <summary>
/// Executes a tool handler and sends the result back via the HandlePendingToolCall RPC.
/// </summary>
Expand Down
72 changes: 72 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,69 @@ public sealed class ElicitationContext
public string? Url { get; set; }
}

/// <summary>
/// Context for an MCP OAuth request callback.
/// </summary>
[Experimental(Diagnostics.Experimental)]
public sealed class McpAuthContext
{
/// <summary>Identifier of the session that triggered the MCP OAuth request.</summary>
public string SessionId { get; set; } = string.Empty;

/// <summary>Display name of the MCP server that requires OAuth.</summary>
public string ServerName { get; set; } = string.Empty;

/// <summary>URL of the MCP server that requires OAuth.</summary>
public string ServerUrl { get; set; } = string.Empty;

/// <summary>Parsed WWW-Authenticate parameters from the MCP server, if available.</summary>
public McpOauthWWWAuthenticateParams? WwwAuthenticateParams { get; set; }

/// <summary>Raw RFC 9728 protected-resource metadata JSON fetched by the runtime, if available.</summary>
public string? ResourceMetadata { get; set; }

/// <summary>Static OAuth client configuration, if the server specifies one.</summary>
public McpOauthRequiredStaticClientConfig? StaticClientConfig { get; set; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cross-SDK consistency suggestion: McpAuthContext is missing a RequestId property.

Every other SDK exposes the request ID to the MCP auth handler:

  • Node.js: McpAuthRequest.requestId
  • Python: McpAuthRequest["requestId"] (TypedDict key)
  • Go: MCPAuthRequest.RequestID
  • Java: McpAuthRequest.requestId() (record component)
  • Rust: request_id: RequestId as a separate handle() parameter

In .NET, a handler receiving an MCP OAuth request has no way to identify or log which specific request it's processing. Consider adding:

/// <summary>Unique request identifier for this MCP OAuth request.</summary>
public string RequestId { get; set; } = string.Empty;

(The existing .NET PermissionInvocation also omits request ID, so there is a consistent internal precedent — but for the new McpAuthContext type this cross-SDK gap is worth revisiting.)

}

/// <summary>
/// Host-provided OAuth token data for a pending MCP OAuth request.
/// </summary>
[Experimental(Diagnostics.Experimental)]
public sealed class McpAuthToken
{
/// <summary>Access token acquired by the SDK host.</summary>
public required string AccessToken { get; set; }

/// <summary>OAuth token type. Defaults to Bearer when omitted.</summary>
public string? TokenType { get; set; }

/// <summary>Refresh token supplied by the host, if available.</summary>
public string? RefreshToken { get; set; }

/// <summary>Token lifetime in seconds, if known.</summary>
public long? ExpiresIn { get; set; }
}

/// <summary>
/// Result returned by an MCP auth request handler.
/// </summary>
[Experimental(Diagnostics.Experimental)]
public sealed class McpAuthResult
{
/// <summary>Whether the request should be cancelled instead of resolved with a token.</summary>
public bool Cancelled { get; set; }

/// <summary>Host-provided token data. Ignored when <see cref="Cancelled"/> is true.</summary>
public McpAuthToken? Token { get; set; }

/// <summary>Create a token result.</summary>
public static McpAuthResult FromToken(McpAuthToken token) => new() { Token = token };

/// <summary>Create a cancellation result.</summary>
public static McpAuthResult Cancel() => new() { Cancelled = true };
}

// ============================================================================
// Session Capabilities
// ============================================================================
Expand Down Expand Up @@ -2662,6 +2725,7 @@ protected SessionConfigBase(SessionConfigBase? other)
OnElicitationRequest = other.OnElicitationRequest;
OnEvent = other.OnEvent;
OnExitPlanModeRequest = other.OnExitPlanModeRequest;
OnMcpAuthRequest = other.OnMcpAuthRequest;
OnPermissionRequest = other.OnPermissionRequest;
OnUserInputRequest = other.OnUserInputRequest;
Provider = other.Provider;
Expand Down Expand Up @@ -3105,6 +3169,14 @@ protected SessionConfigBase(SessionConfigBase? other)
[JsonIgnore]
public ICanvasHandler? CanvasHandler { get; set; }
#pragma warning restore GHCP001

/// <summary>
/// Optional handler for MCP OAuth requests from MCP servers.
/// When provided, the SDK can satisfy MCP server OAuth requests with host-provided token data or cancellation.
/// </summary>
[Experimental(Diagnostics.Experimental)]
[JsonIgnore]
public Func<McpAuthContext, Task<McpAuthResult?>>? OnMcpAuthRequest { get; set; }
}

/// <summary>
Expand Down
154 changes: 154 additions & 0 deletions dotnet/test/E2E/McpOAuthE2ETests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using GitHub.Copilot.Rpc;
using GitHub.Copilot.Test.Harness;
using System.Diagnostics;
using System.Text.Json;
using Xunit;
using Xunit.Abstractions;

namespace GitHub.Copilot.Test.E2E;

public class McpOAuthE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "mcp_oauth", output)
{
private const string ExpectedToken = "sdk-host-token";

[Fact]
public async Task Should_Satisfy_MCP_OAuth_Using_Host_Provided_Token()
{
await using var oauthServer = await OAuthMcpServer.StartAsync(ExpectedToken);
var serverName = "oauth-protected-mcp";
McpAuthContext? observedRequest = null;

await using var session = await CreateSessionAsync(new SessionConfig
{
OnMcpAuthRequest = request =>
{
observedRequest = request;
return Task.FromResult<McpAuthResult?>(McpAuthResult.FromToken(new McpAuthToken
{
AccessToken = ExpectedToken,
TokenType = "Bearer",
ExpiresIn = 3600
}));
},
McpServers = new Dictionary<string, McpServerConfig>
{
[serverName] = new McpHttpServerConfig
{
Url = $"{oauthServer.Url}/mcp",
Tools = ["*"]
}
}
});

await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected);
var tools = await session.Rpc.Mcp.ListToolsAsync(serverName);
Assert.Contains(tools.Tools, tool => tool.Name == "whoami");

Assert.NotNull(observedRequest);
Assert.Equal(serverName, observedRequest!.ServerName);
Assert.Equal($"{oauthServer.Url}/mcp", observedRequest.ServerUrl);
Assert.NotNull(observedRequest.WwwAuthenticateParams);
Assert.Equal($"{oauthServer.Url}/.well-known/oauth-protected-resource", observedRequest.WwwAuthenticateParams!.ResourceMetadataUrl);
Assert.Equal("mcp.read", observedRequest.WwwAuthenticateParams.Scope);
Assert.Equal("invalid_token", observedRequest.WwwAuthenticateParams.Error);

using var metadata = JsonDocument.Parse(observedRequest.ResourceMetadata!);
Assert.Equal($"{oauthServer.Url}/mcp", metadata.RootElement.GetProperty("resource").GetString());

var requests = await oauthServer.GetRequestsAsync();
Assert.Contains(requests, request => request.Authorization is null);
Assert.Contains(requests, request => request.Authorization == $"Bearer {ExpectedToken}");
}

private sealed class OAuthMcpServer : IAsyncDisposable
{
private readonly Process _process;
private readonly HttpClient _http = new();

Check failure on line 70 in dotnet/test/E2E/McpOAuthE2ETests.cs

View workflow job for this annotation

GitHub Actions / .NET SDK Tests (windows-latest)

The type or namespace name 'HttpClient' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 70 in dotnet/test/E2E/McpOAuthE2ETests.cs

View workflow job for this annotation

GitHub Actions / .NET SDK Tests (windows-latest)

The type or namespace name 'HttpClient' could not be found (are you missing a using directive or an assembly reference?)

private OAuthMcpServer(Process process, string url)
{
_process = process;
Url = url;
}

public string Url { get; }

public static async Task<OAuthMcpServer> StartAsync(string expectedToken)
{
var repoRoot = FindRepoRoot();
var script = Path.Combine(repoRoot, "test", "harness", "test-mcp-oauth-server.mjs");

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note test

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
var startInfo = new ProcessStartInfo
{
FileName = "node",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.ArgumentList.Add(script);
startInfo.Environment["EXPECTED_TOKEN"] = expectedToken;

var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Failed to start OAuth MCP server.");
var stderrTask = process.StandardError.ReadToEndAsync();

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
while (!cts.IsCancellationRequested)
{
var line = await process.StandardOutput.ReadLineAsync(cts.Token);
if (line is null)
{
throw new InvalidOperationException($"OAuth MCP server exited before listening: {await stderrTask}");
}
if (line.StartsWith("Listening: ", StringComparison.Ordinal))
{
return new OAuthMcpServer(process, line["Listening: ".Length..]);
}
}

throw new TimeoutException($"Timed out waiting for OAuth MCP server: {await stderrTask}");
}

public async Task<List<OAuthMcpRequest>> GetRequestsAsync()
{
var json = await _http.GetStringAsync($"{Url}/__requests");
using var document = JsonDocument.Parse(json);
return document.RootElement.EnumerateArray()
.Select(element => new OAuthMcpRequest(
element.TryGetProperty("authorization", out var authorization)
&& authorization.ValueKind is JsonValueKind.String
? authorization.GetString()
: null))
.ToList();
}

public async ValueTask DisposeAsync()
{
_http.Dispose();
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
await _process.WaitForExitAsync();
}
_process.Dispose();
}

private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var candidate = Path.Combine(dir.FullName, "test", "harness", "test-mcp-oauth-server.mjs");

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note test

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
if (File.Exists(candidate))
return dir.FullName;
dir = dir.Parent;
}
throw new InvalidOperationException("Could not find repository root.");
}
}

private sealed record OAuthMcpRequest(string? Authorization);
}
Loading
Loading