mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
feat: surface MCP OAuth sidecar events
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user