Skip to content

Commit 3cbf4e7

Browse files
stephentoubCopilot
andauthored
Harden MCP OAuth cancel E2E tests against create/interest race (#2029)
* Harden MCP OAuth cancel E2E tests against create/interest race The MCP OAuth "cancel" E2E tests sampled the host auth callback the instant the server reached `needs-auth`, which is racy: `session.create` kicks off the MCP connection, but the SDK only registers its `mcp.oauth_required` event interest after create returns. When the server's initial 401 wins that race, the runtime records `needs-auth` without invoking the host callback, so the callback observation was intermittently empty (e.g. the flaky C# CI leg in copilot-agent-runtime). Wait for the callback to be invoked (bounded, reusing each suite's existing wait-for-condition helper) instead of sampling it immediately. A later runtime auth retry fires the callback with the same `initial` reason, so the assertions stay valid. Applied uniformly to C#, Go, Python, Java, and Rust; the Go change also guards the observed request with a mutex to fix a latent data race. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7849882-a2a2-4587-a602-da7718889a8c * Use TaskCompletionSource for thread-safe callback wait in C# cancel test Addresses review feedback: the previous poll read observedRequest (written by the callback thread) from the test continuation without synchronization. Switch to the TaskCompletionSource pattern already used by the direct-RPC test in this file, so the callback result is handed off safely and awaited with a timeout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7849882-a2a2-4587-a602-da7718889a8c --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent c4dc3e9 commit 3cbf4e7

5 files changed

Lines changed: 83 additions & 15 deletions

File tree

dotnet/test/E2E/McpOAuthE2ETests.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,13 +204,13 @@ public async Task Should_Cancel_Pending_MCP_OAuth_Request()
204204
{
205205
await using var oauthServer = await OAuthMcpServer.StartAsync(ExpectedToken);
206206
var serverName = "oauth-cancelled-mcp";
207-
McpAuthContext? observedRequest = null;
207+
var authRequest = new TaskCompletionSource<McpAuthContext>(TaskCreationOptions.RunContinuationsAsynchronously);
208208

209209
await using var session = await CreateSessionAsync(new SessionConfig
210210
{
211211
OnMcpAuthRequest = request =>
212212
{
213-
observedRequest = request;
213+
authRequest.TrySetResult(request);
214214
return Task.FromResult<McpAuthResult?>(McpAuthResult.Cancel());
215215
},
216216
McpServers = new Dictionary<string, McpServerConfig>
@@ -225,9 +225,16 @@ public async Task Should_Cancel_Pending_MCP_OAuth_Request()
225225

226226
await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.NeedsAuth);
227227

228-
Assert.NotNull(observedRequest);
229-
Assert.NotEmpty(observedRequest!.RequestId);
230-
Assert.Equal(serverName, observedRequest!.ServerName);
228+
// The MCP connection is kicked off by session.create, but the SDK only registers its
229+
// `mcp.oauth_required` event interest once create returns. If the server's initial 401
230+
// wins that race, the runtime records `needs-auth` WITHOUT invoking the host callback,
231+
// so the callback fires only on a later auth retry (now that interest is registered),
232+
// with the same `Initial` reason. Await the callback rather than sampling it the instant
233+
// `needs-auth` first appears, which is what made this test flaky.
234+
var observedRequest = await authRequest.Task.WaitAsync(TimeSpan.FromSeconds(60));
235+
236+
Assert.NotEmpty(observedRequest.RequestId);
237+
Assert.Equal(serverName, observedRequest.ServerName);
231238
Assert.Equal(McpOauthRequestReason.Initial, observedRequest.Reason);
232239
}
233240

go/internal/e2e/mcp_oauth_e2e_test.go

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,17 @@ func TestMCPOAuthE2E(t *testing.T) {
197197
t.Run("cancel pending MCP OAuth request", func(t *testing.T) {
198198
baseURL := startOAuthMCPServer(t)
199199
serverName := "oauth-cancelled-mcp"
200+
var mu sync.Mutex
200201
var observedRequest copilot.MCPAuthRequest
202+
var observed bool
201203

202204
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
203205
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
204206
OnMCPAuthRequest: func(request copilot.MCPAuthRequest, _ copilot.MCPAuthInvocation) (*copilot.MCPAuthResult, error) {
207+
mu.Lock()
205208
observedRequest = request
209+
observed = true
210+
mu.Unlock()
206211
return copilot.MCPAuthResultCancelled(), nil
207212
},
208213
MCPServers: map[string]copilot.MCPServerConfig{
@@ -218,11 +223,35 @@ func TestMCPOAuthE2E(t *testing.T) {
218223
t.Cleanup(func() { session.Disconnect() })
219224

220225
waitForMCPServerStatus(t, session, serverName, rpc.MCPServerStatusNeedsAuth)
221-
if observedRequest.ServerName != serverName {
222-
t.Fatalf("Expected serverName %q, got %q", serverName, observedRequest.ServerName)
223-
}
224-
if observedRequest.Reason != copilot.MCPOauthRequestReasonInitial {
225-
t.Fatalf("Unexpected auth request reason: %q", observedRequest.Reason)
226+
227+
// The MCP connection is kicked off by session.create, but the SDK only registers its
228+
// `mcp.oauth_required` event interest once create returns. If the server's initial 401
229+
// wins that race, the runtime records `needs-auth` WITHOUT invoking the host callback,
230+
// so `observedRequest` is briefly unset even after `needs-auth` is observed. A later
231+
// auth retry (now that interest is registered) invokes the callback with the same
232+
// `Initial` reason. Wait for the callback rather than sampling it the instant
233+
// `needs-auth` first appears, which is what made this test flaky.
234+
var request copilot.MCPAuthRequest
235+
deadline := time.Now().Add(60 * time.Second)
236+
for {
237+
mu.Lock()
238+
got := observed
239+
request = observedRequest
240+
mu.Unlock()
241+
if got {
242+
break
243+
}
244+
if time.Now().After(deadline) {
245+
t.Fatalf("%s OAuth request did not reach the host callback", serverName)
246+
}
247+
time.Sleep(200 * time.Millisecond)
248+
}
249+
250+
if request.ServerName != serverName {
251+
t.Fatalf("Expected serverName %q, got %q", serverName, request.ServerName)
252+
}
253+
if request.Reason != copilot.MCPOauthRequestReasonInitial {
254+
t.Fatalf("Unexpected auth request reason: %q", request.Reason)
226255
}
227256
})
228257

java/src/test/java/com/github/copilot/McpOAuthE2ETest.java

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,12 +188,18 @@ void testShouldCancelPendingMcpOauthRequest() throws Exception {
188188
.setUrl(oauthServer.url() + "/mcp").setTools(List.of("*")))))
189189
.get()) {
190190
waitForMcpServerStatus(session, serverName, McpServerStatus.NEEDS_AUTH, observedRequest);
191-
}
192191

193-
var request = observedRequest.get();
194-
assertNotNull(request, "MCP auth handler should be invoked");
195-
assertEquals(serverName, request.serverName());
196-
assertEquals(McpOauthRequestReason.INITIAL, request.reason());
192+
// Race: session.create kicks off the MCP connection, but the SDK
193+
// registers its `mcp.oauth_required` interest only after create
194+
// returns. If the initial 401 wins, the runtime records
195+
// `needs-auth` without invoking the host callback. A later auth
196+
// retry (interest now registered) fires the callback with the same
197+
// INITIAL reason. Wait for the callback instead of sampling it the
198+
// instant `needs-auth` appears, which is what made this test flaky.
199+
var request = waitForAuthRequest(observedRequest);
200+
assertEquals(serverName, request.serverName());
201+
assertEquals(McpOauthRequestReason.INITIAL, request.reason());
202+
}
197203
}
198204
}
199205

