Skip to content

Commit 12c93c6

Browse files
docs: add dual-mode auth example to validate AuthProvider decomposition
Demonstrates two auth setups through the same authProvider slot: MODE A (host-managed): Enclosing app owns the token. Minimal AuthProvider with { token, onUnauthorized } — onUnauthorized signals the host UI and throws instead of refreshing, since the host owns the token lifecycle. MODE B (user-configured): OAuth credentials supplied directly. Passes a ClientCredentialsProvider; transport adapts it to AuthProvider via adaptOAuthProvider (synthesizing token()/onUnauthorized()). Same connectAndList() caller code for both — the transport abstracts the difference. Validates the decomposition holds with zero branching in user code.
1 parent 0824dfb commit 12c93c6

File tree

1 file changed

+114
-0
lines changed

1 file changed

+114
-0
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Pressure-test example: two auth modes through the same `authProvider` slot.
5+
*
6+
* The decomposition is validated if both shapes plug in with zero branching
7+
* in the transport/caller code.
8+
*
9+
* MODE A — Host-managed auth (token managed by an enclosing application)
10+
* - Host app owns the token; the MCP client just reads it
11+
* - On 401, the client cannot refresh — it signals the UI and bails
12+
* - Minimal AuthProvider: `{ token, onUnauthorized }` with no OAuth machinery
13+
*
14+
* MODE B — User-configured auth (OAuth credentials supplied directly)
15+
* - User supplies OAuth credentials; the SDK runs the full flow
16+
* - On 401, the provider refreshes via handleOAuthUnauthorized (token refresh,
17+
* or redirect for interactive flows)
18+
* - Full OAuthClientProvider, adapted to AuthProvider by the transport
19+
*/
20+
21+
import type { AuthProvider } from '@modelcontextprotocol/client';
22+
import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
23+
24+
// --- Stubs for host-app integration points ---------------------------------
25+
26+
/** Whatever the host app uses to store session state (e.g., cookies, keychain, in-memory). */
27+
interface HostSessionStore {
28+
getMcpToken(): string | undefined;
29+
}
30+
31+
/** Whatever the host app uses to surface UI prompts. */
32+
interface HostUi {
33+
showReauthPrompt(message: string): void;
34+
}
35+
36+
// --- MODE A: Host-managed auth ---------------------------------------------
37+
38+
function createHostManagedTransport(serverUrl: URL, session: HostSessionStore, ui: HostUi): StreamableHTTPClientTransport {
39+
const authProvider: AuthProvider = {
40+
// Called before every request — just read whatever the host has.
41+
token: async () => session.getMcpToken(),
42+
43+
// Called on 401 — don't refresh (the host owns the token), signal the UI and bail.
44+
// The transport will retry once after this returns, so we throw to stop it:
45+
// the user needs to act before a retry makes sense.
46+
onUnauthorized: async () => {
47+
ui.showReauthPrompt('MCP connection lost — click to reconnect');
48+
throw new UnauthorizedError('Host token rejected — user action required');
49+
}
50+
};
51+
52+
return new StreamableHTTPClientTransport(serverUrl, { authProvider });
53+
}
54+
55+
// --- MODE B: User-configured OAuth -----------------------------------------
56+
57+
function createUserConfiguredTransport(serverUrl: URL, clientId: string, clientSecret: string): StreamableHTTPClientTransport {
58+
// Built-in OAuth provider — the transport adapts it to AuthProvider internally.
59+
// On 401, adaptOAuthProvider synthesizes onUnauthorized → handleOAuthUnauthorized,
60+
// which runs token refresh (or redirect for interactive flows).
61+
const authProvider = new ClientCredentialsProvider({ clientId, clientSecret });
62+
63+
return new StreamableHTTPClientTransport(serverUrl, { authProvider });
64+
}
65+
66+
// --- Same caller code for both modes ---------------------------------------
67+
68+
async function connectAndList(transport: StreamableHTTPClientTransport): Promise<void> {
69+
const client = new Client({ name: 'dual-mode-example', version: '1.0.0' }, { capabilities: {} });
70+
await client.connect(transport);
71+
72+
const tools = await client.listTools();
73+
console.log('Tools:', tools.tools.map(t => t.name).join(', ') || '(none)');
74+
75+
await transport.close();
76+
}
77+
78+
// --- Driver ----------------------------------------------------------------
79+
80+
async function main() {
81+
const serverUrl = new URL(process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp');
82+
const mode = process.argv[2] || 'host';
83+
84+
let transport: StreamableHTTPClientTransport;
85+
86+
if (mode === 'host') {
87+
// Simulate a host app with a session-stored token and a UI hook.
88+
const session: HostSessionStore = { getMcpToken: () => process.env.MCP_TOKEN };
89+
const ui: HostUi = { showReauthPrompt: msg => console.error(`[UI] ${msg}`) };
90+
transport = createHostManagedTransport(serverUrl, session, ui);
91+
} else if (mode === 'oauth') {
92+
const clientId = process.env.OAUTH_CLIENT_ID;
93+
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
94+
if (!clientId || !clientSecret) {
95+
console.error('OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET required for oauth mode');
96+
process.exit(1);
97+
}
98+
transport = createUserConfiguredTransport(serverUrl, clientId, clientSecret);
99+
} else {
100+
console.error(`Unknown mode: ${mode}. Use 'host' or 'oauth'.`);
101+
process.exit(1);
102+
}
103+
104+
// Same connect/list code regardless of mode — the transport abstracts the difference.
105+
await connectAndList(transport);
106+
}
107+
108+
try {
109+
await main();
110+
} catch (error) {
111+
console.error('Error:', error);
112+
// eslint-disable-next-line unicorn/no-process-exit
113+
process.exit(1);
114+
}

0 commit comments

Comments
 (0)