Skip to content
Merged
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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, at least at the moment, register interest in the callback is necessary since the runtime has two MCP OAuth flows - the new one being introduced here and the in-process one currently used by the CLI; I'm working on a PR to switch the CLI to use the new SDK flow - as part of the CLI-over-SDK effort - but for now the interest registration is required in order for the runtime to know which path to take.

Regardless, even after we remove the CLI flow, I'm not sure how the "you're on your own" approach would work here MCP OAuth... The LLM can request a tool call at an arbitrary time, which could trigger an "oauth_required" request, which must be satisfied by the host; if this doesn't happen, the tool call can't proceed, and the turn is stuck. So I think it means the host must be informed (and it must respond in a timely manner)... I think that this oauth flow must indeed be rewired upon resume (even on a different machine), but it doesn't change the fact that an OAuth request can happen at any point, and the host must be able to handle it...

In other words, I think that any SDK host that potentially needs to support OAuth-authorizing MCP servers must register this callback, and if such a request is emitted without that callback being registered, I'm not sure what choice we have besides a fail-fast error... But I might have an incorrect mental model here - let me know how you see this.

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.

To make sure I've understood correctly, all the SDK ends up doing is listening for a particular event, invoking the user's callback, and calling the exposed RPC method to supply the result, right? So SDK consumer could do the same thing without providing a callback: they could listen for the same event, do whatever they want in response (whatever they would have supplied in the callback), and then call that same RPC method, right?

The PR is using a callback being set as an indication that the client is handling oauth, but if the above is true, then those needn't be 1:1... supplying a callback could imply it, but not supplying a callback doesn't mean the client can't also handle it. My question is really: should there be a separate knob for opting-in to handling oauth beyond just supplying a callback.

It's fine if the answer is "not right now, we could always add one in the future if supplying a callback is problematic for some reason".

@roji roji Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

EDIT: Maybe read my comment below, I think I understood better what you were asking

Here's how the flow works:

  1. The runtime makes an MCP call, and receives a 401/403 HTTP error, signifying that a (new) OAuth access token is required. Crucially, the runtime is the one seeing this - the hosting application (or client) has no idea it's happening.
  2. In the new host-delegating flow (when "interest is registered"), the runtime records the pending OAuth request and then invokes the callback in the hosting application, passing it various information from the 401 response (e.g. WWW-Authenticate header contents).
  3. The hosting application does whatever is needed in order to obtain the access token (user browser flow, or possibly just a refresh cycle).
  4. The hosting application then calls an API back into the SDK (handlePendingRequest)
  5. Back in the runtime, we correlate the request ID given to handlePendingRequest with the recorded OAuth request from step 2, and then redoes the MCP request with the new auth token received from the host.

IIUC you're asking whether it makes sense to have a host opt into host-delegated OAuth without registering a callback. In that case, I can't see what would happen when the runtime receives a 401/403 HTTP error which is the trigger for OAuth - how would the host application be made aware that OAuth needs to happen? Like are you thinking of some sort of polling mechanism where the host application would periodically check if there are any pending OAuth requests? I guess that's theoretically possible, but would at the very least mean lots of latency no?

Are you asking because reinstating host callbacks with the runtime after resume is problematic? I looked at this and it seems that we have several other callbacks which IIRC are should also be reinstated after resume - but I could be wrong.

Happy to do a quick call tomorrow to discuss all this!

@roji roji Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

OK, I reread everything and I think I understand your concern... You're saying that in the SDK implementation here (not the runtime), we expose a single callback which must return the token; this would prevents e.g. an implementation that persists the various parameters to disk, and then some other processes loads those and calls handlePendingRequest with the token. In other words, this is less about separating the opt-in from the callback, it's more to have a more low-level split interaction where there's the callback invoked by the runtime doesn't have to return an access token "immediately", and the host application later invokes handlePendingRequest manually.

I can see that... And that's exactly how the low-level RPC API is actually structured (oauth_required is the low-level callback that returns nothing). If so, then I think all this should be achievable with the low-level APIs today:

await session.Rpc.EventLog.RegisterInterestAsync("mcp.oauth_required");

using var sub = session.On<McpOauthRequiredEvent>(evt =>
{
    // Persist requestId + auth params.
    // Do not return token; this is just an event callback.
});

// Later / elsewhere:
await session.Rpc.Mcp.Oauth.HandlePendingRequestAsync(requestId, tokenResult);

There may be some issues around registering interest (and the callback) early enough during resume so as to avoid any race conditions... Also, specifically for the multiple process scenario (one process receives oauth_required, persists, another process actually completes with the access token), the runtime itself needs to remember/correlate the pending OAuth requests, and that's in-memory only; so a true resume will have lost that (assuming the runtime is in the same process as the host application).

tl;dr I think we have a nice high-level convenience callback for the common scenario (register a single async callback that returns the access token). In principle, users can still drop down to low-level RPC APIs to do the advanced (cross-process) thing, but it's likely we have some gaps around this - but they go beyond simple API shape here in the SDK, and we should probably think/design that separately (the main question is whether the high-level API as-is seems OK even if the advanced scenario isn't there yet).

Does this all make sense and correspond to what you're asking?

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.

correspond to what you're asking?

Yup! Being able to write that code sample is what I was asking about.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

OK great, understood.

Though as above, I suspect that for actual cross-process resume there'll still be some gaps/blockers... We can deal with those later.


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