|
| 1 | +/** |
| 2 | + * Self-verifying per-tool scope client. |
| 3 | + * |
| 4 | + * Drives the same OAuth machinery as `examples/oauth/client.ts` to obtain a |
| 5 | + * `files:read` token, then exercises the server's handler-level per-tool scope |
| 6 | + * checks: `list-files` succeeds; `write-file` returns a tool-result |
| 7 | + * `{ isError: true }` because the token lacks `files:write`. The transport's |
| 8 | + * automatic `403 insufficient_scope` step-up (SEP-2350) is exercised by the |
| 9 | + * dedicated e2e scenario (`test/e2e/scenarios/client-auth.test.ts`); this |
| 10 | + * example demonstrates the recommended server-side pattern of enforcing scope |
| 11 | + * inside the tool handler that needs it. |
| 12 | + */ |
| 13 | +import type { OAuthClientMetadata } from '@modelcontextprotocol/client'; |
| 14 | +import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; |
| 15 | + |
| 16 | +import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; |
| 17 | +import { InMemoryOAuthClientProvider } from '../oauth/simpleOAuthClientProvider.js'; |
| 18 | + |
| 19 | +const URL_ARG = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); |
| 20 | +const CALLBACK_URL = 'http://127.0.0.1:8091/callback'; |
| 21 | + |
| 22 | +/** Follow the demo AS's auto-consent 302 and return the `code`. */ |
| 23 | +async function followAuthorize(authorizationUrl: URL): Promise<string> { |
| 24 | + const res = await fetch(authorizationUrl, { redirect: 'manual' }); |
| 25 | + const location = res.headers.get('location'); |
| 26 | + if (!location || res.status !== 302) throw new Error(`expected 302 from /authorize, got ${res.status}`); |
| 27 | + const code = new globalThis.URL(location).searchParams.get('code'); |
| 28 | + if (!code) throw new Error(`authorize redirect missing ?code: ${location}`); |
| 29 | + return code; |
| 30 | +} |
| 31 | + |
| 32 | +runClient('scoped-tools', async () => { |
| 33 | + const captured: URL[] = []; |
| 34 | + const clientMetadata: OAuthClientMetadata = { |
| 35 | + client_name: 'Scoped-Tools Step-Up Client', |
| 36 | + redirect_uris: [CALLBACK_URL], |
| 37 | + grant_types: ['authorization_code'], |
| 38 | + response_types: ['code'], |
| 39 | + token_endpoint_auth_method: 'none', |
| 40 | + scope: 'files:read' |
| 41 | + }; |
| 42 | + const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, url => { |
| 43 | + captured.push(url); |
| 44 | + }); |
| 45 | + |
| 46 | + // ---- 1. Initial authorization for files:read ------------------------------ |
| 47 | + const client = new Client({ name: 'scoped-tools-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); |
| 48 | + const t1 = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); |
| 49 | + let challenged = false; |
| 50 | + try { |
| 51 | + await client.connect(t1); |
| 52 | + } catch (error) { |
| 53 | + const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; |
| 54 | + if (!(root instanceof UnauthorizedError)) throw error; |
| 55 | + challenged = true; |
| 56 | + } |
| 57 | + check.ok(challenged, 'first connect must 401'); |
| 58 | + check.equal(captured.length, 1, 'authorize URL captured'); |
| 59 | + check.match(captured[0]?.searchParams.get('scope') ?? '', /files:read/); |
| 60 | + await t1.finishAuth(await followAuthorize(captured[0]!)); |
| 61 | + check.equal(provider.tokens()?.scope, 'files:read'); |
| 62 | + |
| 63 | + // ---- 2. Reconnect with files:read; list-files works ----------------------- |
| 64 | + const t2 = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); |
| 65 | + await client.connect(t2); |
| 66 | + const listed = await client.callTool({ name: 'list-files', arguments: {} }); |
| 67 | + check.match(listed.content?.[0]?.type === 'text' ? listed.content[0].text : '', /listed by .* \[files:read]/); |
| 68 | + |
| 69 | + // ---- 3. write-file → handler-level insufficient_scope --------------------- |
| 70 | + // Per-tool scope is enforced inside the tool handler (ctx.http?.authInfo), |
| 71 | + // so an under-scoped call surfaces as a tool-result `isError`, not an HTTP |
| 72 | + // 403. The transport's automatic step-up (SEP-2350) applies only when the |
| 73 | + // RS responds 403 at the HTTP layer. |
| 74 | + const denied = await client.callTool({ name: 'write-file', arguments: {} }); |
| 75 | + check.equal(denied.isError, true, 'write-file must isError under files:read-only token'); |
| 76 | + check.match(denied.content?.[0]?.type === 'text' ? denied.content[0].text : '', /insufficient_scope: requires files:write/); |
| 77 | + check.equal(captured.length, 1, 'no transport step-up — scope is enforced in the tool handler'); |
| 78 | + |
| 79 | + await client.close(); |
| 80 | +}); |
0 commit comments