feat: surface MCP OAuth sidecar events

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-27 19:01:46 +01:00
co-authored by Copilot
parent b670680a7d
commit 192c28f721
9 changed files with 343 additions and 10 deletions
@@ -309,6 +309,23 @@ public sealed class UserInputRequestedEventDto : SidecarEventDto
public bool? AllowFreeform { get; init; }
}
public sealed class McpOauthStaticClientConfigDto
{
public string ClientId { get; init; } = string.Empty;
public bool? PublicClient { get; init; }
}
public sealed class McpOauthRequiredEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string OauthRequestId { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string ServerName { get; init; } = string.Empty;
public string ServerUrl { get; init; } = string.Empty;
public McpOauthStaticClientConfigDto? StaticClientConfig { get; init; }
}
public sealed class ExitPlanModeRequestedEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
@@ -0,0 +1,58 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotMcpOAuthCoordinator
{
public McpOauthRequiredEventDto BuildMcpOauthRequiredEvent(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
McpOauthRequiredEvent request)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(request);
McpOauthRequiredData requestData = request.Data
?? throw new InvalidOperationException("MCP OAuth request data is required.");
string oauthRequestId = NormalizeOptionalString(requestData.RequestId)
?? throw new InvalidOperationException("MCP OAuth request ID is required.");
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
return new McpOauthRequiredEventDto
{
Type = "mcp-oauth-required",
RequestId = command.RequestId,
SessionId = command.SessionId,
OauthRequestId = oauthRequestId,
AgentId = normalizedAgentId,
AgentName = normalizedAgentName,
ServerName = NormalizeOptionalString(requestData.ServerName) ?? string.Empty,
ServerUrl = NormalizeOptionalString(requestData.ServerUrl) ?? string.Empty,
StaticClientConfig = BuildStaticClientConfig(requestData.StaticClientConfig),
};
}
private static McpOauthStaticClientConfigDto? BuildStaticClientConfig(
McpOauthRequiredDataStaticClientConfig? staticClientConfig)
{
if (staticClientConfig is null)
{
return null;
}
return new McpOauthStaticClientConfigDto
{
ClientId = NormalizeOptionalString(staticClientConfig.ClientId) ?? string.Empty,
PublicClient = staticClientConfig.PublicClient,
};
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
@@ -9,6 +9,7 @@ internal sealed class CopilotTurnExecutionState
{
private readonly RunTurnCommandDto _command;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
private int _fallbackMessageIndex;
@@ -76,6 +77,9 @@ internal sealed class CopilotTurnExecutionState
case AssistantReasoningDeltaEvent:
ActiveAgent = agent;
break;
case McpOauthRequiredEvent:
ActiveAgent = agent;
break;
case ExitPlanModeRequestedEvent:
HasPendingExitPlanModeRequest = true;
ActiveAgent = agent;
@@ -83,6 +87,23 @@ internal sealed class CopilotTurnExecutionState
}
}
public void EnqueuePendingMcpOauthRequest(McpOauthRequiredEventDto request)
{
ArgumentNullException.ThrowIfNull(request);
_pendingMcpOauthRequests.Enqueue(request);
}
public IReadOnlyList<McpOauthRequiredEventDto> DrainPendingMcpOauthRequests()
{
List<McpOauthRequiredEventDto> pending = [];
while (_pendingMcpOauthRequests.TryDequeue(out McpOauthRequiredEventDto? request))
{
pending.Add(request);
}
return pending;
}
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
{
agent = default;
@@ -10,6 +10,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
private readonly PatternValidator _patternValidator;
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
public CopilotWorkflowRunner(PatternValidator patternValidator)
@@ -23,6 +24,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
Func<AgentActivityEventDto, Task> onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
CancellationToken cancellationToken)
{
@@ -58,6 +60,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
(agent, sessionEvent) =>
{
state.ObserveSessionEvent(agent, sessionEvent);
if (sessionEvent is McpOauthRequiredEvent mcpOauthRequired)
{
state.EnqueuePendingMcpOauthRequest(
_mcpOAuthCoordinator.BuildMcpOauthRequiredEvent(command, agent, mcpOauthRequired));
}
if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested)
{
_exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested);
@@ -75,16 +83,19 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
{
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
.ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
if (shouldEndTurn)
{
break;
}
}
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
return state.FinalizeCompletedMessages();
}
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId);
if (exitPlanModeEvent is null || !state.HasPendingExitPlanModeRequest)
@@ -97,6 +108,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
}
private static async Task EmitPendingMcpOauthRequestsAsync(
CopilotTurnExecutionState state,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired)
{
foreach (McpOauthRequiredEventDto request in state.DrainPendingMcpOauthRequests())
{
await onMcpOAuthRequired(request).ConfigureAwait(false);
}
}
public Task ResolveApprovalAsync(
ResolveApprovalCommandDto command,
CancellationToken cancellationToken)
@@ -10,6 +10,7 @@ public interface ITurnWorkflowRunner
Func<AgentActivityEventDto, Task> onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
CancellationToken cancellationToken);
@@ -188,6 +188,7 @@ public sealed class SidecarProtocolHost
activity => WriteAsync(context.Output, activity, turnCancellation.Token),
approval => WriteAsync(context.Output, approval, turnCancellation.Token),
userInput => WriteAsync(context.Output, userInput, turnCancellation.Token),
mcpOauth => WriteAsync(context.Output, mcpOauth, turnCancellation.Token),
exitPlanMode => WriteAsync(context.Output, exitPlanMode, turnCancellation.Token),
turnCancellation.Token)
.ConfigureAwait(false);
@@ -0,0 +1,71 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Tests;
public sealed class CopilotMcpOAuthCoordinatorTests
{
[Fact]
public void BuildMcpOauthRequiredEvent_MapsSdkEventToProtocolEvent()
{
CopilotMcpOAuthCoordinator coordinator = new();
RunTurnCommandDto command = CreateCommand();
McpOauthRequiredEventDto oauthEvent = coordinator.BuildMcpOauthRequiredEvent(
command,
command.Pattern.Agents[0],
new McpOauthRequiredEvent
{
Data = new McpOauthRequiredData
{
RequestId = " oauth-request-1 ",
ServerName = " Example MCP ",
ServerUrl = " https://example.com/mcp ",
StaticClientConfig = new McpOauthRequiredDataStaticClientConfig
{
ClientId = " aryx-client ",
PublicClient = true,
},
},
});
Assert.Equal("mcp-oauth-required", oauthEvent.Type);
Assert.Equal("turn-1", oauthEvent.RequestId);
Assert.Equal("session-1", oauthEvent.SessionId);
Assert.Equal("oauth-request-1", oauthEvent.OauthRequestId);
Assert.Equal("agent-1", oauthEvent.AgentId);
Assert.Equal("Primary", oauthEvent.AgentName);
Assert.Equal("Example MCP", oauthEvent.ServerName);
Assert.Equal("https://example.com/mcp", oauthEvent.ServerUrl);
Assert.NotNull(oauthEvent.StaticClientConfig);
Assert.Equal("aryx-client", oauthEvent.StaticClientConfig!.ClientId);
Assert.True(oauthEvent.StaticClientConfig.PublicClient);
}
private static RunTurnCommandDto CreateCommand()
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "MCP OAuth Pattern",
Mode = "single",
Availability = "available",
Agents =
[
new PatternAgentDefinitionDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
],
},
};
}
}
@@ -0,0 +1,82 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Tests;
public sealed class CopilotTurnExecutionStateTests
{
[Fact]
public void ObserveSessionEvent_McpOauthRequired_SetsActiveAgent()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
new McpOauthRequiredEvent
{
Data = new McpOauthRequiredData
{
RequestId = "oauth-request-1",
ServerName = "Example MCP",
ServerUrl = "https://example.com/mcp",
},
});
Assert.True(state.ActiveAgent.HasValue);
Assert.Equal("agent-1", state.ActiveAgent.Value.AgentId);
Assert.Equal("Primary", state.ActiveAgent.Value.AgentName);
}
[Fact]
public void DrainPendingMcpOauthRequests_ReturnsQueuedRequestsAndClearsQueue()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
McpOauthRequiredEventDto request = new()
{
Type = "mcp-oauth-required",
RequestId = command.RequestId,
SessionId = command.SessionId,
OauthRequestId = "oauth-request-1",
ServerName = "Example MCP",
ServerUrl = "https://example.com/mcp",
};
state.EnqueuePendingMcpOauthRequest(request);
IReadOnlyList<McpOauthRequiredEventDto> firstDrain = state.DrainPendingMcpOauthRequests();
IReadOnlyList<McpOauthRequiredEventDto> secondDrain = state.DrainPendingMcpOauthRequests();
McpOauthRequiredEventDto drained = Assert.Single(firstDrain);
Assert.Equal("oauth-request-1", drained.OauthRequestId);
Assert.Empty(secondDrain);
}
private static RunTurnCommandDto CreateCommand()
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "MCP OAuth Pattern",
Mode = "single",
Availability = "available",
Agents =
[
new PatternAgentDefinitionDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
],
},
};
}
}
@@ -113,7 +113,7 @@ public sealed class SidecarProtocolHostTests
{
SidecarProtocolHost host = new(
new PatternValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) =>
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onActivity(new AgentActivityEventDto
{
@@ -238,7 +238,7 @@ public sealed class SidecarProtocolHostTests
string? capturedMode = null;
SidecarProtocolHost host = new(
new PatternValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) =>
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
capturedMode = command.Mode;
return [];
@@ -274,7 +274,7 @@ public sealed class SidecarProtocolHostTests
{
SidecarProtocolHost host = new(
new PatternValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) =>
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onApproval(new ApprovalRequestedEventDto
{
@@ -352,7 +352,7 @@ public sealed class SidecarProtocolHostTests
{
SidecarProtocolHost host = new(
new PatternValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) =>
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onUserInput(new UserInputRequestedEventDto
{
@@ -420,12 +420,70 @@ public sealed class SidecarProtocolHostTests
});
}
[Fact]
public async Task RunTurnCommand_ReturnsMcpOauthRequiredEvents()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onMcpOAuthRequired(new McpOauthRequiredEventDto
{
Type = "mcp-oauth-required",
RequestId = command.RequestId,
SessionId = command.SessionId,
OauthRequestId = "oauth-request-1",
AgentId = "agent-1",
AgentName = "Primary",
ServerName = "Example MCP",
ServerUrl = "https://example.com/mcp",
StaticClientConfig = new McpOauthStaticClientConfigDto
{
ClientId = "aryx-client",
PublicClient = true,
},
});
return [];
}));
IReadOnlyList<JsonElement> events = await RunHostAsync(
CreateRunTurnCommand(requestId: "turn-mcp-oauth"),
host);
Assert.Collection(
events,
oauthEvent =>
{
Assert.Equal("mcp-oauth-required", oauthEvent.GetProperty("type").GetString());
Assert.Equal("turn-mcp-oauth", oauthEvent.GetProperty("requestId").GetString());
Assert.Equal("session-1", oauthEvent.GetProperty("sessionId").GetString());
Assert.Equal("oauth-request-1", oauthEvent.GetProperty("oauthRequestId").GetString());
Assert.Equal("Primary", oauthEvent.GetProperty("agentName").GetString());
Assert.Equal("Example MCP", oauthEvent.GetProperty("serverName").GetString());
Assert.Equal("https://example.com/mcp", oauthEvent.GetProperty("serverUrl").GetString());
JsonElement staticClientConfig = oauthEvent.GetProperty("staticClientConfig");
Assert.Equal("aryx-client", staticClientConfig.GetProperty("clientId").GetString());
Assert.True(staticClientConfig.GetProperty("publicClient").GetBoolean());
},
completionEvent =>
{
Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString());
Assert.False(completionEvent.GetProperty("cancelled").GetBoolean());
},
commandCompleteEvent =>
{
Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString());
Assert.Equal("turn-mcp-oauth", commandCompleteEvent.GetProperty("requestId").GetString());
});
}
[Fact]
public async Task RunTurnCommand_ReturnsExitPlanModeEvents()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) =>
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onExitPlanMode(new ExitPlanModeRequestedEventDto
{
@@ -500,7 +558,7 @@ public sealed class SidecarProtocolHostTests
{
SidecarProtocolHost host = new(
new PatternValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) =>
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await Task.Delay(Timeout.Infinite, cancellationToken);
return [];
@@ -548,7 +606,7 @@ public sealed class SidecarProtocolHostTests
{
SidecarProtocolHost host = new(
new PatternValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => []));
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => []));
await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host);
@@ -571,7 +629,7 @@ public sealed class SidecarProtocolHostTests
SidecarProtocolHost host = new(
new PatternValidator(),
new FakeWorkflowRunner(
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => [],
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
resolveApprovalHandler: (command, cancellationToken) =>
{
captured = command;
@@ -602,7 +660,7 @@ public sealed class SidecarProtocolHostTests
SidecarProtocolHost host = new(
new PatternValidator(),
new FakeWorkflowRunner(
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => [],
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
resolveUserInputHandler: (command, cancellationToken) =>
{
captured = command;
@@ -892,6 +950,7 @@ public sealed class SidecarProtocolHostTests
Func<AgentActivityEventDto, Task>,
Func<ApprovalRequestedEventDto, Task>,
Func<UserInputRequestedEventDto, Task>,
Func<McpOauthRequiredEventDto, Task>,
Func<ExitPlanModeRequestedEventDto, Task>,
CancellationToken,
Task<IReadOnlyList<ChatMessageDto>>> _handler;
@@ -905,6 +964,7 @@ public sealed class SidecarProtocolHostTests
Func<AgentActivityEventDto, Task>,
Func<ApprovalRequestedEventDto, Task>,
Func<UserInputRequestedEventDto, Task>,
Func<McpOauthRequiredEventDto, Task>,
Func<ExitPlanModeRequestedEventDto, Task>,
CancellationToken,
Task<IReadOnlyList<ChatMessageDto>>> handler,
@@ -922,10 +982,11 @@ public sealed class SidecarProtocolHostTests
Func<AgentActivityEventDto, Task> onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
CancellationToken cancellationToken)
{
return _handler(command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken);
return _handler(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken);
}
public Task ResolveApprovalAsync(