Skip to content

Commit 9389df8

Browse files
oratisclaude
andauthored
feat(mcp): OAuth 2.0 (authorization-code + PKCE) for http/sse servers (#122)
Adds the last MCP §3.3 capability: connect to MCP servers that require OAuth. The SDK drives the protocol; we supply an OAuthClientProvider (mcp/oauth.ts): - McpAuthStore — persists tokens / dynamic client registration / PKCE verifier to ~/.deepcode/mcp-auth/<server>.json (auto-refresh handled by the SDK). - startLoopbackReceiver — one-shot 127.0.0.1 server that captures the ?code=/state from the browser redirect (validates state, surfaces errors). - DeepCodeOAuthProvider — PKCE client metadata (token_endpoint_auth_method 'none', authorization_code + refresh_token grants), store-backed persistence, opens the system browser, invalidateCredentials by scope. Wiring (client.ts): opt-in via `mcpServers.<name>.oauth: true` (+ optional `oauthScopes`). connectMcpServer passes the provider as the transport authProvider; on the first `UnauthorizedError` it awaits the loopback code, calls transport.finishAuth(code), and reconnects. stdio servers ignore it. Tests (+9): McpAuthStore read/patch/clear-by-scope; loopback captures the code / rejects on error / state-mismatch; provider PKCE metadata, token+verifier persistence across instances, browser-open + missing-verifier error. Core 597. Note: the live token exchange is exercised by the SDK; end-to-end against a real provider is manual (no OAuth server in CI). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 984b930 commit 9389df8

6 files changed

Lines changed: 473 additions & 10 deletions

File tree

packages/core/src/config/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ export interface McpServerConfig {
5353
headers?: Record<string, string>;
5454
headersHelper?: string;
5555
alwaysLoad?: boolean;
56+
/**
57+
* Authenticate to an http/sse server via OAuth 2.0 (authorization-code +
58+
* PKCE, with dynamic client registration). On first connect this opens the
59+
* browser; tokens persist under ~/.deepcode/mcp-auth/<server>.json and
60+
* auto-refresh thereafter. Ignored for stdio servers.
61+
*/
62+
oauth?: boolean;
63+
/** OAuth scopes to request (space-joined into the authorization request). */
64+
oauthScopes?: string[];
5665
}
5766

5867
export interface StatusLineConfig {

packages/core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ export {
204204
getMcpPrompt,
205205
mcpPromptCommands,
206206
resolveMcpPromptInvocation,
207+
McpAuthStore,
208+
createMcpOAuthProvider,
209+
startLoopbackReceiver,
210+
mcpAuthPath,
207211
type McpClientHandle,
208212
type McpToolMeta,
209213
type McpResourceMeta,

packages/core/src/mcp/client.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
// MCP client — wraps @modelcontextprotocol/sdk. Supports stdio, Streamable HTTP,
2-
// and HTTP+SSE transports, with static + dynamic (headersHelper) auth headers.
2+
// and HTTP+SSE transports, with static + dynamic (headersHelper) auth headers
3+
// AND OAuth 2.0 (authorization-code + PKCE, browser flow) via `config.oauth`
4+
// (see ./oauth.ts). Tokens persist under ~/.deepcode/mcp-auth/ and auto-refresh.
35
// Spec: docs/DEVELOPMENT_PLAN.md §3.3
4-
//
5-
// NOTE: full OAuth (authorization-code browser flow via the SDK's authProvider)
6-
// is a separate follow-up — static bearer/headers + headersHelper cover the
7-
// common token-auth case today. The transports already accept an authProvider,
8-
// so wiring OAuth later is additive.
96

107
import { execFile } from 'node:child_process';
118
import { promisify } from 'node:util';
@@ -14,9 +11,14 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
1411
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
1512
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
1613
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
14+
import {
15+
UnauthorizedError,
16+
type OAuthClientProvider,
17+
} from '@modelcontextprotocol/sdk/client/auth.js';
1718
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
1819
import type { McpServerConfig } from '../config/types.js';
1920
import type { ToolDefinition, ToolHandler, ToolResult } from '../types.js';
21+
import { createMcpOAuthProvider, type DeepCodeOAuthProvider } from './oauth.js';
2022

2123
const execFileAsync = promisify(execFile);
2224

@@ -71,6 +73,10 @@ export interface ConnectMcpOpts {
7173
* servers know not to elicit.
7274
*/
7375
elicit?: McpElicitHandler;
76+
/** Override $HOME for OAuth token storage (tests). */
77+
home?: string;
78+
/** Diagnostics sink for the OAuth flow (browser-open prompt, etc.). */
79+
log?: (msg: string) => void;
7480
}
7581

7682
export interface McpClientHandle {
@@ -144,6 +150,7 @@ async function buildTransport(
144150
serverName: string,
145151
config: McpServerConfig,
146152
kind: McpTransportKind,
153+
authProvider?: OAuthClientProvider,
147154
): Promise<Transport> {
148155
if (kind === 'stdio') {
149156
if (!config.command) {
@@ -163,8 +170,13 @@ async function buildTransport(
163170
const headers = await resolveAuthHeaders(config);
164171
const requestInit: RequestInit = Object.keys(headers).length > 0 ? { headers } : {};
165172
return kind === 'sse'
166-
? new SSEClientTransport(url, { requestInit })
167-
: new StreamableHTTPClientTransport(url, { requestInit });
173+
? new SSEClientTransport(url, { requestInit, authProvider })
174+
: new StreamableHTTPClientTransport(url, { requestInit, authProvider });
175+
}
176+
177+
/** A transport that supports completing an interactive OAuth flow. */
178+
interface FinishableTransport extends Transport {
179+
finishAuth(code: string): Promise<void>;
168180
}
169181

170182
/**
@@ -183,7 +195,17 @@ export async function connectMcpServer(
183195
`MCP server "${serverName}" must specify a command (stdio) or a url (http/sse)`,
184196
);
185197
}
186-
const transport = await buildTransport(serverName, config, kind);
198+
// OAuth (http/sse only): start a loopback receiver + provider so the SDK can
199+
// run the authorization-code + PKCE flow. Tokens persist + auto-refresh.
200+
let oauthProvider: DeepCodeOAuthProvider | undefined;
201+
if (config.oauth && kind !== 'stdio') {
202+
oauthProvider = await createMcpOAuthProvider(serverName, {
203+
scopes: config.oauthScopes,
204+
home: opts.home,
205+
log: opts.log ?? ((m) => process.stderr.write(`[mcp:${serverName}] ${m}\n`)),
206+
});
207+
}
208+
const transport = await buildTransport(serverName, config, kind, oauthProvider);
187209
// Advertise elicitation support only when the host gave us a handler — an
188210
// empty `elicitation: {}` capability means form mode (SDK default).
189211
const capabilities = opts.elicit ? { elicitation: {} } : {};
@@ -200,7 +222,22 @@ export async function connectMcpServer(
200222
});
201223
});
202224
}
203-
await client.connect(transport);
225+
try {
226+
await client.connect(transport);
227+
} catch (err) {
228+
// First connect with no/expired token throws UnauthorizedError after opening
229+
// the browser. Wait for the loopback redirect, finish the exchange, retry.
230+
if (oauthProvider && err instanceof UnauthorizedError) {
231+
const code = await oauthProvider.waitForCode();
232+
await (transport as FinishableTransport).finishAuth(code);
233+
await client.connect(transport);
234+
} else {
235+
oauthProvider?.closeReceiver();
236+
throw err;
237+
}
238+
} finally {
239+
oauthProvider?.closeReceiver();
240+
}
204241

205242
// List the tools the server exposes
206243
const listed = await client.listTools();

packages/core/src/mcp/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,14 @@ export {
3838
type BuildMcpServerOpts,
3939
type ServeMcpStdioOpts,
4040
} from './serve.js';
41+
42+
export {
43+
McpAuthStore,
44+
DeepCodeOAuthProvider,
45+
createMcpOAuthProvider,
46+
startLoopbackReceiver,
47+
mcpAuthPath,
48+
openBrowser,
49+
type LoopbackReceiver,
50+
type OAuthProviderOpts,
51+
} from './oauth.js';
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { mkdtemp, rm } from 'node:fs/promises';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5+
import {
6+
createMcpOAuthProvider,
7+
mcpAuthPath,
8+
McpAuthStore,
9+
startLoopbackReceiver,
10+
} from './oauth.js';
11+
import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
12+
13+
const TOKENS: OAuthTokens = { access_token: 'at-123', token_type: 'Bearer', refresh_token: 'rt-9' };
14+
15+
describe('McpAuthStore', () => {
16+
let home: string;
17+
beforeEach(async () => {
18+
home = await mkdtemp(join(tmpdir(), 'dc-oauth-'));
19+
});
20+
afterEach(async () => {
21+
await rm(home, { recursive: true, force: true });
22+
});
23+
24+
it('path is under ~/.deepcode/mcp-auth and sanitizes the server name', () => {
25+
expect(mcpAuthPath('git/hub', home)).toBe(join(home, '.deepcode', 'mcp-auth', 'git_hub.json'));
26+
});
27+
28+
it('read() returns {} when absent; patch persists + merges', async () => {
29+
const s = new McpAuthStore('srv', home);
30+
expect(await s.read()).toEqual({});
31+
await s.patch({ tokens: TOKENS });
32+
await s.patch({ codeVerifier: 'verifier-abc' });
33+
const rec = await s.read();
34+
expect(rec.tokens).toEqual(TOKENS);
35+
expect(rec.codeVerifier).toBe('verifier-abc');
36+
});
37+
38+
it('clear(scope) drops only the targeted slice; clear(all) removes the file', async () => {
39+
const s = new McpAuthStore('srv', home);
40+
await s.patch({ tokens: TOKENS, codeVerifier: 'v' });
41+
await s.clear('tokens');
42+
expect((await s.read()).tokens).toBeUndefined();
43+
expect((await s.read()).codeVerifier).toBe('v');
44+
await s.clear('all');
45+
expect(await s.read()).toEqual({});
46+
});
47+
});
48+
49+
describe('startLoopbackReceiver', () => {
50+
it('captures the authorization code from the redirect', async () => {
51+
const r = await startLoopbackReceiver();
52+
try {
53+
expect(r.redirectUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/);
54+
const codeP = r.waitForCode();
55+
const res = await fetch(`${r.redirectUrl}?code=THE_CODE&state=s1`);
56+
expect(res.status).toBe(200);
57+
expect(await codeP).toBe('THE_CODE');
58+
} finally {
59+
r.close();
60+
}
61+
});
62+
63+
it('rejects on an error redirect', async () => {
64+
const r = await startLoopbackReceiver();
65+
try {
66+
// Attach the rejection expectation BEFORE triggering it, so the rejection
67+
// never lands without a handler (avoids an unhandled-rejection warning).
68+
const assertion = expect(r.waitForCode()).rejects.toThrow(/access_denied/);
69+
await fetch(`${r.redirectUrl}?error=access_denied`);
70+
await assertion;
71+
} finally {
72+
r.close();
73+
}
74+
});
75+
76+
it('rejects on a state mismatch', async () => {
77+
const r = await startLoopbackReceiver({ expectedState: 'expected' });
78+
try {
79+
const assertion = expect(r.waitForCode()).rejects.toThrow(/state mismatch/i);
80+
await fetch(`${r.redirectUrl}?code=x&state=wrong`);
81+
await assertion;
82+
} finally {
83+
r.close();
84+
}
85+
});
86+
});
87+
88+
describe('DeepCodeOAuthProvider', () => {
89+
let home: string;
90+
beforeEach(async () => {
91+
home = await mkdtemp(join(tmpdir(), 'dc-oauthp-'));
92+
});
93+
afterEach(async () => {
94+
await rm(home, { recursive: true, force: true });
95+
});
96+
97+
it('builds PKCE client metadata pointing at the loopback redirect', async () => {
98+
const p = await createMcpOAuthProvider('srv', { home, scopes: ['read', 'write'] });
99+
try {
100+
const meta = p.clientMetadata;
101+
expect(meta.redirect_uris[0]).toBe(p.redirectUrl);
102+
expect(meta.redirect_uris[0]).toMatch(/127\.0\.0\.1/);
103+
expect(meta.grant_types).toContain('authorization_code');
104+
expect(meta.response_types).toContain('code');
105+
expect(meta.token_endpoint_auth_method).toBe('none');
106+
expect(meta.scope).toBe('read write');
107+
} finally {
108+
p.closeReceiver();
109+
}
110+
});
111+
112+
it('persists tokens + verifier through the store', async () => {
113+
const p = await createMcpOAuthProvider('srv', { home });
114+
try {
115+
expect(await p.tokens()).toBeUndefined();
116+
await p.saveTokens(TOKENS);
117+
await p.saveCodeVerifier('pkce-verifier');
118+
expect(await p.tokens()).toEqual(TOKENS);
119+
expect(await p.codeVerifier()).toBe('pkce-verifier');
120+
// a fresh provider (new receiver) still reads persisted state
121+
const p2 = await createMcpOAuthProvider('srv', { home });
122+
try {
123+
expect(await p2.tokens()).toEqual(TOKENS);
124+
} finally {
125+
p2.closeReceiver();
126+
}
127+
} finally {
128+
p.closeReceiver();
129+
}
130+
});
131+
132+
it('codeVerifier() throws if none saved; redirectToAuthorization opens the URL', async () => {
133+
const opened: string[] = [];
134+
const p = await createMcpOAuthProvider('srv', { home, openBrowser: (u) => opened.push(u) });
135+
try {
136+
await expect(p.codeVerifier()).rejects.toThrow(/code_verifier/);
137+
await p.redirectToAuthorization(new URL('https://auth.example.com/authorize?x=1'));
138+
expect(opened).toEqual(['https://auth.example.com/authorize?x=1']);
139+
} finally {
140+
p.closeReceiver();
141+
}
142+
});
143+
});

0 commit comments

Comments
 (0)