python/e2e/test_mcp_oauth_e2e.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,20 @@ def on_mcp_auth_request(request, _invocation):
326326
) as session:
327327
await _wait_for_mcp_server_status(session, server_name, McpServerStatus.NEEDS_AUTH)
328328

329+
# The MCP connection is kicked off by session.create, but the SDK only registers
330+
# its `mcp.oauth_required` event interest once create returns. If the server's
331+
# initial 401 wins that race, the runtime records `needs-auth` WITHOUT invoking
332+
# the host callback, so `observed_request` is briefly None even after `needs-auth`
333+
# is observed. A later auth retry (now that interest is registered) invokes the
334+
# callback with the same `initial` reason. Wait for the callback rather than
335+
# sampling it the instant `needs-auth` first appears, which made this test flaky.
336+
await wait_for_condition(
337+
lambda: observed_request is not None,
338+
timeout=60.0,
339+
poll_interval=0.2,
340+
timeout_message=f"{server_name} OAuth request did not reach the host callback",
341+
)
342+
329343
assert observed_request is not None
330344
assert observed_request["serverName"] == server_name
331345
assert observed_request["reason"] == "initial"

rust/tests/e2e/mcp_oauth.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,18 @@ async fn should_cancel_pending_mcp_oauth_request() {
209209

210210
wait_for_mcp_server_status(&session, server_name, McpServerStatus::NeedsAuth).await;
211211

212+
// The MCP connection is kicked off by session.create, but the SDK only registers its
213+
// `mcp.oauth_required` event interest once create returns. If the server's initial 401
214+
// wins that race, the runtime records `needs-auth` WITHOUT invoking the host callback,
215+
// so `handler.request` is briefly `None` even after `needs-auth` is observed. A later
216+
// auth retry (now that interest is registered) invokes the callback with the same
217+
// `Initial` reason. Wait for the callback rather than sampling it the instant
218+
// `needs-auth` first appears, which is what made this test flaky.
219+
wait_for_condition("MCP OAuth request reaching the host callback", || async {
220+
handler.request.lock().is_some()
221+
})
222+
.await;
223+
212224
let request = handler
213225
.request
214226
.lock()

0 commit comments

Comments
 (0)