Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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
15 changes: 15 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 @@ -937,6 +938,10 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
transformCallbacks,
hasHooks,
"CopilotClient.CreateSessionAsync");
if (config.OnMcpAuthRequest is not null)
{
await session.Rpc.EventLog.RegisterInterestAsync("mcp.oauth_required", cancellationToken);
}
}
try
{
Expand Down Expand Up @@ -1050,6 +1055,12 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
$"session.create returned sessionId {response.SessionId} but the caller requested {localSessionId}.");
}

// Local IDs registered before create; server-assigned IDs can only register now.
if (localSessionId is null && 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 +1147,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
Loading
Loading