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
819 changes: 473 additions & 346 deletions dotnet/src/Generated/Rpc.cs

Large diffs are not rendered by default.

35 changes: 33 additions & 2 deletions dotnet/src/Generated/SessionEvents.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

120 changes: 2 additions & 118 deletions dotnet/test/E2E/RpcMcpLifecycleE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
namespace GitHub.Copilot.Test.E2E;

/// <summary>
/// E2E coverage for the session-scoped MCP lifecycle RPC methods that were previously untested:
/// listTools, isServerRunning, stopServer, startServer, restartServer, registerExternalClient,
/// unregisterExternalClient, reloadWithConfig, configureGitHub, and oauth.respond.
/// E2E coverage for the public session-scoped MCP lifecycle RPC methods:
/// listTools, isServerRunning, and stopServer.
/// </summary>
public class RpcMcpLifecycleE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
: E2ETestBase(fixture, "rpc_mcp_lifecycle", output)
Expand Down Expand Up @@ -71,121 +70,6 @@ public async Task Should_Stop_Running_Mcp_Server()
await WaitForMcpRunningAsync(session, serverName, expectedRunning: false);
}

[Fact]
public async Task Should_Start_And_Restart_Mcp_Server()
{
const string hostServer = "rpc-lifecycle-host-server";
await using var session = await CreateSessionAsync(new SessionConfig
{
McpServers = CreateTestMcpServers(hostServer),
});
await WaitForMcpServerStatusAsync(session, hostServer, McpServerStatus.Connected);

// Start a brand-new server through the lifecycle API, reusing the exact stdio config shape
// the bulk session-config path uses so the runtime accepts and connects it.
const string startedServer = "rpc-lifecycle-started-server";
var config = CreateTestMcpServers(startedServer)[startedServer];

await session.Rpc.Mcp.StartServerAsync(startedServer, config);
await WaitForMcpRunningAsync(session, startedServer, expectedRunning: true);

// The freshly started server exposes its tools just like a config-provided server.
var tools = await session.Rpc.Mcp.ListToolsAsync(startedServer);
Assert.NotEmpty(tools.Tools);

// Restart stops then starts the same server; it must end up running again.
await session.Rpc.Mcp.RestartServerAsync(startedServer, config);
await WaitForMcpRunningAsync(session, startedServer, expectedRunning: true);
}

[Fact]
public async Task Should_Register_And_Unregister_External_Mcp_Client()
{
const string hostServer = "rpc-lifecycle-extclient-host";
await using var session = await CreateSessionAsync(new SessionConfig
{
McpServers = CreateTestMcpServers(hostServer),
});
await WaitForMcpServerStatusAsync(session, hostServer, McpServerStatus.Connected);

const string externalName = "rpc-lifecycle-external-client";
Assert.False((await session.Rpc.Mcp.IsServerRunningAsync(externalName)).Running);

// The runtime stores the supplied client/transport handles on the host registry, so the
// registered name immediately reports as running until it is unregistered again.
await session.Rpc.Mcp.RegisterExternalClientAsync(
externalName,
client: new Dictionary<string, object> { ["id"] = externalName },
transport: new Dictionary<string, object> { ["kind"] = "in-process" },
config: new Dictionary<string, object> { ["command"] = "noop" });
Assert.True((await session.Rpc.Mcp.IsServerRunningAsync(externalName)).Running);

await session.Rpc.Mcp.UnregisterExternalClientAsync(externalName);
Assert.False((await session.Rpc.Mcp.IsServerRunningAsync(externalName)).Running);
}

[Fact]
public async Task Should_Reload_Mcp_Servers_With_Config()
{
const string hostServer = "rpc-lifecycle-reload-host";
await using var session = await CreateSessionAsync(new SessionConfig
{
McpServers = CreateTestMcpServers(hostServer),
});
await WaitForMcpServerStatusAsync(session, hostServer, McpServerStatus.Connected);

// reloadWithConfig drives the runtime's reloadMcpServers with an explicit host config and
// returns the startup filtering result. Reloading an empty server set is a valid no-op.
var result = await session.Rpc.Mcp.ReloadWithConfigAsync(new Dictionary<string, object>
{
["mcpServers"] = new Dictionary<string, object>(),
["disabledServers"] = new List<string>(),
});

Assert.NotNull(result);
Assert.NotNull(result.FilteredServers);
Assert.Empty(result.FilteredServers);
}

