Skip to content
Open
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 @@ -1080,6 +1081,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 @@ -1166,6 +1172,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);
}
Comment on lines +1175 to +1178

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Are these necessarily tied together?

I'm thinking back to permission requests and tool invocation requests. Initially the only way you could handle them was to provide a handler that the SDK would invoke, but that was prohibitive, especially with regards to needing to be able to suspend the runtime and later resume it (possibly on a different machine) and supply the result of the operation in order to keep going. We made it so that providing the callback was optional; if you don't supply it, you're on your own for handling the events and calling the runtime rpc method to supply the results.

Is there a correlary here?


try
{
Expand Down
124 changes: 124 additions & 0 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public sealed partial class CopilotSession : IAsyncDisposable
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 @@ -558,6 +559,11 @@ internal void RegisterPermissionHandler(Func<PermissionRequest, PermissionInvoca
_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 @@ -633,6 +639,39 @@ private async Task HandleBroadcastEventAsync(SessionEvent sessionEvent)
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,
RequestId = data.RequestId,
ServerName = data.ServerName,
ServerUrl = data.ServerUrl,
Reason = data.Reason,
WwwAuthenticateParams = data.WwwAuthenticateParams,
ResourceMetadata = data.ResourceMetadata,
StaticClientConfig = data.StaticClientConfig
}, handler);
break;
}

case CommandExecuteEvent cmdEvent:
{
var data = cmdEvent.Data;
Expand Down Expand Up @@ -702,6 +741,91 @@ await HandleElicitationRequestAsync(
}
}

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,
ExpiresIn = token.ExpiresIn
}
: new McpOauthPendingRequestResponseCancelled();

await Rpc.Mcp.Oauth.HandlePendingRequestAsync(requestId, response);
}
catch (OperationCanceledException)
{
await TryCancelMcpAuthRequestAsync(requestId);
}
catch (ObjectDisposedException)
{
await TryCancelMcpAuthRequestAsync(requestId);
}
catch (InvalidOperationException)
{
await TryCancelMcpAuthRequestAsync(requestId);
}
catch (ArgumentException)
{
await TryCancelMcpAuthRequestAsync(requestId);
}
catch (NotSupportedException)
{
await TryCancelMcpAuthRequestAsync(requestId);
}
catch (JsonException)
{
await TryCancelMcpAuthRequestAsync(requestId);
}
catch (RemoteRpcException)
{
await TryCancelMcpAuthRequestAsync(requestId);
}
catch (IOException)
{
await TryCancelMcpAuthRequestAsync(requestId);
}
catch (Exception ex) when (IsRecoverableMcpAuthFailure(ex))
{
await TryCancelMcpAuthRequestAsync(requestId);
}
}

private static bool IsRecoverableMcpAuthFailure(Exception exception)
=> exception is not OperationCanceledException
and not OutOfMemoryException
and not StackOverflowException
and not AccessViolationException
and not AppDomainUnloadedException;

private async Task TryCancelMcpAuthRequestAsync(string requestId)
{
try
{
await Rpc.Mcp.Oauth.HandlePendingRequestAsync(requestId, new McpOauthPendingRequestResponseCancelled());
}
catch (IOException)
{
// Connection lost — nothing we can do.
}
catch (ObjectDisposedException)
{
// Connection already disposed — nothing we can do.
}
catch (RemoteRpcException)
{
// The pending request may already be gone — nothing we can do.
}
}

/// <summary>
/// Executes a tool handler and sends the result back via the HandlePendingToolCall RPC.
/// </summary>
Expand Down
75 changes: 75 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,72 @@ 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>Identifier of the pending MCP OAuth request.</summary>
public string RequestId { 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>Why the runtime is requesting host-provided OAuth credentials.</summary>
public McpOauthRequestReason Reason { get; set; }

/// <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; }
}

/// <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>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 @@ -2719,6 +2785,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 @@ -3180,6 +3247,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