Skip to content

add oauth support for http based mcp server (like copilot-cli) #1002

@plusplusoneplusplus

Description

@plusplusoneplusplus

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions