-
Notifications
You must be signed in to change notification settings - Fork 1.1k
add oauth support for http based mcp server (like copilot-cli) #1002
Copy link
Copy link
Open
Labels
Description
There's no oauth support from the copilot-sdk, below is the analysis from AI:
The full OAuth delegation flow (5 steps):
Step 1 — CLI session checks if SDK is listening for OAuth events:
// @github/copilot app.js @ ~11489428
let o = this.hasEventListeners("mcp.oauth_required")
? (a,l,c) => this.pendingRequests.requestMcpOAuth(a,l,c)
: void 0; // ← No listeners? onOAuthRequired = undefined
this.mcpHost = new CT(j, n, ..., o, ...);
Step 2 — HTTP MCP server returns 401, CLI tries OAuth:
// @github/copilot app.js @ ~11252358 (processHttpServer)
try {
await this.registry.startHttpMcpClient(e, o); // ← 401 here
} catch(s) {
if (Zfe(s)) { // Zfe = detects auth-required error
let a = await this.getAuthProvider(e, n); // ← Step 3
if (a) { await this.registry.startHttpMcpClient(e, o, a); return; }
}
throw s;
}
Step 3 — getAuthProvider calls onOAuthRequired:
// @github/copilot app.js @ ~11248342
getAuthProvider(e, n) {
if (!this.onOAuthRequired) return; // ← Returns undefined if no handler!
let r = n.oauthClientId ? {clientId: n.oauthClientId, ...} : void 0;
let o = await this.onOAuthRequired(e, n.url, r); // ← Calls Step 4
return o;
}
Step 4 — requestMcpOAuth emits event and waits for response:
// @github/copilot app.js @ ~11267096
async requestMcpOAuth(e, n, r) {
let o = uuid();
let {promise, resolve} = Promise.withResolvers();
this.mcpOAuthRequests.set(o, {resolve});
this.emitEphemeral("mcp.oauth_required", { // ← Emits to SDK
requestId: o, serverName: e, serverUrl: n, staticClientConfig: r
});
return promise; // ← BLOCKS until respondToMcpOAuth() is called
}
Step 5 — SDK session.ts event handler — mcp.oauth_required is NOT handled:
// C:/src/copilot-sdk/nodejs/src/session.ts lines 380-448
private _onEvent(event: SessionEvent): void {
if (event.type === "tool.call") { ... }
else if (event.type === "permission.requested") { ... } // ✅ handled
else if (event.type === "elicitation.requested") { ... } // ✅ handled
else if (event.type === "capabilities.changed") { ... }
// ❌ NO "mcp.oauth_required" case — event is silently ignored
}
So the promise at Step 4 hangs forever → getAuthProvider never returns → processHttpServer catch block throws → server marked as "failed".
BUT — critically, in Step 1, hasEventListeners("mcp.oauth_required") returns false because the SDK never registers a listener. So onOAuthRequired is undefined, and Step 3 returns undefined immediately (no auth provider). The 401 error is then re-thrown, and the server fails without even attempting OAuth.
Here's the full OAuth flow as implemented in the Copilot CLI. The wT class (app.js @ ~11239897) is the OAuth authenticator:
CLI OAuth Flow (readable form):📋
class McpOAuthAuthenticator {
constructor(store) { this.store = store; }
async authenticate(serverUrl, options) {
const { onStatusChange, signal, staticClientConfig } = options;
// 1. Check for existing valid tokens
onStatusChange("checking_existing_tokens");
if (await this.hasValidTokens(serverUrl)) {
return this.createProvider(serverUrl, redirectUri, staticClientConfig);
}
// 2. Try refresh token if available
const tokens = await this.store.getTokens(serverUrl);
if (tokens?.refreshToken) {
onStatusChange("refreshing_tokens");
if (await this.tryRefreshTokens(serverUrl, staticClientConfig)) {
return this.createProvider(serverUrl, redirectUri, staticClientConfig);
}
}
// 3. Discover OAuth metadata from server URL
onStatusChange("discovering_metadata");
const resourceMetadata = await discoverResourceMetadata(serverUrl);
const { metadata } = await discoverAuthServerMetadata(authServerUrl);
// 4. Start local callback server on 127.0.0.1
onStatusChange("starting_callback_server");
const callbackServer = new CallbackServer();
const { callbackUrl } = await callbackServer.start();
// 5. Register OAuth client (dynamic registration) if needed
onStatusChange("checking_registration");
let clientInfo = await provider.clientInformation();
if (!clientInfo) {
onStatusChange("registering_client");
clientInfo = await registerClient(authServerUrl, { metadata, clientMetadata });
}
// 6. Build authorization URL and open browser
const { authorizationUrl, codeVerifier } = await buildAuthorizationUrl(
authServerUrl, { metadata, clientInformation, redirectUrl, state, scope }
);
onStatusChange("waiting_for_browser");
await openBrowser(authorizationUrl.toString()); // ← Opens browser!
// 7. Wait for callback with authorization code
onStatusChange("waiting_for_authorization");
const authCode = await callbackServer.waitForCallback(state, timeoutMs);
// 8. Exchange code for tokens
onStatusChange("exchanging_code");
const tokens = await exchangeCodeForTokens(
authServerUrl, { metadata, clientInformation, authorizationCode: authCode,
codeVerifier, redirectUri }
);
await provider.saveTokens(tokens);
return provider; // ← Returns auth provider for MCP client
}
}
This is a standard OAuth 2.0 Authorization Code + PKCE flow:
Check cached tokens → try refresh → discover OAuth metadata
Start local HTTP server on 127.0.0.1 for the callback
Register as an OAuth client (dynamic client registration)
Open a browser for user to authorize
Wait for redirect callback with auth code
Exchange code for access + refresh tokens
Return auth provider that attaches the token to MCP requests
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
Type
Fields
Give feedbackNo fields configured for issues without a type.