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
200 changes: 200 additions & 0 deletions dotnet/test/E2E/RpcMcpLifecycleE2ETests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using GitHub.Copilot.Rpc;
using Xunit;
using Xunit.Abstractions;

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.
/// </summary>
public class RpcMcpLifecycleE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
: E2ETestBase(fixture, "rpc_mcp_lifecycle", output)
{
[Fact]
public async Task Should_List_Tools_And_Report_Running_Status_For_Connected_Server()
{
const string serverName = "rpc-lifecycle-list-server";
await using var session = await CreateSessionAsync(new SessionConfig
{
McpServers = CreateTestMcpServers(serverName),
});
await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected);

var tools = await session.Rpc.Mcp.ListToolsAsync(serverName);
Assert.NotNull(tools.Tools);
Assert.NotEmpty(tools.Tools);
Assert.All(tools.Tools, tool => Assert.False(string.IsNullOrWhiteSpace(tool.Name)));

// A connected server reports running; a name that was never configured does not.
Assert.True((await session.Rpc.Mcp.IsServerRunningAsync(serverName)).Running);
Assert.False((await session.Rpc.Mcp.IsServerRunningAsync($"missing-{Guid.NewGuid():N}")).Running);
}

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

// The MCP host is initialized (a server is connected), but the requested server is not,
// so listTools reaches the runtime and fails with a domain error rather than "Unhandled method".
var ex = await Assert.ThrowsAnyAsync<Exception>(
() => session.Rpc.Mcp.ListToolsAsync($"missing-{Guid.NewGuid():N}"));
var message = ex.ToString();
AssertNotUnhandledMethod(message);
Assert.Contains("not connected", message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task Should_Stop_Running_Mcp_Server()
{
const string serverName = "rpc-lifecycle-stop-server";
await using var session = await CreateSessionAsync(new SessionConfig
{
McpServers = CreateTestMcpServers(serverName),
});
await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected);
Assert.True((await session.Rpc.Mcp.IsServerRunningAsync(serverName)).Running);

await session.Rpc.Mcp.StopServerAsync(serverName);

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,
timeout: TimeSpan.FromSeconds(60),
pollInterval: TimeSpan.FromMilliseconds(200),
timeoutMessage: $"{serverName} running={expectedRunning}");

private static void AssertNotUnhandledMethod(string message)
{
Assert.DoesNotContain("Unhandled method", message, StringComparison.OrdinalIgnoreCase);
}
}
160 changes: 160 additions & 0 deletions dotnet/test/E2E/RpcServerMiscE2ETests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using GitHub.Copilot;
using GitHub.Copilot.Rpc;
using Xunit;
using Xunit.Abstractions;

namespace GitHub.Copilot.Test.E2E;