[Fact]
public async Task Should_Configure_GitHub_Mcp_Server()
{
const string hostServer = "rpc-lifecycle-configure-host";
await using var session = await CreateSessionAsync(new SessionConfig
{
McpServers = CreateTestMcpServers(hostServer),
});
await WaitForMcpServerStatusAsync(session, hostServer, McpServerStatus.Connected);

// configureGitHub forwards a typed auth-info union to the runtime. An "api-key" auth info is
// a recognized type that the runtime declines to act on, so configuration is left unchanged
// (changed=false) while still proving the method is wired through to the handler.
var result = await session.Rpc.Mcp.ConfigureGitHubAsync(new Dictionary<string, object?>
{
["type"] = "api-key",
});

Assert.NotNull(result);
Assert.False(result.Changed);
}

[Fact]
public async Task Should_Respond_To_Mcp_Oauth_Request_Without_Pending_Request()
{
const string hostServer = "rpc-lifecycle-oauth-host";
await using var session = await CreateSessionAsync(new SessionConfig
{
McpServers = CreateTestMcpServers(hostServer),
});
await WaitForMcpServerStatusAsync(session, hostServer, McpServerStatus.Connected);

// With no pending OAuth request, the runtime's respondToMcpOAuth is a tolerant no-op: it
// looks up the request id, finds nothing, and returns an empty result without throwing. The
// call must reach the runtime and complete successfully, proving the method is wired.
var result = await session.Rpc.Mcp.Oauth.RespondAsync($"missing-{Guid.NewGuid():N}");
Assert.NotNull(result);
}

private static Task WaitForMcpRunningAsync(CopilotSession session, string serverName, bool expectedRunning) =>
Harness.TestHelper.WaitForConditionAsync(
async () => (await session.Rpc.Mcp.IsServerRunningAsync(serverName)).Running == expectedRunning,
Expand Down
120 changes: 0 additions & 120 deletions go/internal/e2e/rpc_mcp_lifecycle_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,126 +80,6 @@ func TestRpcMcpLifecycle(t *testing.T) {
}
waitForPortedMCPRunning(t, session, serverName, false)
})

t.Run("should_start_and_restart_mcp_server", func(t *testing.T) {
ctx.ConfigureForTest(t)
const hostServer = "rpc-lifecycle-host-server"
session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, hostServer)})
defer session.Disconnect()
waitForPortedMCPServerStatus(t, session, hostServer, rpc.MCPServerStatusConnected)

const startedServer = "rpc-lifecycle-started-server"
config := testMCPServers(t, startedServer)[startedServer]
if _, err := session.RPC.MCP.StartServer(t.Context(), &rpc.MCPStartServerRequest{ServerName: startedServer, Config: config}); err != nil {
t.Fatalf("MCP.StartServer failed: %v", err)
}
waitForPortedMCPRunning(t, session, startedServer, true)

tools, err := session.RPC.MCP.ListTools(t.Context(), &rpc.MCPListToolsRequest{ServerName: startedServer})
if err != nil {
t.Fatalf("MCP.ListTools(started) failed: %v", err)
}
if len(tools.Tools) == 0 {
t.Fatal("Expected started MCP server to expose tools")
}

if _, err := session.RPC.MCP.RestartServer(t.Context(), &rpc.MCPRestartServerRequest{ServerName: startedServer, Config: config}); err != nil {
t.Fatalf("MCP.RestartServer failed: %v", err)
}
waitForPortedMCPRunning(t, session, startedServer, true)
})

t.Run("should_register_and_unregister_external_mcp_client", func(t *testing.T) {
ctx.ConfigureForTest(t)
const hostServer = "rpc-lifecycle-extclient-host"
session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, hostServer)})
defer session.Disconnect()
waitForPortedMCPServerStatus(t, session, hostServer, rpc.MCPServerStatusConnected)

const externalName = "rpc-lifecycle-external-client"
initial, err := session.RPC.MCP.IsServerRunning(t.Context(), &rpc.MCPIsServerRunningRequest{ServerName: externalName})
if err != nil {
t.Fatalf("MCP.IsServerRunning(initial external) failed: %v", err)
}
if initial.Running {
t.Fatal("Expected external client to start as not running")
}

if _, err := session.RPC.MCP.RegisterExternalClient(t.Context(), &rpc.MCPRegisterExternalClientRequest{
ServerName: externalName,
Client: map[string]any{"id": externalName},
Transport: map[string]any{"kind": "in-process"},
Config: map[string]any{"command": "noop"},
}); err != nil {
t.Fatalf("MCP.RegisterExternalClient failed: %v", err)
}
waitForPortedMCPRunning(t, session, externalName, true)

if _, err := session.RPC.MCP.UnregisterExternalClient(t.Context(), &rpc.MCPUnregisterExternalClientRequest{ServerName: externalName}); err != nil {
t.Fatalf("MCP.UnregisterExternalClient failed: %v", err)
}
waitForPortedMCPRunning(t, session, externalName, false)
})

t.Run("should_reload_mcp_servers_with_config", func(t *testing.T) {
ctx.ConfigureForTest(t)
const hostServer = "rpc-lifecycle-reload-host"
session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, hostServer)})
defer session.Disconnect()
waitForPortedMCPServerStatus(t, session, hostServer, rpc.MCPServerStatusConnected)

result, err := session.RPC.MCP.ReloadWithConfig(t.Context(), &rpc.MCPReloadWithConfigRequest{Config: map[string]any{
"mcpServers": map[string]any{},
"disabledServers": []string{},
}})
if err != nil {
t.Fatalf("MCP.ReloadWithConfig failed: %v", err)
}
if result == nil {
t.Fatal("Expected non-nil reload result")
}
if result.FilteredServers == nil {
t.Fatal("Expected non-nil FilteredServers")
}
if len(result.FilteredServers) != 0 {
t.Fatalf("Expected no filtered servers, got %+v", result.FilteredServers)
}
})

t.Run("should_configure_github_mcp_server", func(t *testing.T) {
ctx.ConfigureForTest(t)
const hostServer = "rpc-lifecycle-configure-host"
session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, hostServer)})
defer session.Disconnect()
waitForPortedMCPServerStatus(t, session, hostServer, rpc.MCPServerStatusConnected)

result, err := session.RPC.MCP.ConfigureGitHub(t.Context(), &rpc.MCPConfigureGitHubRequest{AuthInfo: map[string]any{"type": "api-key"}})
if err != nil {
t.Fatalf("MCP.ConfigureGitHub failed: %v", err)
}
if result == nil {
t.Fatal("Expected non-nil configure result")
}
if result.Changed {
t.Fatal("Expected Changed=false")
}
})

t.Run("should_respond_to_mcp_oauth_request_without_pending_request", func(t *testing.T) {
ctx.ConfigureForTest(t)
const hostServer = "rpc-lifecycle-oauth-host"
session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, hostServer)})
defer session.Disconnect()
waitForPortedMCPServerStatus(t, session, hostServer, rpc.MCPServerStatusConnected)

result, err := session.RPC.MCP.Oauth().Respond(t.Context(), &rpc.MCPOauthRespondRequest{RequestID: "missing-" + randomHex(t)})
if err != nil {
t.Fatalf("MCP.Oauth.Respond failed: %v", err)
}
if result == nil {
t.Fatal("Expected non-nil OAuth respond result")
}
})
}

func waitForPortedMCPServerStatus(t *testing.T, session *copilot.Session, serverName string, expectedStatus rpc.MCPServerStatus) {
Expand Down
Loading
Loading