Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
8 changed files with 69131 additions and 9001 deletions
28
examples/oauth/README.md
Normal file
28
examples/oauth/README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# oauth
|
||||
|
||||
The **authorization-code** OAuth grant — the interactive "user signs in and approves" flow — against an in-repo OAuth-protected MCP server.
|
||||
|
||||
- `server.ts` — `setupAuthServer` (the better-auth/OIDC demo Authorization Server from `@mcp-examples/shared`) on `:PORT+1`, and a `createMcpHandler` Resource Server behind `requireBearerAuth({ verifier: demoTokenVerifier })` on `:PORT/mcp`, advertising the AS via
|
||||
`createProtectedResourceMetadataRouter` (RFC 9728). DEMO ONLY — the AS auto-signs-in a fixed user, and with `OAUTH_DEMO_AUTO_CONSENT=1` it also auto-approves the consent screen.
|
||||
- `client.ts` — **CI-runnable headless flow.** Drives the same SDK auth machinery as the browser client, but instead of `open()`ing the authorization URL it follows the 302 chain itself with `fetch(..., { redirect: 'manual' })` (the demo AS's auto-sign-in + auto-consent collapse
|
||||
every interactive step into a redirect), reads the callback query off the final `Location` header, calls `transport.finishAuth(url.searchParams)` (so the SDK reads `code` + `iss` per RFC 9207), reconnects, and asserts `ctx.authInfo` round-trips. This is what `pnpm run:examples` runs.
|
||||
- `simpleOAuthClient.ts` + `simpleOAuthClientProvider.ts` — **manual real-browser flow.** Full authorization-code flow against any OAuth-protected MCP server: opens the browser, runs a local callback server on `:8090`, exchanges the code, then drops into a small `list`/`call`
|
||||
REPL. Run this when you want to see the consent page.
|
||||
- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`.
|
||||
- `simpleTokenProvider.ts` — the minimal `AuthProvider` (just `token()`) for externally-managed bearer tokens.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# headless (what CI does) — terminal 1: AS (:3001) + protected RS (:3000/mcp), auto-consent on
|
||||
OAUTH_DEMO_AUTO_CONSENT=1 pnpm --filter @mcp-examples/oauth server
|
||||
# terminal 2: follows the 302 chain, exchanges the code, asserts whoami
|
||||
pnpm --filter @mcp-examples/oauth client -- --http http://127.0.0.1:3000/mcp
|
||||
|
||||
# manual real-browser flow — terminal 1: same server (auto-consent optional)
|
||||
pnpm --filter @mcp-examples/oauth server
|
||||
# terminal 2: opens a browser to the demo AS, callback server on :8090, then a list/call REPL
|
||||
pnpm --filter @mcp-examples/oauth client:browser
|
||||
```
|
||||
|
||||
For the headless bearer-token resource-server case see `../bearer-auth/`; for the machine-to-machine `client_credentials` grant see `../oauth-client-credentials/`; for URL-mode elicitation see `../elicitation/`; for the interactive readline playground see `../repl/`.
|
||||
159
examples/oauth/client.ts
Normal file
159
examples/oauth/client.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Self-verifying **authorization-code** OAuth client — the CI-runnable headless
|
||||
* twin of {@link ./simpleOAuthClient.ts}.
|
||||
*
|
||||
* `simpleOAuthClient.ts` is the manual real-user example: it `open()`s the
|
||||
* authorization URL in a real browser, the user signs in and clicks **Approve**
|
||||
* on the consent screen, the browser is redirected to a local callback server,
|
||||
* and the example reads the `code` off that callback. THIS file drives the
|
||||
* exact same SDK auth machinery but follows the redirect chain itself — which
|
||||
* only works because the demo Authorization Server is started with
|
||||
* `OAUTH_DEMO_AUTO_CONSENT=1` so its `/sign-in` page auto-signs-in a fixed
|
||||
* demo user and its `/authorize` endpoint auto-consents (skips the Approve
|
||||
* screen and 302s straight back to `redirect_uri?code=...`).
|
||||
*
|
||||
* Flow:
|
||||
* 1. Connect with an {@linkcode InMemoryOAuthClientProvider} → 401 → SDK auth
|
||||
* driver discovers PRM → AS metadata → registers a client (DCR) → builds
|
||||
* the authorization URL → calls our `redirectToAuthorization` hook (we
|
||||
* capture the URL) → throws {@linkcode UnauthorizedError}.
|
||||
* 2. Follow that URL with `fetch(..., { redirect: 'manual' })`, forwarding
|
||||
* `Set-Cookie` → `Cookie` across hops, until the AS 302s to our
|
||||
* `redirect_uri` with `?code=...`. No callback server is bound — the code
|
||||
* is read straight off the `Location` header.
|
||||
* 3. `transport.finishAuth(code)` → SDK exchanges the code (+ PKCE verifier)
|
||||
* for tokens at the AS `/token` endpoint and saves them on the provider.
|
||||
* 4. Reconnect with a fresh transport (same provider, now holding tokens) →
|
||||
* Bearer header → 200. Call `whoami` and assert `ctx.authInfo` round-trips.
|
||||
*
|
||||
* HTTP-only (the OAuth dance is HTTP redirects + Bearer headers), so the
|
||||
* canonical stdio branch does not apply.
|
||||
*/
|
||||
import { check, parseExampleArgs } from '@mcp-examples/shared';
|
||||
import type { OAuthClientMetadata } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
|
||||
|
||||
import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider';
|
||||
|
||||
// The redirect target the AS will 302 back to with `?code=...`. In the real
|
||||
// browser flow (`simpleOAuthClient.ts`) a tiny HTTP server listens here so the
|
||||
// browser has somewhere to land; headlessly we never bind it — we read the
|
||||
// `code` off the final 302's `Location` header instead.
|
||||
const CALLBACK_URL = 'http://127.0.0.1:8090/callback';
|
||||
|
||||
/**
|
||||
* Follow an authorization URL through the demo AS's redirect chain
|
||||
* (authorize → /sign-in → authorize → redirect_uri?code=...) and return the
|
||||
* `code`. This is the headless stand-in for "the user's browser navigates the
|
||||
* login + consent pages": cookies are forwarded hop-to-hop the way a browser
|
||||
* would, and the demo AS's auto-sign-in + `autoConsent` collapse every
|
||||
* interactive step into a 302.
|
||||
*/
|
||||
async function followAuthorizationRedirects(authorizationUrl: URL): Promise<URLSearchParams> {
|
||||
let next = authorizationUrl.href;
|
||||
// Crude cookie jar — enough for a single-origin demo AS.
|
||||
const jar = new Map<string, string>();
|
||||
for (let hop = 0; hop < 10; hop++) {
|
||||
const cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
// In a real client this is `open(authorizationUrl)` — we follow the redirect
|
||||
// chain headlessly because the demo AS auto-signs-in and auto-approves.
|
||||
const res = await fetch(next, { redirect: 'manual', headers: cookie ? { cookie } : {} });
|
||||
for (const sc of res.headers.getSetCookie()) {
|
||||
const pair = sc.split(';', 1)[0] ?? '';
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
||||
}
|
||||
const location = res.headers.get('location');
|
||||
if (!location || res.status < 300 || res.status >= 400) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`expected a redirect at hop ${hop} (${next}); got ${res.status}\n${body.slice(0, 400)}`);
|
||||
}
|
||||
const resolved = new globalThis.URL(location, next);
|
||||
// In a real deployment, the browser would render the consent page here and
|
||||
// the user would click Approve; the demo AS's `autoConsent` flag simulates
|
||||
// that approval, so the chain ends in a 302 straight to `redirect_uri`.
|
||||
if (resolved.href.startsWith(CALLBACK_URL)) {
|
||||
const code = resolved.searchParams.get('code');
|
||||
const error = resolved.searchParams.get('error');
|
||||
if (error) throw new Error(`AS returned error on callback: ${error} ${resolved.searchParams.get('error_description') ?? ''}`);
|
||||
if (!code) throw new Error(`callback redirect missing ?code: ${resolved.href}`);
|
||||
return resolved.searchParams;
|
||||
}
|
||||
next = resolved.href;
|
||||
}
|
||||
throw new Error('authorization redirect chain did not terminate at the callback within 10 hops');
|
||||
}
|
||||
|
||||
const { url, era } = parseExampleArgs();
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'oauth-headless-client', version: '1.0.0' },
|
||||
{ versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } }
|
||||
);
|
||||
|
||||
// ---- 1. Kick off the SDK auth driver --------------------------------------
|
||||
// The SDK builds the authorization URL and hands it to
|
||||
// `redirectToAuthorization` — in `simpleOAuthClient.ts` that opens a browser;
|
||||
// here we just capture it.
|
||||
let capturedAuthorizationUrl: URL | undefined;
|
||||
const clientMetadata: OAuthClientMetadata = {
|
||||
client_name: 'Headless OAuth MCP Client (CI)',
|
||||
redirect_uris: [CALLBACK_URL],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
application_type: 'native',
|
||||
token_endpoint_auth_method: 'client_secret_post'
|
||||
};
|
||||
const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, authUrl => {
|
||||
capturedAuthorizationUrl = authUrl;
|
||||
});
|
||||
|
||||
const firstTransport = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider });
|
||||
let challenged = false;
|
||||
try {
|
||||
await client.connect(firstTransport);
|
||||
} catch (error) {
|
||||
// Under `--legacy` the transport surfaces `UnauthorizedError` directly;
|
||||
// under `mode: 'auto'` the version-negotiation probe is what got 401'd
|
||||
// and wraps it in an EraNegotiationFailed `SdkError` whose `data.cause`
|
||||
// is the original `UnauthorizedError`. Either way the auth driver has
|
||||
// already run by the time we land here — DCR done, auth URL captured.
|
||||
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
|
||||
if (!(root instanceof UnauthorizedError)) throw error;
|
||||
challenged = true;
|
||||
}
|
||||
check.ok(challenged, 'first connect must 401 and throw UnauthorizedError');
|
||||
check.ok(capturedAuthorizationUrl, 'SDK auth driver should have produced an authorization URL');
|
||||
check.ok(provider.clientInformation()?.client_id, 'dynamic client registration should have run');
|
||||
|
||||
// ---- 2. Follow the authorization URL headlessly ---------------------------
|
||||
// (the browser-and-user stand-in; see `followAuthorizationRedirects`).
|
||||
const callbackParams = await followAuthorizationRedirects(capturedAuthorizationUrl!);
|
||||
|
||||
// ---- 3. Exchange the code for tokens --------------------------------------
|
||||
// In the browser flow the local callback server hands the redirect query to
|
||||
// `transport.finishAuth`; we read it off the final `Location` header instead.
|
||||
// The SDK reads `code` + `iss` (RFC 9207) from the params, validates `iss`
|
||||
// against the recorded issuer, then POSTs `grant_type=authorization_code`
|
||||
// (+ PKCE `code_verifier`) to the AS `/token` endpoint and saves the tokens
|
||||
// on `provider`.
|
||||
await firstTransport.finishAuth(callbackParams);
|
||||
const tokens = provider.tokens();
|
||||
check.ok(tokens?.access_token, 'token exchange should have yielded an access_token');
|
||||
check.equal(tokens?.token_type, 'Bearer');
|
||||
|
||||
// ---- 4. Reconnect with the now-populated provider -------------------------
|
||||
// A fresh transport reads the saved Bearer token from `provider` and the
|
||||
// protected `/mcp` endpoint lets us through.
|
||||
const transport = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider });
|
||||
await client.connect(transport);
|
||||
|
||||
const result = await client.callTool({ name: 'whoami', arguments: {} });
|
||||
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '';
|
||||
const seen = JSON.parse(text) as { clientId?: string; scopes?: string[] };
|
||||
// `ctx.authInfo` round-trips: the clientId the AS minted at DCR time is the
|
||||
// one the Resource Server's verifier sees on the Bearer token.
|
||||
check.equal(seen.clientId, provider.clientInformation()?.client_id, 'ctx.authInfo.clientId round-trips the DCR client_id');
|
||||
check.ok(seen.scopes?.includes('openid'), 'ctx.authInfo.scopes carries a granted scope');
|
||||
|
||||
await client.close();
|
||||
114
examples/oauth/dualModeAuth.ts
Normal file
114
examples/oauth/dualModeAuth.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Two auth patterns through the same `authProvider` option.
|
||||
*
|
||||
* The transport accepts either a minimal `AuthProvider` (just `token()` +
|
||||
* optional `onUnauthorized()`) or a full `OAuthClientProvider`, adapting
|
||||
* the latter automatically. This means your connect/call code is identical
|
||||
* regardless of which pattern fits your deployment.
|
||||
*
|
||||
* HOST-MANAGED — token lives in an enclosing app
|
||||
* The app fetches and stores tokens; the MCP client just reads them.
|
||||
* On 401, there is nothing to refresh — signal the UI and throw so the
|
||||
* user can re-authenticate through the host's flow.
|
||||
*
|
||||
* USER-CONFIGURED — OAuth credentials supplied directly
|
||||
* Pass a built-in or custom OAuthClientProvider. The transport handles
|
||||
* the full OAuth flow: token refresh on 401, or redirect for interactive
|
||||
* authorization.
|
||||
*/
|
||||
|
||||
import type { AuthProvider } from '@modelcontextprotocol/client';
|
||||
import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
|
||||
|
||||
// --- Stubs for host-app integration points ---------------------------------
|
||||
|
||||
/** Whatever the host app uses to store session state (e.g., cookies, keychain, in-memory). */
|
||||
interface HostSessionStore {
|
||||
getMcpToken(): string | undefined;
|
||||
}
|
||||
|
||||
/** Whatever the host app uses to surface UI prompts. */
|
||||
interface HostUi {
|
||||
showReauthPrompt(message: string): void;
|
||||
}
|
||||
|
||||
// --- MODE A: Host-managed auth ---------------------------------------------
|
||||
|
||||
function createHostManagedTransport(serverUrl: URL, session: HostSessionStore, ui: HostUi): StreamableHTTPClientTransport {
|
||||
const authProvider: AuthProvider = {
|
||||
// Called before every request — just read whatever the host has.
|
||||
token: async () => session.getMcpToken(),
|
||||
|
||||
// Called on 401 — don't refresh (the host owns the token), signal the UI and bail.
|
||||
// The transport will retry once after this returns, so we throw to stop it:
|
||||
// the user needs to act before a retry makes sense.
|
||||
onUnauthorized: async () => {
|
||||
ui.showReauthPrompt('MCP connection lost — click to reconnect');
|
||||
throw new UnauthorizedError('Host token rejected — user action required');
|
||||
}
|
||||
};
|
||||
|
||||
return new StreamableHTTPClientTransport(serverUrl, { authProvider });
|
||||
}
|
||||
|
||||
// --- MODE B: User-configured OAuth -----------------------------------------
|
||||
|
||||
function createUserConfiguredTransport(serverUrl: URL, clientId: string, clientSecret: string): StreamableHTTPClientTransport {
|
||||
// Built-in OAuth provider — the transport adapts it to AuthProvider internally.
|
||||
// On 401, adaptOAuthProvider synthesizes onUnauthorized → handleOAuthUnauthorized,
|
||||
// which runs token refresh (or redirect for interactive flows).
|
||||
const authProvider = new ClientCredentialsProvider({ clientId, clientSecret });
|
||||
|
||||
return new StreamableHTTPClientTransport(serverUrl, { authProvider });
|
||||
}
|
||||
|
||||
// --- Same caller code for both modes ---------------------------------------
|
||||
|
||||
async function connectAndList(transport: StreamableHTTPClientTransport): Promise<void> {
|
||||
const client = new Client({ name: 'dual-mode-example', version: '1.0.0' }, { capabilities: {} });
|
||||
await client.connect(transport);
|
||||
|
||||
const tools = await client.listTools();
|
||||
console.log('Tools:', tools.tools.map(t => t.name).join(', ') || '(none)');
|
||||
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
// --- Driver ----------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const serverUrl = new URL(process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000/mcp');
|
||||
const mode = process.argv[2] || 'host';
|
||||
|
||||
let transport: StreamableHTTPClientTransport;
|
||||
|
||||
if (mode === 'host') {
|
||||
// Simulate a host app with a session-stored token and a UI hook.
|
||||
const session: HostSessionStore = { getMcpToken: () => process.env.MCP_TOKEN };
|
||||
const ui: HostUi = { showReauthPrompt: msg => console.error(`[UI] ${msg}`) };
|
||||
transport = createHostManagedTransport(serverUrl, session, ui);
|
||||
} else if (mode === 'oauth') {
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
console.error('OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET required for oauth mode');
|
||||
process.exit(1);
|
||||
}
|
||||
transport = createUserConfiguredTransport(serverUrl, clientId, clientSecret);
|
||||
} else {
|
||||
console.error(`Unknown mode: ${mode}. Use 'host' or 'oauth'.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Same connect/list code regardless of mode — the transport abstracts the difference.
|
||||
await connectAndList(transport);
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
35
examples/oauth/package.json
Normal file
35
examples/oauth/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@mcp-examples/oauth",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"server": "tsx server.ts",
|
||||
"client": "tsx client.ts",
|
||||
"client:browser": "tsx simpleOAuthClient.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mcp-examples/shared": "workspace:*",
|
||||
"@modelcontextprotocol/client": "workspace:*",
|
||||
"@modelcontextprotocol/express": "workspace:*",
|
||||
"@modelcontextprotocol/node": "workspace:*",
|
||||
"@modelcontextprotocol/server": "workspace:*",
|
||||
"cors": "catalog:runtimeServerOnly",
|
||||
"open": "^11.0.0",
|
||||
"zod": "catalog:runtimeShared"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "catalog:devTools",
|
||||
"tsx": "catalog:devTools"
|
||||
},
|
||||
"example": {
|
||||
"transports": [
|
||||
"http"
|
||||
],
|
||||
"era": "dual",
|
||||
"path": "/mcp",
|
||||
"env": {
|
||||
"OAUTH_DEMO_AUTO_CONSENT": "1"
|
||||
},
|
||||
"//": "client.ts drives the full authorization-code flow headlessly because OAUTH_DEMO_AUTO_CONSENT=1 makes the demo AS auto-sign-in + auto-approve, collapsing the browser dance into a 302 chain. simpleOAuthClient.ts is the manual real-browser flow — run via `pnpm client:browser`."
|
||||
}
|
||||
}
|
||||
89
examples/oauth/server.ts
Normal file
89
examples/oauth/server.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* In-repo OAuth-protected MCP server for the **authorization-code** flow — the
|
||||
* demo Resource Server that {@link ./client.ts} (headless, CI) and
|
||||
* {@link ./simpleOAuthClient.ts} (manual, real browser) authenticate against.
|
||||
*
|
||||
* One process, two listeners on adjacent ports:
|
||||
*
|
||||
* - `:PORT+1` — the demo **Authorization Server** (`setupAuthServer` from
|
||||
* `@mcp-examples/shared`, backed by better-auth's OIDC plugin). It
|
||||
* implements the `authorization_code` grant only and auto-signs-in a fixed
|
||||
* demo user. With `OAUTH_DEMO_AUTO_CONSENT=1` it also **auto-consents** —
|
||||
* the `/authorize` endpoint skips the consent UI and 302s straight back to
|
||||
* `redirect_uri?code=...`, so the whole browser dance becomes a chain of
|
||||
* redirects a headless client can follow.
|
||||
* - `:PORT` — the MCP **Resource Server**: `createMcpHandler` behind
|
||||
* `requireBearerAuth({ verifier: demoTokenVerifier })`, advertising the AS
|
||||
* via `createProtectedResourceMetadataRouter` (RFC 9728) so the client's
|
||||
* discovery from a `401` `WWW-Authenticate` challenge works.
|
||||
*
|
||||
* DEMO ONLY — NOT FOR PRODUCTION. The demo AS auto-approves a fixed user; CORS
|
||||
* allows every origin; tokens are validated in-process against the same demo
|
||||
* AS instance.
|
||||
*
|
||||
* HTTP-only (Bearer auth has no stdio equivalent), so the canonical
|
||||
* `if (transport === 'stdio')` branch does not apply.
|
||||
*/
|
||||
import { parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared/auth';
|
||||
import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express';
|
||||
import { toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import type { McpRequestContext } from '@modelcontextprotocol/server';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import cors from 'cors';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
function buildServer(ctx: McpRequestContext): McpServer {
|
||||
const server = new McpServer({ name: 'oauth-protected-example', version: '1.0.0' });
|
||||
server.registerTool(
|
||||
'whoami',
|
||||
{ description: 'Returns the authenticated subject and granted scopes.', inputSchema: z.object({}) },
|
||||
async () => ({
|
||||
content: [{ type: 'text', text: JSON.stringify({ clientId: ctx.authInfo?.clientId, scopes: ctx.authInfo?.scopes }) }]
|
||||
})
|
||||
);
|
||||
return server;
|
||||
}
|
||||
|
||||
const { port } = parseExampleArgs();
|
||||
const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : port + 1;
|
||||
// 127.0.0.1 (not `localhost`) so the PRM `resource` value matches the URL the
|
||||
// runner passes the client byte-for-byte — the SDK auth driver enforces that.
|
||||
const mcpServerUrl = new URL(`http://127.0.0.1:${port}/mcp`);
|
||||
const authServerUrl = new URL(`http://127.0.0.1:${AUTH_PORT}`);
|
||||
|
||||
// ---- Authorization Server (better-auth OIDC; authorization_code only) ----
|
||||
// `autoConsent` is the demo-only switch that turns the consent screen into an
|
||||
// immediate 302 — set by the runner so `./client.ts` can run without a browser.
|
||||
setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, autoConsent: process.env.OAUTH_DEMO_AUTO_CONSENT === '1' });
|
||||
|
||||
// ---- Resource Server (MCP) ----
|
||||
const handler = createMcpHandler(buildServer);
|
||||
|
||||
const app = createMcpExpressApp();
|
||||
// DEMO ONLY — restrict `origin` in production. `exposedHeaders` lists the
|
||||
// response headers a browser-based MCP client must be able to read.
|
||||
app.use(
|
||||
cors({
|
||||
origin: '*',
|
||||
exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate', 'Last-Event-Id', 'Mcp-Protocol-Version']
|
||||
})
|
||||
);
|
||||
// RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource/mcp
|
||||
// — the client discovers the AS from the 401 challenge → this route → AS metadata.
|
||||
app.use(createProtectedResourceMetadataRouter('/mcp'));
|
||||
|
||||
const auth = requireBearerAuth({
|
||||
verifier: demoTokenVerifier,
|
||||
requiredScopes: [],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
});
|
||||
// `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it
|
||||
// to the factory as `ctx.authInfo`.
|
||||
const node = toNodeHandler(handler);
|
||||
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));
|
||||
|
||||
app.listen(port, () => {
|
||||
console.error(`OAuth-protected MCP server listening on ${mcpServerUrl.href}`);
|
||||
console.error(` Protected Resource Metadata: http://127.0.0.1:${port}/.well-known/oauth-protected-resource/mcp`);
|
||||
});
|
||||
426
examples/oauth/simpleOAuthClient.ts
Normal file
426
examples/oauth/simpleOAuthClient.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
import type { ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
|
||||
import open from 'open';
|
||||
|
||||
import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider';
|
||||
|
||||
// Configuration
|
||||
const DEFAULT_SERVER_URL = 'http://127.0.0.1:3000/mcp';
|
||||
const CALLBACK_PORT = 8090; // Use different port than auth server (3001)
|
||||
const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;
|
||||
|
||||
/** Minimal HTML escaper for any user/query-derived value interpolated into an HTML response. */
|
||||
function escHtml(s: string): string {
|
||||
return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive MCP client with OAuth authentication
|
||||
* Demonstrates the complete OAuth flow with browser-based authorization
|
||||
*/
|
||||
class InteractiveOAuthClient {
|
||||
private client: Client | null = null;
|
||||
private readonly rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
constructor(
|
||||
private serverUrl: string,
|
||||
private clientMetadataUrl?: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Prompts user for input via readline
|
||||
*/
|
||||
private async question(query: string): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
this.rl.question(query, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the authorization URL in the user's default browser
|
||||
*/
|
||||
private static readonly ALLOWED_SCHEMES = new Set(['http:', 'https:']);
|
||||
|
||||
private async openBrowser(url: string): Promise<void> {
|
||||
console.log(`🌐 Opening browser for authorization: ${url}`);
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!InteractiveOAuthClient.ALLOWED_SCHEMES.has(parsed.protocol)) {
|
||||
console.error(`Refusing to open URL with unsupported scheme '${parsed.protocol}': ${url}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.error(`Invalid URL: ${url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await open(url);
|
||||
} catch {
|
||||
console.log(`Please manually open: ${url}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Example OAuth callback handler - in production, use a more robust approach
|
||||
* for handling callbacks and storing tokens
|
||||
*/
|
||||
/**
|
||||
* Starts a temporary HTTP server to receive the OAuth callback
|
||||
*/
|
||||
private async waitForOAuthCallback(): Promise<URLSearchParams> {
|
||||
return new Promise<URLSearchParams>((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
// Ignore favicon requests
|
||||
if (req.url === '/favicon.ico') {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📥 Received callback: ${req.url}`);
|
||||
const parsedUrl = new URL(req.url || '', 'http://localhost');
|
||||
const code = parsedUrl.searchParams.get('code');
|
||||
const error = parsedUrl.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
console.log(`✅ Authorization code received: ${code?.slice(0, 10)}...`);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Successful!</h1>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
// Hand back the whole query — finishAuth() reads `code` + `iss` (RFC 9207) itself.
|
||||
resolve(parsedUrl.searchParams);
|
||||
setTimeout(() => server.close(), 3000);
|
||||
} else if (error) {
|
||||
console.log(`❌ Authorization error: ${error}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>Error: ${escHtml(error)}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
reject(new Error(`OAuth authorization failed: ${error}`));
|
||||
} else {
|
||||
console.log(`❌ No authorization code or error in callback`);
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
reject(new Error('No authorization code provided'));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(CALLBACK_PORT, () => {
|
||||
console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise<void> {
|
||||
console.log('🚢 Creating transport with OAuth provider...');
|
||||
const baseUrl = new URL(this.serverUrl);
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl, {
|
||||
authProvider: oauthProvider
|
||||
});
|
||||
console.log('🚢 Transport created');
|
||||
|
||||
try {
|
||||
console.log('🔌 Attempting connection (this will trigger OAuth redirect)...');
|
||||
await this.client!.connect(transport);
|
||||
console.log('✅ Connected successfully');
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
console.log('🔐 OAuth required - waiting for authorization...');
|
||||
const callbackParams = await this.waitForOAuthCallback();
|
||||
// Pass the whole callback query — the SDK extracts `code` and validates
|
||||
// `iss` against the recorded issuer (RFC 9207) before exchanging the code.
|
||||
await transport.finishAuth(callbackParams);
|
||||
console.log('🔐 Authorization code received:', callbackParams.get('code'));
|
||||
console.log('🔌 Reconnecting with authenticated transport...');
|
||||
await this.attemptConnection(oauthProvider);
|
||||
} else {
|
||||
console.error('❌ Connection failed with non-auth error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes connection to the MCP server with OAuth authentication
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
console.log(`🔗 Attempting to connect to ${this.serverUrl}...`);
|
||||
|
||||
const clientMetadata: OAuthClientMetadata = {
|
||||
client_name: 'Simple OAuth MCP Client',
|
||||
redirect_uris: [CALLBACK_URL],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
application_type: 'native',
|
||||
token_endpoint_auth_method: 'client_secret_post'
|
||||
};
|
||||
|
||||
console.log('🔐 Creating OAuth provider...');
|
||||
const oauthProvider = new InMemoryOAuthClientProvider(
|
||||
CALLBACK_URL,
|
||||
clientMetadata,
|
||||
(redirectUrl: URL) => {
|
||||
console.log(`📌 OAuth redirect handler called - opening browser`);
|
||||
console.log(`Opening browser to: ${redirectUrl.toString()}`);
|
||||
this.openBrowser(redirectUrl.toString());
|
||||
},
|
||||
this.clientMetadataUrl
|
||||
);
|
||||
console.log('🔐 OAuth provider created');
|
||||
|
||||
console.log('👤 Creating MCP client...');
|
||||
this.client = new Client(
|
||||
{
|
||||
name: 'simple-oauth-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{ capabilities: {} }
|
||||
);
|
||||
console.log('👤 Client created');
|
||||
|
||||
console.log('🔐 Starting OAuth flow...');
|
||||
|
||||
await this.attemptConnection(oauthProvider);
|
||||
|
||||
// Start interactive loop
|
||||
await this.interactiveLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main interactive loop for user commands
|
||||
*/
|
||||
async interactiveLoop(): Promise<void> {
|
||||
console.log('\n🎯 Interactive MCP Client with OAuth');
|
||||
console.log('Commands:');
|
||||
console.log(' list - List available tools');
|
||||
console.log(' call <tool_name> [args] - Call a tool');
|
||||
console.log(' stream <tool_name> [args] - (disabled; returns when the SEP-2663 tasks extension lands)');
|
||||
console.log(' quit - Exit the client');
|
||||
console.log();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const command = await this.question('mcp> ');
|
||||
|
||||
if (!command.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (command === 'quit') {
|
||||
console.log('\n👋 Goodbye!');
|
||||
this.close();
|
||||
process.exit(0);
|
||||
} else if (command === 'list') {
|
||||
await this.listTools();
|
||||
} else if (command.startsWith('call ')) {
|
||||
await this.handleCallTool(command);
|
||||
} else if (command.startsWith('stream ')) {
|
||||
await this.handleStreamTool(command);
|
||||
} else {
|
||||
console.log("❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'SIGINT') {
|
||||
console.log('\n\n👋 Goodbye!');
|
||||
break;
|
||||
}
|
||||
console.error('❌ Error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async listTools(): Promise<void> {
|
||||
if (!this.client) {
|
||||
console.log('❌ Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: ListToolsRequest = {
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
const result = await this.client.request(request);
|
||||
|
||||
if (result.tools && result.tools.length > 0) {
|
||||
console.log('\n📋 Available tools:');
|
||||
for (const [index, tool] of result.tools.entries()) {
|
||||
console.log(`${index + 1}. ${tool.name}`);
|
||||
if (tool.description) {
|
||||
console.log(` Description: ${tool.description}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
} else {
|
||||
console.log('No tools available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to list tools:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCallTool(command: string): Promise<void> {
|
||||
const parts = command.split(/\s+/);
|
||||
const toolName = parts[1];
|
||||
|
||||
if (!toolName) {
|
||||
console.log('❌ Please specify a tool name');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse arguments (simple JSON-like format)
|
||||
let toolArgs: Record<string, unknown> = {};
|
||||
if (parts.length > 2) {
|
||||
const argsString = parts.slice(2).join(' ');
|
||||
try {
|
||||
toolArgs = JSON.parse(argsString);
|
||||
} catch {
|
||||
console.log('❌ Invalid arguments format (expected JSON)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.callTool(toolName, toolArgs);
|
||||
}
|
||||
|
||||
private async callTool(toolName: string, toolArgs: Record<string, unknown>): Promise<void> {
|
||||
if (!this.client) {
|
||||
console.log('❌ Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.client.callTool({
|
||||
name: toolName,
|
||||
arguments: toolArgs
|
||||
});
|
||||
|
||||
console.log(`\n🔧 Tool '${toolName}' result:`);
|
||||
if (result.content) {
|
||||
for (const content of result.content) {
|
||||
if (content.type === 'text') {
|
||||
console.log(content.text);
|
||||
} else {
|
||||
console.log(content);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to call tool '${toolName}':`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStreamTool(command: string): Promise<void> {
|
||||
const parts = command.split(/\s+/);
|
||||
const toolName = parts[1];
|
||||
|
||||
if (!toolName) {
|
||||
console.log('❌ Please specify a tool name');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse arguments (simple JSON-like format)
|
||||
let toolArgs: Record<string, unknown> = {};
|
||||
if (parts.length > 2) {
|
||||
const argsString = parts.slice(2).join(' ');
|
||||
try {
|
||||
toolArgs = JSON.parse(argsString);
|
||||
} catch {
|
||||
console.log('❌ Invalid arguments format (expected JSON)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.streamTool(toolName, toolArgs);
|
||||
}
|
||||
|
||||
private async streamTool(toolName: string, toolArgs: Record<string, unknown>): Promise<void> {
|
||||
if (!this.client) {
|
||||
console.log('❌ Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
// The streaming-tool demo (callToolStream) was removed with the 2025-11
|
||||
// experimental tasks (SEP-2663); it returns when the tasks extension lands.
|
||||
void toolName;
|
||||
void toolArgs;
|
||||
console.log('Streaming tool demo removed with the 2025-11 experimental tasks (SEP-2663); returns when the tasks extension lands.');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.rl.close();
|
||||
if (this.client) {
|
||||
// Note: Client doesn't have a close method in the current implementation
|
||||
// This would typically close the transport connection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const serverUrl = args[0] || DEFAULT_SERVER_URL;
|
||||
const clientMetadataUrl = args[1];
|
||||
|
||||
console.log('🚀 Simple MCP OAuth Client');
|
||||
console.log(`Connecting to: ${serverUrl}`);
|
||||
if (clientMetadataUrl) {
|
||||
console.log(`Client Metadata URL: ${clientMetadataUrl}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
const client = new InteractiveOAuthClient(serverUrl, clientMetadataUrl);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n👋 Goodbye!');
|
||||
client.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (error) {
|
||||
console.error('Failed to start client:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Run if this file is executed directly
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error('Error running client:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
98
examples/oauth/simpleOAuthClientProvider.ts
Normal file
98
examples/oauth/simpleOAuthClientProvider.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import type {
|
||||
OAuthClientInformationMixed,
|
||||
OAuthClientMetadata,
|
||||
OAuthClientProvider,
|
||||
OAuthDiscoveryState,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/client';
|
||||
import { validateClientMetadataUrl } from '@modelcontextprotocol/client';
|
||||
|
||||
/**
|
||||
* In-memory OAuth client provider for demonstration purposes.
|
||||
* In production, you should persist tokens and client credentials securely.
|
||||
*
|
||||
* Tokens and client credentials are stored as single-slot blobs. The SDK stamps an
|
||||
* `issuer` field onto every value it saves; round-tripping the blob unchanged means
|
||||
* a credential issued by one authorization server is never reused at another (the
|
||||
* SDK reads the stamp back as a key-not-found and re-registers / re-authorizes).
|
||||
* To hold credentials for several authorization servers at once, key your storage
|
||||
* on the `ctx.issuer` argument instead.
|
||||
*/
|
||||
export class InMemoryOAuthClientProvider implements OAuthClientProvider {
|
||||
private _clientInformation?: OAuthClientInformationMixed;
|
||||
private _tokens?: OAuthTokens;
|
||||
private _codeVerifier?: string;
|
||||
private _discoveryState?: OAuthDiscoveryState;
|
||||
|
||||
constructor(
|
||||
private readonly _redirectUrl: string | URL,
|
||||
private readonly _clientMetadata: OAuthClientMetadata,
|
||||
onRedirect?: (url: URL) => void,
|
||||
public readonly clientMetadataUrl?: string
|
||||
) {
|
||||
// Validate clientMetadataUrl at construction time (fail-fast)
|
||||
validateClientMetadataUrl(clientMetadataUrl);
|
||||
|
||||
this._onRedirect =
|
||||
onRedirect ||
|
||||
(url => {
|
||||
console.log(`Redirect to: ${url.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
private _onRedirect: (url: URL) => void;
|
||||
|
||||
get redirectUrl(): string | URL {
|
||||
return this._redirectUrl;
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return this._clientMetadata;
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformationMixed | undefined {
|
||||
return this._clientInformation;
|
||||
}
|
||||
|
||||
saveClientInformation(clientInformation: OAuthClientInformationMixed): void {
|
||||
this._clientInformation = clientInformation;
|
||||
}
|
||||
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return this._tokens;
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens): void {
|
||||
this._tokens = tokens;
|
||||
}
|
||||
|
||||
redirectToAuthorization(authorizationUrl: URL): void {
|
||||
this._onRedirect(authorizationUrl);
|
||||
}
|
||||
|
||||
saveCodeVerifier(codeVerifier: string): void {
|
||||
this._codeVerifier = codeVerifier;
|
||||
}
|
||||
|
||||
codeVerifier(): string {
|
||||
if (!this._codeVerifier) {
|
||||
throw new Error('No code verifier saved');
|
||||
}
|
||||
return this._codeVerifier;
|
||||
}
|
||||
|
||||
saveDiscoveryState(state: OAuthDiscoveryState): void {
|
||||
this._discoveryState = state;
|
||||
}
|
||||
|
||||
discoveryState(): OAuthDiscoveryState | undefined {
|
||||
return this._discoveryState;
|
||||
}
|
||||
|
||||
invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void {
|
||||
if (scope === 'all' || scope === 'client') this._clientInformation = undefined;
|
||||
if (scope === 'all' || scope === 'tokens') this._tokens = undefined;
|
||||
if (scope === 'all' || scope === 'verifier') this._codeVerifier = undefined;
|
||||
if (scope === 'all' || scope === 'discovery') this._discoveryState = undefined;
|
||||
}
|
||||
}
|
||||
55
examples/oauth/simpleTokenProvider.ts
Normal file
55
examples/oauth/simpleTokenProvider.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Example demonstrating the minimal AuthProvider for bearer token authentication.
|
||||
*
|
||||
* AuthProvider is the base interface for all client auth. For simple cases where
|
||||
* tokens are managed externally — pre-configured API tokens, gateway/proxy patterns,
|
||||
* or tokens obtained through a separate auth flow — implement only `token()`.
|
||||
*
|
||||
* For OAuth flows (client_credentials, private_key_jwt, etc.), use the built-in
|
||||
* providers which implement both `token()` and `onUnauthorized()`.
|
||||
*
|
||||
* Environment variables:
|
||||
* MCP_SERVER_URL - Server URL (default: http://127.0.0.1:3000/mcp)
|
||||
* MCP_TOKEN - Bearer token to use for authentication (required)
|
||||
*/
|
||||
|
||||
import type { AuthProvider } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000/mcp';
|
||||
|
||||
async function main() {
|
||||
const token = process.env.MCP_TOKEN;
|
||||
if (!token) {
|
||||
console.error('MCP_TOKEN environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// AuthProvider with just token() — the simplest possible auth.
|
||||
// token() is called before every request, so it can handle refresh internally.
|
||||
// With no onUnauthorized(), a 401 throws UnauthorizedError immediately.
|
||||
const authProvider: AuthProvider = {
|
||||
token: async () => token
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'auth-provider-example', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { authProvider });
|
||||
|
||||
await client.connect(transport);
|
||||
console.log('Connected successfully.');
|
||||
|
||||
const tools = await client.listTools();
|
||||
console.log('Available tools:', tools.tools.map(t => t.name).join(', ') || '(none)');
|
||||
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error('Error running client:', error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Loading…
Reference in a new issue