/// <summary>
/// E2E coverage for the remaining miscellaneous server-scoped RPC methods that were previously
/// untested: user.settings.reload, agentRegistry.spawn, runtime.shutdown, sessions.open, and the
/// session-scoped session.extensions.sendAttachmentsToMessage.
///
/// Several of these are intentionally exercised at the wiring/guard boundary because the meaningful
/// "happy path" requires capabilities the SDK host does not expose (a registered agent-registry
/// delegate, an extension-owned connection). For those we assert the method reaches the runtime and
/// enforces its documented guard rather than failing as an unknown method.
/// </summary>
public class RpcServerMiscE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
: E2ETestBase(fixture, "rpc_server_misc", output)
{
[Fact]
public async Task Should_Reload_User_Settings()
{
await Client.StartAsync();

// Drops the runtime's in-memory user-settings cache so the next read observes disk. Returns
// no value; success is simply completing without error.
await Client.Rpc.User.Settings.ReloadAsync();
}

[Fact]
public async Task Should_Report_Agent_Registry_Spawn_Gate_Closed()
{
await Client.StartAsync();

// agentRegistry.spawn is gated off on the SDK host (no spawn delegate is registered). The
// call must still reach the runtime and be rejected by that gate, proving the method is
// wired rather than an unknown method.
var ex = await Assert.ThrowsAnyAsync<Exception>(
() => Client.Rpc.AgentRegistry.SpawnAsync(cwd: Path.GetTempPath()));

var message = ex.ToString();
Assert.DoesNotContain("Unhandled method", message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("agentRegistry.spawn", message, StringComparison.OrdinalIgnoreCase);
Assert.True(
message.Contains("not enabled", StringComparison.OrdinalIgnoreCase)
|| message.Contains("no delegate", StringComparison.OrdinalIgnoreCase),
message);
}

[Fact]
public async Task Should_Shut_Down_Owned_Runtime()
{
// runtime.shutdown must only ever target a dedicated, SDK-owned runtime — never the shared
// fixture client whose process backs every other test.
var client = Ctx.CreateClient();
await client.StartAsync();

try
{
// Confirm the runtime is live before shutting it down.
await client.Rpc.User.Settings.ReloadAsync();

await client.Rpc.Runtime.ShutdownAsync();

// After a graceful shutdown the runtime tears down and stops serving. Poll until a
// follow-up RPC fails rather than asserting on a single immediate call, which could race
// shutdown propagation across the connection.
await Harness.TestHelper.WaitForConditionAsync(
async () =>
{
try { await client.Rpc.User.Settings.ReloadAsync(); return false; }
catch { return true; }

Check notice

Code scanning / CodeQL

Generic catch clause Note test

Generic catch clause.
Comment thread
stephentoub marked this conversation as resolved.
},
timeout: TimeSpan.FromSeconds(15),
pollInterval: TimeSpan.FromMilliseconds(100),
timeoutMessage: "Runtime kept serving RPCs after a graceful shutdown.");
}
finally
{
try { await client.DisposeAsync(); }
catch { /* process is already gone after shutdown */ }

Check notice

Code scanning / CodeQL

Generic catch clause Note test

Generic catch clause.
Comment thread
stephentoub marked this conversation as resolved.
}
}

[Fact]
public async Task Should_Report_Not_Found_When_Opening_Session_Without_Context()
{
// sessions.open with no parameters asks the runtime to resume the last session for the
// (unspecified) context. A fresh runtime with its own empty COPILOT_HOME has no such
// session, so the documented "not_found" outcome is returned deterministically.
var (client, home) = await CreateIsolatedClientAsync();
try
{
var result = await client.Rpc.Sessions.OpenAsync();

Assert.Equal(SessionsOpenStatus.NotFound, result.Status);
Assert.Null(result.SessionId);
}
finally
{
try { await client.DisposeAsync(); } catch { /* best-effort */ }

Check notice

Code scanning / CodeQL

Generic catch clause Note test

Generic catch clause.
Comment thread
stephentoub marked this conversation as resolved.
TryDeleteDirectory(home);
}
}

[Fact]
public async Task Should_Reject_Send_Attachments_From_Non_Extension_Connection()
{
// session.extensions.sendAttachmentsToMessage may only be called over an extension-owned
// connection. A normal SDK session connection has no extensionId, so the runtime rejects the
// push — confirming the method is wired and enforces its ownership guard.
await using var session = await CreateSessionAsync();

var ex = await Assert.ThrowsAnyAsync<Exception>(
() => session.Rpc.Extensions.SendAttachmentsToMessageAsync(new List<PushAttachment>()));
var message = ex.ToString();
Assert.DoesNotContain("Unhandled method", message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("extension", message, StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Creates a started client backed by a throwaway COPILOT_HOME so its session store is empty and
/// independent of every other test and of the shared fixture client.
/// </summary>
private async Task<(CopilotClient Client, string Home)> CreateIsolatedClientAsync()
{
var home = Path.Combine(Path.GetTempPath(), "copilot-e2e-misc-home-" + Guid.NewGuid().ToString("N"));

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note test

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
Comment thread
stephentoub marked this conversation as resolved.
Directory.CreateDirectory(home);

var env = Ctx.GetEnvironment();
env["COPILOT_HOME"] = home;
env["GH_CONFIG_DIR"] = home;
env["XDG_CONFIG_HOME"] = home;
env["XDG_STATE_HOME"] = home;

var client = Ctx.CreateClient(options: new CopilotClientOptions { Environment = env });
await client.StartAsync();
return (client, home);
}

private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
catch
{
// Temp directories are reclaimed by the OS; ignore transient locks on cleanup.
}

Check notice

Code scanning / CodeQL

Generic catch clause Note test

Generic catch clause.
Comment thread
stephentoub marked this conversation as resolved.
}
}
Loading
Loading