diff --git a/CHANGELOG.md b/CHANGELOG.md index 044072b..1ffb442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ ## Unreleased +### New Features + +- **Secure token elicitation for `preview_style_tool` / `style_comparison_tool`** (#57): `accessToken` is now optional on both tools. When omitted, the tool asks how you'd like to provide a public token — pasting an existing `pk.*` token, creating a new one with optional URL restrictions, or auto-creating a minimally-scoped one (`styles:read`, `styles:tiles`, `fonts:read`). This keeps your server's `sk.*`/`pk.*` access token out of chat history and preview URLs. Falls back to requiring `accessToken` directly on clients without elicitation support (Claude Desktop, Claude Code). A chosen/created token is cached in memory per token; pass `useCustomToken: true` to force re-selection. Elicitation requests are routed through the per-call session context rather than a shared server reference, so a tool call's prompt is always delivered back to the session that made it, even when the same tool instance is reused across multiple concurrent sessions. + - If the server's own access token is a literal Mapbox temporary token (`tk.*`), the "create a new token" and "auto-create" options are omitted from the dialog up front, since that token shape is guaranteed to lack `tokens:write`. Other callers lacking `tokens:write` (e.g. the hosted MCP endpoint, which authenticates with its own access token rather than a Mapbox `pk.*`/`sk.*`/`tk.*` token) aren't detectable ahead of time; for those, choosing "create"/"auto-create" fails against the Tokens API with a scope/permission hint appended to the error, steering back to "I have a token to provide". + - The initial choice dialog has an explicit 60s timeout rather than relying on the SDK's default; a client-returned token name/URL-restriction list that exceeds a sane size is rejected server-side regardless of what the dialog's schema hints suggest a client's form should enforce; and the in-memory token cache is bounded to 1000 entries (LRU eviction), so none of this can grow memory usage without limit. + - **"I have a token to provide" now uses MCP URL-mode elicitation, not a form field.** The MCP spec (SEP-1036) prohibits collecting credentials via form-mode elicitation — a `pk.*` token is still a credential, so the original design (a `token` text field in the same form-mode dialog as the choice picker) didn't comply, and separately meant a pasted `sk.*` secret token skipped the `pk.*` prefix check every other path enforces and got embedded straight into the returned preview URL (found by an adversarial review pass). Choosing "provide" now opens a short-lived HTTP server on `127.0.0.1` (the same pattern `gh auth login`/`gcloud auth login` use) and sends a URL-mode elicitation request pointing at it; the token is submitted directly to that local page, never through the MCP client or chat history, then validated with the same `pk.*`/length checks as every other path. This is opt-in via `ENABLE_LOCAL_URL_ELICITATION` (default `false`) — only this package's own local stdio entry point (`dist/esm/index.js`) enables it automatically, since that's the one context confirmed to run on the same machine as the user's browser. Any other embedding (e.g. a hosted/cloud deployment, where a `127.0.0.1` URL wouldn't resolve to anything the user's browser could reach) stays safe with no action needed; with it disabled, "provide" falls back to requiring `accessToken` directly, same as a client without elicitation support at all. + - Every field the client returns from the choice dialog (choice, token name, URL restrictions) is now validated against its expected runtime type and, for `choice`, against the set of options actually offered — a client returning an unrecognized or wrong-typed value previously either fell through to auto-creating a real token or threw an unclassified `TypeError`. A request timeout or cancellation at either step (the choice dialog or the URL-mode consent request) is now also distinguished from "client doesn't support elicitation", so a caller passing `useCustomToken: true` gets a real error instead of silently getting back the stale cached token it was trying to replace. + - Listing a user's existing public tokens (to populate the dialog) only requires `tokens:read`, a separate scope from the `tokens:write` needed to create one — it's no longer skipped for `tk.*`-authenticated servers, which lack the latter but not necessarily the former. + - Error messages from a failed token-creation call are now redacted the same way every other Mapbox API tool's errors are, since these two tools don't extend the base class that applies that redaction automatically. A misconfigured endpoint could previously surface the caller's own access token verbatim in the returned error text. + +### Changed + +- **`preview_style_tool` / `style_comparison_tool`**: token-listing and token-creation HTTP calls now go through the shared `HttpPipeline` (constructor-injected `httpRequest`) instead of a bare `fetch`, consistent with the rest of the API-calling tools. + ### Dependencies - Bumped `@modelcontextprotocol/sdk` to `1.30.0`. Not adopting the `2026-07-28` spec revision this release covers (stateless request/response model, elicitation replaced by Multi Round-Trip Requests, Sampling deprecated) — that's a separate migration, tracked in #130, given this repo's own elicitation-based features depend on the mechanism being replaced. Regenerated `patches/@modelcontextprotocol+sdk+1.30.0.patch` (previously pinned to `1.29.0`) — same patch content, applies cleanly to the new version, verified live against the built server. diff --git a/README.md b/README.md index 9759c7a..19f58d7 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,19 @@ Get started by integrating with your preferred AI development environment: - [Cursor Integration](./docs/cursor-integration.md) - Cursor IDE integration - [VS Code Integration](./docs/vscode-integration.md) - Visual Studio Code with GitHub Copilot +**Note on MCP Elicitation Support**: Some tools (like `preview_style_tool` and `style_comparison_tool`) use [MCP elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to provide secure token management following the principle of least privilege. Elicitation ensures that only minimal-scope public tokens (pk._) appear in preview URLs, while your powerful server token (sk._) stays secure. This guided workflow also improves UX for token selection and creation. Elicitation support varies by client: + +- **MCP Inspector**: ✅ Full support +- **Cursor**: ✅ Full support +- **VS Code (with Copilot)**: ✅ Full support +- **Goose**: ⚠️ Known bug - Form displays after timeout ([goose#6471](https://github.com/block/goose/issues/6471)) +- **Claude Desktop**: ⚠️ Not yet supported (Claude will fall back to creating tokens via chat) +- **Claude Code**: ⚠️ Not yet supported (provide `accessToken` parameter directly) + +Choosing **"I have a token to provide"** doesn't paste the token into a form — the MCP spec requires credentials to go through [URL-mode elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-mode-elicitation-requests) instead, so this opens a page served locally on your own machine (`http://127.0.0.1:`) to submit it. This requires a client that supports URL-mode elicitation specifically, and only works when the server process is running on the same machine as your browser — running via `dist/esm/index.js` (Claude Desktop, Claude Code, Cursor, VS Code, or any other local stdio client) enables it automatically. See [`ENABLE_LOCAL_URL_ELICITATION`](#enable_local_url_elicitation) if you're embedding this package's tools yourself rather than running it as a local server. + +**Note on the hosted MCP endpoint**: even on a client with full elicitation support, "create a new token" and "auto-create" will fail on the [hosted endpoint](#hosted-mcp-endpoint) — see below for why. "I have a token to provide" is also unavailable there; use the `accessToken` parameter directly instead. + ### DXT Package Distribution This MCP server can be packaged as a DXT (Desktop Extension) file for easy distribution and installation. DXT is a standardized format for distributing local MCP servers, similar to browser extensions. @@ -96,6 +109,13 @@ For quick access, you can use our hosted MCP endpoint: For detailed setup instructions for different clients and API usage, see the [Hosted MCP Server Guide](https://github.com/mapbox/mcp-server/blob/main/docs/hosted-mcp-guide.md). Note: This guide references the standard MCP endpoint - you'll need to update the endpoint URL to use the devkit endpoint above. +**Token creation is unavailable on the hosted endpoint**: the hosted deployment authenticates each request with its own access token rather than your personal Mapbox account token, and that token is not granted `tokens:write`. As a result, on the hosted endpoint: + +- `preview_style_tool` / `style_comparison_tool`'s elicitation dialog still offers all three options, but choosing "create a new token" or "auto-create" fails against the Mapbox Tokens API with a scope/permission error (the dialog can't know ahead of time that this particular deployment's token lacks `tokens:write` — see the `isTemporaryServerToken` caveat in `src/utils/tokenElicitation.ts` for tokens where it can tell). +- `create_token_tool` is not exposed on the hosted endpoint at all. + +**"I have a token to provide" is also unavailable on the hosted endpoint by default**: that option works via URL-mode elicitation to a page served on `127.0.0.1`, which only makes sense when the server process runs on your own machine. This is opt-in (see [`ENABLE_LOCAL_URL_ELICITATION`](#enable_local_url_elicitation)) and only this package's own local stdio entry point (`dist/esm/index.js`) turns it on automatically, so a hosted/cloud deployment stays safe with no action needed — pass `accessToken` directly there instead. Create a token ahead of time from your [Mapbox Account page](https://account.mapbox.com/) if you don't have one. Running this server **locally** with your own `pk.*`/`sk.*` access token (which can carry `tokens:write`) also enables create and auto-create. + ### Getting Your Mapbox Access Token **A Mapbox access token is required to use this MCP server.** @@ -169,11 +189,49 @@ Complete set of tools for managing Mapbox styles via the Styles API: - Input: `styleId` - Returns: Success confirmation -**PreviewStyleTool** - Generate preview URL for a Mapbox style using an existing public token +**PreviewStyleTool** - Generate preview URL for a Mapbox style with secure token handling -- Input: `styleId`, `title` (optional), `zoomwheel` (optional), `zoom` (optional), `center` (optional), `bearing` (optional), `pitch` (optional) +- Input: + - `styleId` (required): Style ID to preview + - `accessToken` (optional): Provide a specific public token (for backward compatibility) + - `useCustomToken` (optional): Force token selection dialog even if a token is cached + - `title` (optional): Show title in preview + - `zoomwheel` (optional): Enable zoom wheel control - Returns: URL to open the style preview in browser -- **Note**: This tool automatically fetches the first available public token from your account for the preview URL. Requires at least one public token with `styles:read` scope. +- **🔐 Secure Token Management**: If `accessToken` is not provided, this tool uses MCP **elicitation** to create minimal-scope public tokens (pk._) instead of exposing your powerful server token. This follows the **principle of least privilege** - preview/comparison URLs only contain read-only tokens (styles:read, styles:tiles, fonts:read), keeping your server token (sk._) with write permissions secure. **Elicitation support varies by client**: + - **MCP Inspector, Cursor, VS Code**: ✅ Full support - Shows guided form dialog with three options: + 1. **Provide an existing token** - Paste a token you already have + 2. **Create a new preview token** - Create a new token with optional URL restrictions for enhanced security + 3. **Auto-create a basic token** - Let the tool create a simple preview token for you + - **Goose**: ⚠️ Known bug - Form displays after timeout ([goose#6471](https://github.com/block/goose/issues/6471)) + - **Claude Desktop, Claude Code**: ⚠️ Not yet supported - Provide `accessToken` parameter directly, or Claude will intelligently offer to create a token for you using `create_token_tool` + - **Hosted MCP endpoint**: ⚠️ "Create" and "auto-create" will fail regardless of client — see [Token creation is unavailable on the hosted endpoint](#hosted-mcp-endpoint) + - **Alternative**: Provide `accessToken` parameter directly for backward compatibility with any client +- **Session Storage**: Your token choice is cached for the session, so you only need to provide it once (when elicitation is supported) +- **Best Practice**: Use URL-restricted tokens to further limit token usage to specific domains. While public tokens in URLs are read-only, URL restrictions add an extra layer of security by ensuring tokens only work on your specified domains + +**StyleComparisonTool** - Generate side-by-side comparison URL for two Mapbox styles + +- Input: + - `before` (required): Mapbox style for the "before" side (accepts full style URL, username/styleId format, or just styleId) + - `after` (required): Mapbox style for the "after" side (accepts full style URL, username/styleId format, or just styleId) + - `accessToken` (optional): Provide a specific public token (for backward compatibility) + - `useCustomToken` (optional): Force token selection dialog even if a token is cached + - `zoom` (optional): Initial zoom level (0-22) + - `latitude` (optional): Latitude coordinate for initial map center (-90 to 90) + - `longitude` (optional): Longitude coordinate for initial map center (-180 to 180) +- Returns: URL to open the side-by-side style comparison in browser +- **🔐 Secure Token Management**: If `accessToken` is not provided, this tool uses MCP **elicitation** to create minimal-scope public tokens (pk._) instead of exposing your powerful server token. This follows the **principle of least privilege** - preview/comparison URLs only contain read-only tokens (styles:read, styles:tiles, fonts:read), keeping your server token (sk._) with write permissions secure. **Elicitation support varies by client**: + - **MCP Inspector, Cursor, VS Code**: ✅ Full support - Shows guided form dialog with three options: + 1. **Provide an existing token** - Paste a token you already have + 2. **Create a new preview token** - Create a new token with optional URL restrictions for enhanced security + 3. **Auto-create a basic token** - Let the tool create a simple preview token for you + - **Goose**: ⚠️ Known bug - Form displays after timeout ([goose#6471](https://github.com/block/goose/issues/6471)) + - **Claude Desktop, Claude Code**: ⚠️ Not yet supported - Provide `accessToken` parameter directly, or Claude will intelligently offer to create a token for you using `create_token_tool` + - **Hosted MCP endpoint**: ⚠️ "Create" and "auto-create" will fail regardless of client — see [Token creation is unavailable on the hosted endpoint](#hosted-mcp-endpoint) + - **Alternative**: Provide `accessToken` parameter directly for backward compatibility with any client +- **Session Storage**: Your token choice is cached for the session, so you only need to provide it once (when elicitation is supported) +- **Best Practice**: Use URL-restricted tokens to further limit token usage to specific domains. While public tokens in URLs are read-only, URL restrictions add an extra layer of security by ensuring tokens only work on your specified domains **ValidateStyleTool** - Validate Mapbox style JSON against the Mapbox Style Specification @@ -195,7 +253,8 @@ Complete set of tools for managing Mapbox styles via the Styles API: - **RetrieveStyleTool**: Requires `styles:download` scope - **UpdateStyleTool**: Requires `styles:write` scope - **DeleteStyleTool**: Requires `styles:write` scope -- **PreviewStyleTool**: Requires `tokens:read` scope (to list tokens) and at least one public token with `styles:read` scope +- **PreviewStyleTool**: Can work without token scopes via elicitation, or optionally accepts a direct public token. If using automatic token listing, requires `tokens:read` scope +- **StyleComparisonTool**: Can work without token scopes via elicitation, or optionally accepts a direct public token. If using automatic token listing, requires `tokens:read` scope **Note:** The username is automatically extracted from the JWT token payload. @@ -1247,6 +1306,16 @@ Set `VERBOSE_ERRORS=true` to get detailed error messages from the MCP server. Th By default, the server returns generic error messages. With verbose errors enabled, you'll receive the actual error details, which can help diagnose API connection issues, invalid parameters, or other problems. +#### ENABLE_LOCAL_URL_ELICITATION + +Controls whether `preview_style_tool` / `style_comparison_tool`'s "I have a token to provide" option is offered. Per the MCP spec, servers must not collect credentials via form-mode elicitation, so providing a token instead opens a short-lived HTTP server on `127.0.0.1` and sends a URL-mode elicitation request pointing at it — the same pattern CLI tools like `gh auth login` use. This only works when the server process and your browser are on the same machine. + +**Opt-in, not opt-out: defaults to `false`.** Set to `true` only after confirming the server process and the user's browser really are on the same machine — a `127.0.0.1` URL otherwise resolves to the browser's own loopback interface, where nothing is listening, rather than the server. This package's own local stdio entry point (`dist/esm/index.js`, used by Claude Desktop, Claude Code, Cursor, VS Code, and similar clients) sets this automatically; you only need to set it yourself if you're embedding these tools in your own server rather than running `dist/esm/index.js` directly. With it disabled (the default for any such embedding), choosing "provide" falls back to the same message shown to clients without elicitation support at all: pass `accessToken` directly instead. + +```bash +export ENABLE_LOCAL_URL_ELICITATION=true +``` + #### ENABLE_MCP_UI **Interactive Previews: MCP Apps (primary) & MCP-UI (compatibility)** diff --git a/docs/engineering_standards.md b/docs/engineering_standards.md index 64a34ae..c94c7c0 100644 --- a/docs/engineering_standards.md +++ b/docs/engineering_standards.md @@ -110,6 +110,7 @@ Keep secrets out of repositories. Use environment variables for sensitive data: - `MAPBOX_ACCESS_TOKEN` - Required for all Mapbox API operations - `VERBOSE_ERRORS` - Set to `true` for detailed error messages - `ENABLE_MCP_UI` - Controls MCP-UI support (default: `true`) +- `ENABLE_LOCAL_URL_ELICITATION` - Controls whether `preview_style_tool`/`style_comparison_tool` offer URL-mode token collection via a local `127.0.0.1` server (opt-in, default: `false` — see `src/utils/tokenCollectionServer.ts`). Only `src/index.ts` (this package's stdio entry point) sets it to `true` automatically, since that's the one context confirmed to run on the same machine as the user's browser; any other embedder (e.g. a hosted/cloud deployment) stays safe by default and must not enable this unless it's confirmed the same machine/browser relationship holds. - `OTEL_EXPORTER_OTLP_ENDPOINT` - OpenTelemetry endpoint (optional) - `OTEL_SERVICE_NAME` - Override service name for tracing (optional) diff --git a/src/index.ts b/src/index.ts index 6e824de..609d20f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,18 @@ if (existsSync(envPath)) { } } +// preview_style_tool / style_comparison_tool's "provide a token" option collects the +// token via a local http://127.0.0.1 server (see src/utils/tokenCollectionServer.ts), +// which only makes sense when the MCP server process and the user's browser are on the +// same machine — true for this stdio entry point, but not for every way this package's +// tools can be embedded (e.g. a cloud deployment). Opt in here, since this is the one +// context confirmed safe, rather than defaulting it on everywhere and relying on every +// other embedder to remember to opt out. Left untouched if already set (by the +// environment or the .env file below), so an explicit override always wins. +if (process.env.ENABLE_LOCAL_URL_ELICITATION === undefined) { + process.env.ENABLE_LOCAL_URL_ELICITATION = 'true'; +} + const versionInfo = getVersionInfo(); // Parse configuration from command-line arguments diff --git a/src/tools/index.ts b/src/tools/index.ts index ca134f3..91a537e 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -124,7 +124,7 @@ export const listTokens = new ListTokensTool({ httpRequest }); export const optimizeStyle = new OptimizeStyleTool(); /** Preview a Mapbox style */ -export const previewStyle = new PreviewStyleTool(); +export const previewStyle = new PreviewStyleTool({ httpRequest }); /** Retrieve a Mapbox style */ export const retrieveStyle = new RetrieveStyleTool({ httpRequest }); @@ -133,7 +133,7 @@ export const retrieveStyle = new RetrieveStyleTool({ httpRequest }); export const styleBuilder = new StyleBuilderTool(); /** Compare styles side-by-side */ -export const styleComparison = new StyleComparisonTool(); +export const styleComparison = new StyleComparisonTool({ httpRequest }); /** Query tiles at a location */ export const tilequery = new TilequeryTool({ httpRequest }); diff --git a/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts index fb9111b..569eda0 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts @@ -9,8 +9,16 @@ export const PreviewStyleSchema = z.object({ 'pk.', 'Invalid access token. Only public tokens (starting with pk.*) are allowed for preview URLs. Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs.' ) + .optional() + .describe( + 'Mapbox public access token (optional). If not provided, you will be prompted to provide, create, or auto-create a preview token. Must start with pk.* and have styles:read permission. Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs.' + ), + useCustomToken: z + .boolean() + .optional() + .default(false) .describe( - 'Mapbox public access token (required, must start with pk.* and have styles:read permission). Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs. Please use an existing public token or get one from list_tokens_tool or create one with create_token_tool with styles:read permission.' + 'Force token selection dialog even if a preview token is already stored for this session. Useful when you want to use a different token.' ), title: z .boolean() diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index f445161..f5a4e6f 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import { createUIResource } from '@mcp-ui/server'; import { BaseTool } from '../BaseTool.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; @@ -8,6 +9,28 @@ import { PreviewStyleInput } from './PreviewStyleTool.input.schema.js'; import { getUserNameFromToken } from '../../utils/jwtUtils.js'; +import { + cacheKeyFor, + collectProvidedToken, + createPreviewToken, + ElicitationUnavailableError, + elicitPreviewToken, + isTemporaryServerToken, + listPublicPreviewTokens, + previewTokenStorage +} from '../../utils/tokenElicitation.js'; +import { + localHttpTokenCollectionHandler, + type TokenCollectionHandler +} from '../../utils/tokenCollectionServer.js'; +import type { HttpRequest } from '../../utils/types.js'; + +// `BaseTool#execute`'s abstract signature accepts `ToolExecutionContext` in this slot; +// overriding with the concrete `RequestHandlerExtra` type here (rather than `any`) would +// fail TS's contravariant parameter check since the two types don't overlap. `any` is the +// same escape hatch `BaseTool.run()` itself already uses for this exact parameter. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ToolCallExtra = RequestHandlerExtra; export class PreviewStyleTool extends BaseTool { readonly name = 'preview_style_tool'; @@ -32,14 +55,217 @@ export class PreviewStyleTool extends BaseTool { } }; - constructor() { + private readonly httpRequest: HttpRequest; + private readonly tokenCollectionHandler: TokenCollectionHandler; + + constructor(params: { + httpRequest: HttpRequest; + tokenCollectionHandler?: TokenCollectionHandler; + }) { super({ inputSchema: PreviewStyleSchema }); + this.httpRequest = params.httpRequest; + this.tokenCollectionHandler = + params.tokenCollectionHandler ?? localHttpTokenCollectionHandler; } - protected async execute(input: PreviewStyleInput): Promise { + /** + * Overridden only to forward `extra` down to `execute()`. `BaseTool.run()` extracts + * `accessToken` from `extra` and drops the rest, but elicitation needs `extra.sendRequest` + * — the per-call, correctly-session-scoped request sender (see the doc comment on + * `elicitPreviewToken` for why `this.server` can't be used for this instead). + */ + async run(rawInput: unknown, extra?: ToolCallExtra): Promise { + try { + const input = this.inputSchema.parse(rawInput); + const accessToken = + extra?.authInfo?.token || process.env.MAPBOX_ACCESS_TOKEN; + return this.execute(input, accessToken, extra); + } catch (error) { + return { + isError: true, + content: [{ type: 'text', text: (error as Error).message }] + }; + } + } + + protected async execute( + input: PreviewStyleInput, + serverAccessToken?: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rawExtra?: any + ): Promise { + const extra: ToolCallExtra | undefined = rawExtra; + let publicToken: string; let userName: string; + + // Step 1: Determine which token to use for preview + if (input.accessToken) { + // User provided token directly (backward compatibility) + publicToken = input.accessToken; + } else { + // No token provided - use elicitation flow + try { + // Get username from server access token to check storage + userName = getUserNameFromToken(serverAccessToken || ''); + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text', + text: + 'Server access token is required when no preview token is provided. ' + + (error instanceof Error ? error.message : String(error)) + } + ] + }; + } + + const cacheKey = cacheKeyFor(serverAccessToken || ''); + const storedToken = previewTokenStorage.get(cacheKey); + + // Reuse the cached token unless the caller explicitly wants to choose a + // different one — in which case we still need `extra.sendRequest` to ask. + if (storedToken && !input.useCustomToken) { + publicToken = storedToken; + } else if (!extra?.sendRequest) { + // No per-call session context to elicit through at all (e.g. invoked + // directly, outside a connected MCP session). + if (storedToken) { + publicToken = storedToken; + } else { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Server not initialized. Cannot elicit token from user.' + } + ] + }; + } + } else { + // A server authenticated with a temporary tk.* token (e.g. the hosted MCP + // DevKit Server) can never call the Tokens API to create a new token, so + // the "create"/"auto" options are dropped from the dialog before asking. + const canCreateTokens = !isTemporaryServerToken(serverAccessToken!); + + // Listing only needs `tokens:read`, a separate scope from the `tokens:write` + // that canCreateTokens checks — a tk.*-authenticated server lacking the + // latter isn't thereby known to lack the former too, so this isn't gated on + // canCreateTokens. The call already fails safe to an empty list on any + // API/permission error. + const existingTokens = await listPublicPreviewTokens( + this.httpRequest, + MapboxApiBasedTool.mapboxApiEndpoint, + serverAccessToken!, + userName + ); + + try { + // Elicit token choice from user, over *this* call's own connection. + const elicited = await elicitPreviewToken( + extra.sendRequest, + existingTokens, + canCreateTokens + ); + + // Handle user's choice + if (elicited.choice === 'provide') { + // Collected via a follow-up URL-mode elicitation, not this form dialog — + // the MCP spec requires credentials to go through URL mode, not form mode. + // Errors here (unsupported client, decline/cancel, validation failure) are + // handled by the catch below exactly like elicitPreviewToken's own errors. + publicToken = await collectProvidedToken( + extra.sendRequest, + extra.sendNotification, + this.tokenCollectionHandler + ); + } else if (elicited.choice === 'create') { + // Create new token with user's specifications + const created = await createPreviewToken( + this.httpRequest, + MapboxApiBasedTool.mapboxApiEndpoint, + serverAccessToken!, + userName, + elicited.tokenNote, + elicited.urlRestrictions + ); + if (!created.success) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Failed to create token: ${created.error}` + } + ] + }; + } + publicToken = created.token!; + } else { + // auto - create basic preview token + const created = await createPreviewToken( + this.httpRequest, + MapboxApiBasedTool.mapboxApiEndpoint, + serverAccessToken!, + userName + ); + if (!created.success) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Failed to auto-create token: ${created.error}` + } + ] + }; + } + publicToken = created.token!; + } + + // Store token for future use + previewTokenStorage.set(cacheKey, publicToken); + } catch (error) { + if (error instanceof ElicitationUnavailableError) { + // The client can't be asked at all (e.g. no elicitation support). Fall + // back to a cached token if one exists rather than failing outright. + if (storedToken) { + publicToken = storedToken; + } else { + return { + isError: true, + content: [ + { + type: 'text', + text: + 'Preview token required but client does not support elicitation. ' + + 'Please provide an accessToken parameter directly, or use a client that supports MCP elicitation (e.g., MCP Inspector, Cursor, VS Code).' + } + ] + }; + } + } else { + // The client was asked and the user declined/cancelled, or something + // else went wrong — surface it rather than silently reusing a cache. + return { + isError: true, + content: [ + { + type: 'text', + text: error instanceof Error ? error.message : String(error) + } + ] + }; + } + } + } + } + + // Step 2: Get username from the preview token try { - userName = getUserNameFromToken(input.accessToken); + userName = getUserNameFromToken(publicToken); } catch (error) { return { isError: true, @@ -52,9 +278,6 @@ export class PreviewStyleTool extends BaseTool { }; } - // Use the user-provided public token - const publicToken = input.accessToken; - // Build URL for the embeddable HTML endpoint const params = new URLSearchParams(); params.append('access_token', publicToken); diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts index 1265c11..5b65033 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts @@ -28,8 +28,16 @@ export const StyleComparisonSchema = z.object({ 'pk.', 'Invalid token type. Style comparison requires a public token (pk.*) that can be used in browser URLs. Secret tokens (sk.*) cannot be exposed in client-side applications. Please provide a public token with styles:read permission.' ) + .optional() + .describe( + 'Mapbox public access token (optional). If not provided, you will be prompted to provide, create, or auto-create a preview token via MCP elicitation (supported in MCP Inspector, Cursor, VS Code). For clients without elicitation support (Claude Desktop, Claude Code), provide this parameter directly. Must start with pk.* and have styles:read permission. Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs.' + ), + useCustomToken: z + .boolean() + .optional() + .default(false) .describe( - 'Mapbox public access token (required, must start with pk.* and have styles:read permission). Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs. Please use a public token or create one with styles:read permission.' + 'Force token selection dialog even if a preview token is already stored for this session. Useful when you want to use a different token.' ), zoom: z .number() diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index e916b5a..09aa96d 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -3,13 +3,37 @@ import { randomUUID } from 'node:crypto'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import { createUIResource } from '@mcp-ui/server'; import { BaseTool } from '../BaseTool.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { StyleComparisonSchema, StyleComparisonInput } from './StyleComparisonTool.schema.js'; import { getUserNameFromToken } from '../../utils/jwtUtils.js'; +import { + cacheKeyFor, + collectProvidedToken, + createPreviewToken, + ElicitationUnavailableError, + elicitPreviewToken, + isTemporaryServerToken, + listPublicPreviewTokens, + previewTokenStorage +} from '../../utils/tokenElicitation.js'; +import { + localHttpTokenCollectionHandler, + type TokenCollectionHandler +} from '../../utils/tokenCollectionServer.js'; +import type { HttpRequest } from '../../utils/types.js'; + +// `BaseTool#execute`'s abstract signature accepts `ToolExecutionContext` in this slot; +// overriding with the concrete `RequestHandlerExtra` type here (rather than `any`) would +// fail TS's contravariant parameter check since the two types don't overlap. `any` is the +// same escape hatch `BaseTool.run()` itself already uses for this exact parameter. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ToolCallExtra = RequestHandlerExtra; export class StyleComparisonTool extends BaseTool< typeof StyleComparisonSchema @@ -36,8 +60,17 @@ export class StyleComparisonTool extends BaseTool< } }; - constructor() { + private readonly httpRequest: HttpRequest; + private readonly tokenCollectionHandler: TokenCollectionHandler; + + constructor(params: { + httpRequest: HttpRequest; + tokenCollectionHandler?: TokenCollectionHandler; + }) { super({ inputSchema: StyleComparisonSchema }); + this.httpRequest = params.httpRequest; + this.tokenCollectionHandler = + params.tokenCollectionHandler ?? localHttpTokenCollectionHandler; } /** @@ -85,15 +118,202 @@ export class StyleComparisonTool extends BaseTool< return resolved; } + /** + * Overridden only to forward `extra` down to `execute()`. `BaseTool.run()` extracts + * `accessToken` from `extra` and drops the rest, but elicitation needs `extra.sendRequest` + * — the per-call, correctly-session-scoped request sender (see the doc comment on + * `elicitPreviewToken` for why `this.server` can't be used for this instead). + */ + async run(rawInput: unknown, extra?: ToolCallExtra): Promise { + try { + const input = this.inputSchema.parse(rawInput); + const accessToken = + extra?.authInfo?.token || process.env.MAPBOX_ACCESS_TOKEN; + return this.execute(input, accessToken, extra); + } catch (error) { + return { + isError: true, + content: [{ type: 'text', text: (error as Error).message }] + }; + } + } + protected async execute( - input: StyleComparisonInput + input: StyleComparisonInput, + serverAccessToken?: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rawExtra?: any ): Promise { + const extra: ToolCallExtra | undefined = rawExtra; + let publicToken: string; + + // Step 1: Determine which token to use for the comparison + if (input.accessToken) { + // User provided token directly (backward compatibility) + publicToken = input.accessToken; + } else { + // No token provided - use elicitation flow + let userName: string; + try { + userName = getUserNameFromToken(serverAccessToken || ''); + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text', + text: + 'Server access token is required when no preview token is provided. ' + + (error instanceof Error ? error.message : String(error)) + } + ] + }; + } + + const cacheKey = cacheKeyFor(serverAccessToken || ''); + const storedToken = previewTokenStorage.get(cacheKey); + + // Reuse the cached token unless the caller explicitly wants to choose a + // different one — in which case we still need `extra.sendRequest` to ask. + if (storedToken && !input.useCustomToken) { + publicToken = storedToken; + } else if (!extra?.sendRequest) { + // No per-call session context to elicit through at all (e.g. invoked + // directly, outside a connected MCP session). + if (storedToken) { + publicToken = storedToken; + } else { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Server not initialized. Cannot elicit token from user.' + } + ] + }; + } + } else { + // A server authenticated with a temporary tk.* token (e.g. the hosted MCP + // DevKit Server) can never call the Tokens API to create a new token, so + // the "create"/"auto" options are dropped from the dialog before asking. + const canCreateTokens = !isTemporaryServerToken(serverAccessToken!); + + // Listing only needs `tokens:read`, a separate scope from the `tokens:write` + // that canCreateTokens checks — a tk.*-authenticated server lacking the + // latter isn't thereby known to lack the former too, so this isn't gated on + // canCreateTokens. The call already fails safe to an empty list on any + // API/permission error. + const existingTokens = await listPublicPreviewTokens( + this.httpRequest, + MapboxApiBasedTool.mapboxApiEndpoint, + serverAccessToken!, + userName + ); + + try { + // Elicit token choice from user, over *this* call's own connection. + const elicited = await elicitPreviewToken( + extra.sendRequest, + existingTokens, + canCreateTokens + ); + + if (elicited.choice === 'provide') { + // Collected via a follow-up URL-mode elicitation, not this form dialog — + // the MCP spec requires credentials to go through URL mode, not form mode. + // Errors here (unsupported client, decline/cancel, validation failure) are + // handled by the catch below exactly like elicitPreviewToken's own errors. + publicToken = await collectProvidedToken( + extra.sendRequest, + extra.sendNotification, + this.tokenCollectionHandler + ); + } else if (elicited.choice === 'create') { + const created = await createPreviewToken( + this.httpRequest, + MapboxApiBasedTool.mapboxApiEndpoint, + serverAccessToken!, + userName, + elicited.tokenNote, + elicited.urlRestrictions + ); + if (!created.success) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Failed to create token: ${created.error}` + } + ] + }; + } + publicToken = created.token!; + } else { + const created = await createPreviewToken( + this.httpRequest, + MapboxApiBasedTool.mapboxApiEndpoint, + serverAccessToken!, + userName + ); + if (!created.success) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Failed to auto-create token: ${created.error}` + } + ] + }; + } + publicToken = created.token!; + } + + previewTokenStorage.set(cacheKey, publicToken); + } catch (error) { + if (error instanceof ElicitationUnavailableError) { + // The client can't be asked at all (e.g. no elicitation support). Fall + // back to a cached token if one exists rather than failing outright. + if (storedToken) { + publicToken = storedToken; + } else { + return { + isError: true, + content: [ + { + type: 'text', + text: + 'Preview token required but client does not support elicitation. ' + + 'Please provide an accessToken parameter directly, or use a client that supports MCP elicitation (e.g., MCP Inspector, Cursor, VS Code).' + } + ] + }; + } + } else { + // The client was asked and the user declined/cancelled, or something + // else went wrong — surface it rather than silently reusing a cache. + return { + isError: true, + content: [ + { + type: 'text', + text: error instanceof Error ? error.message : String(error) + } + ] + }; + } + } + } + } + let beforeStyleId; let afterStyleId; try { // Process style IDs to get username/styleId format - beforeStyleId = this.processStyleId(input.before, input.accessToken); - afterStyleId = this.processStyleId(input.after, input.accessToken); + beforeStyleId = this.processStyleId(input.before, publicToken); + afterStyleId = this.processStyleId(input.after, publicToken); } catch (error) { return { content: [ @@ -111,7 +331,7 @@ export class StyleComparisonTool extends BaseTool< // Build the comparison URL const params = new URLSearchParams(); - params.append('access_token', input.accessToken); + params.append('access_token', publicToken); params.append('before', beforeStyleId); params.append('after', afterStyleId); diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 5e9237d..25fabe5 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -25,6 +25,7 @@ import { ValidateExpressionTool } from './validate-expression-tool/ValidateExpre import { ValidateGeojsonTool } from './validate-geojson-tool/ValidateGeojsonTool.js'; import { ValidateStyleTool } from './validate-style-tool/ValidateStyleTool.js'; import { httpRequest } from '../utils/httpPipeline.js'; +import { localHttpTokenCollectionHandler } from '../utils/tokenCollectionServer.js'; /** * Core tools that work in all MCP clients without requiring special capabilities @@ -36,13 +37,19 @@ export const CORE_TOOLS = [ new RetrieveStyleTool({ httpRequest }), new UpdateStyleTool({ httpRequest }), new DeleteStyleTool({ httpRequest }), - new PreviewStyleTool(), + new PreviewStyleTool({ + httpRequest, + tokenCollectionHandler: localHttpTokenCollectionHandler + }), new StyleBuilderTool(), new GeojsonPreviewTool(), new CheckColorContrastTool(), new CompareStylesTool(), new OptimizeStyleTool(), - new StyleComparisonTool(), + new StyleComparisonTool({ + httpRequest, + tokenCollectionHandler: localHttpTokenCollectionHandler + }), new CreateTokenTool({ httpRequest }), new ListTokensTool({ httpRequest }), new BoundingBoxTool(), diff --git a/src/utils/jwtUtils.ts b/src/utils/jwtUtils.ts index 9a3d3e6..65ef969 100644 --- a/src/utils/jwtUtils.ts +++ b/src/utils/jwtUtils.ts @@ -12,8 +12,16 @@ export function mapboxApiEndpoint() { } /** - * Extracts the username from the Mapbox access token. - * Mapbox tokens are JWT tokens where the payload contains the username. + * Extracts the username from the Mapbox access token's `u` claim, WITHOUT verifying + * the token's signature — this only base64-decodes the JWT payload. + * + * The returned value is only as trustworthy as whatever already validated this + * token (e.g. the Mapbox API itself rejecting the token value if it's forged, or an + * upstream gateway that verifies bearers before this code ever sees them). Do not + * use this result alone for authorization decisions or as a cache key for anything + * sensitive — hash the raw token instead if you need a value tied to the specific + * credential presented (see `cacheKeyFor` in `tokenElicitation.ts` for why). + * * @throws Error if the token is not set, invalid, or doesn't contain username */ export function getUserNameFromToken(accessToken?: string): string { diff --git a/src/utils/tokenCollectionServer.ts b/src/utils/tokenCollectionServer.ts new file mode 100644 index 0000000..fb6f1bd --- /dev/null +++ b/src/utils/tokenCollectionServer.ts @@ -0,0 +1,211 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { + createServer, + type IncomingMessage, + type ServerResponse +} from 'node:http'; +import { randomBytes } from 'node:crypto'; + +/** + * Collects a sensitive value (a Mapbox access token) out-of-band from the MCP + * connection itself, per the MCP spec's requirement that servers MUST use URL-mode + * elicitation — not form-mode — for credentials ("Servers MUST NOT use form mode + * elicitation to request sensitive information such as passwords, API keys, access + * tokens, or payment credentials"). + * + * `collect()` starts the out-of-band flow and returns the URL to present to the user + * via a URL-mode elicitation request, plus a promise that resolves once the value has + * been submitted (or rejects on timeout/failure). Callers MUST call `cancel()` if they + * give up waiting on `result` (e.g. because the URL-mode elicitation request itself was + * declined or unsupported) to release the resources started by `collect()`. + */ +export interface TokenCollectionHandler { + collect(options: { timeoutMs: number }): Promise<{ + /** URL to present to the user via URL-mode elicitation. */ + url: string; + /** Resolves with the raw submitted value, or rejects on timeout/failure. */ + result: Promise; + /** Releases resources without resolving `result`. Safe to call after `result` + * has already settled — a no-op in that case. */ + cancel: () => void; + }>; +} + +/** Bounds how much of a POST body is buffered before the token itself is even + * inspected — independent defense-in-depth against a huge submitted body, on top of + * whatever length limit the caller enforces on the parsed token value afterward. */ +const MAX_BODY_BYTES = 16 * 1024; + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + req.destroy(); + reject(new Error('Request body exceeds the maximum accepted size.')); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + req.on('error', reject); + }); +} + +function formPage(): string { + return ` + +Mapbox MCP DevKit — Preview Token + +

Provide your Mapbox public token

+

This page is running locally on your own machine and was opened at your request by + your MCP client. The token you submit here is sent directly to the MCP DevKit server + process running on this machine — not to your MCP client, and not through chat + history.

+
+ + +
+ +`; +} + +const SUCCESS_PAGE = ` + +Mapbox MCP DevKit — Preview Token + +

Token received

+

You can close this window and return to your MCP client.

+ +`; + +/** + * Default `TokenCollectionHandler`: a short-lived HTTP server bound to the loopback + * interface only (`127.0.0.1`, never `0.0.0.0`), serving exactly one form at a random, + * single-use path and accepting exactly one submission before tearing itself down. This + * is the same pattern CLI OAuth flows use (`gh auth login`, `gcloud auth login`) — a + * local callback server the user's own browser can reach. + * + * This only makes sense when the MCP server process and the user's browser run on the + * same machine, which is true for this package's only shipped entry point + * (`src/index.ts`, stdio-based). It is NOT appropriate for a deployment where the + * server process runs somewhere other than the end user's own machine (e.g. + * hosted-mcp-server, a cloud deployment) — a URL pointing at `127.0.0.1` there would + * resolve to the *browser's* loopback interface, where nothing is listening. See + * `ENABLE_LOCAL_URL_ELICITATION` in `tokenElicitation.ts` for the opt-out for that case. + */ +export class LocalHttpTokenCollectionHandler implements TokenCollectionHandler { + async collect(options: { timeoutMs: number }): Promise<{ + url: string; + result: Promise; + cancel: () => void; + }> { + const server = createServer(); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + + const address = server.address(); + if (address === null || typeof address === 'string') { + server.close(); + throw new Error( + 'Failed to determine local token-collection server address.' + ); + } + + const path = `/${randomBytes(24).toString('hex')}`; + const url = `http://127.0.0.1:${address.port}${path}`; + + let settled = false; + let resolveResult!: (value: string) => void; + let rejectResult!: (reason: unknown) => void; + const result = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + + const finish = (action: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timeoutHandle); + server.close(); + action(); + }; + + server.on('request', (req: IncomingMessage, res: ServerResponse) => { + if (req.url !== path) { + res.writeHead(404).end(); + return; + } + + if (req.method === 'GET') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(formPage()); + return; + } + + if (req.method === 'POST') { + readBody(req) + .then((body) => { + const token = new URLSearchParams(body).get('token'); + if (!token) { + // Let the user retry on the same page rather than tearing the server + // down over one malformed submission. + res.writeHead(400, { + 'content-type': 'text/html; charset=utf-8' + }); + res.end(formPage()); + return; + } + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(SUCCESS_PAGE); + finish(() => resolveResult(token)); + }) + .catch((error) => { + res.writeHead(400).end(); + finish(() => + rejectResult( + error instanceof Error ? error : new Error(String(error)) + ) + ); + }); + return; + } + + res.writeHead(405).end(); + }); + + server.on('error', (error) => finish(() => rejectResult(error))); + + const timeoutHandle = setTimeout(() => { + finish(() => + rejectResult( + new Error( + `Timed out after ${options.timeoutMs}ms waiting for the token to be submitted.` + ) + ) + ); + }, options.timeoutMs); + + return { + url, + result, + cancel: () => + finish(() => rejectResult(new Error('Token collection was cancelled.'))) + }; + } +} + +/** Shared default instance, wired into the tools' constructors in `toolRegistry.ts` the + * same way the `httpRequest` singleton in `httpPipeline.ts` is. */ +export const localHttpTokenCollectionHandler = + new LocalHttpTokenCollectionHandler(); diff --git a/src/utils/tokenElicitation.ts b/src/utils/tokenElicitation.ts new file mode 100644 index 0000000..86381e5 --- /dev/null +++ b/src/utils/tokenElicitation.ts @@ -0,0 +1,733 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { createHash, randomUUID } from 'node:crypto'; +import { + ElicitResultSchema, + ErrorCode, + McpError +} from '@modelcontextprotocol/sdk/types.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import { redactToken } from '../tools/MapboxApiBasedTool.js'; +import { + localHttpTokenCollectionHandler, + type TokenCollectionHandler +} from './tokenCollectionServer.js'; +import type { HttpRequest } from './types.js'; + +/** + * The per-call `sendRequest`/`sendNotification` a tool receives via `RequestHandlerExtra` + * — bound correctly to whichever session actually made the current call, unlike a + * `Server` instance stashed on `this` (see {@link elicitPreviewToken} for why that + * matters). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SendRequest = RequestHandlerExtra['sendRequest']; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SendNotification = RequestHandlerExtra['sendNotification']; + +/** + * How long to wait for the client to answer an elicitation prompt before giving up. + * Set explicitly (matching the SDK's own `DEFAULT_REQUEST_TIMEOUT_MSEC`) rather than + * omitting `timeout` and letting the SDK default apply implicitly — a future SDK bump + * changing that constant shouldn't silently change how long a session-holding-open + * elicitation call can be kept pending. A human genuinely filling out the dialog needs + * on the order of tens of seconds; a client that never answers at all (accidentally or + * as a resource-exhaustion attempt — each pending call ties up its session's live + * connection for the duration) shouldn't be able to hold the request open indefinitely. + * Capping the number of *concurrent* pending elicitations a single deployment will + * tolerate is an infrastructure-level concern (rate limiting, connection limits) outside + * what this package can enforce on its own. + */ +const ELICITATION_TIMEOUT_MSEC = 60_000; + +/** Real Mapbox tokens are well under this; anything longer is almost certainly not a + * token at all. Enforced against whatever a user submits via URL-mode token collection + * (see {@link validatePublicPreviewToken}) — the local collection page's own `maxLength` + * attribute is advisory only, since it's just client-side HTML. */ +const MAX_TOKEN_LENGTH = 2048; + +/** + * How long to wait for the out-of-band URL-mode collection flow to complete once the + * user has agreed to open the link — separate from, and much longer than, + * {@link ELICITATION_TIMEOUT_MSEC}, since this step requires a human to actually open a + * browser and type something, not just click a button in an already-open dialog. + */ +const URL_MODE_COLLECTION_TIMEOUT_MSEC = 5 * 60_000; + +/** + * Opt-in, not opt-out: defaults to disabled, and must be set to `"true"` to enable + * URL-mode token collection. When disabled (the default), the "provide" choice falls + * straight back to the "client does not support elicitation, please provide accessToken + * directly" message. + * + * The default {@link TokenCollectionHandler} (`localHttpTokenCollectionHandler`) starts + * a server bound to `127.0.0.1` on *this* process's own machine — correct only when this + * package runs as a local stdio server, and wrong for any deployment where this process + * runs somewhere other than the end user's own machine (e.g. hosted-mcp-server, a cloud + * deployment): there, the URL would point at the *browser's* loopback interface, where + * nothing is listening. This package's own stdio entry point (`src/index.ts`) is the + * only context confirmed safe, so it opts in there explicitly (unless the environment + * already set this var, which is left untouched); every other embedder — this package + * used as a library, a test harness, a future unknown embedder — stays safe by default + * and must deliberately opt in only after confirming the same machine/browser + * relationship holds, rather than relying on every embedder remembering to opt out. + */ +function isLocalUrlElicitationEnabled(): boolean { + return process.env.ENABLE_LOCAL_URL_ELICITATION === 'true'; +} + +const MAX_TOKEN_NOTE_LENGTH = 256; + +/** Matches CreateTokenTool's own `allowedUrls` cap. */ +const MAX_URL_RESTRICTIONS = 100; + +/** Bounds the raw comma-separated `urlRestrictions` string before it's split into an + * array. The array-length cap above only limits the *parsed* result — nothing stops a + * client from returning one unsplit string far larger than 100 short URLs would ever + * require, so this is enforced independently and before the `.split()` call. */ +const MAX_URL_RESTRICTIONS_RAW_LENGTH = 4096; + +/** + * Thrown when the elicitation request itself could not be delivered or answered — + * most commonly because the connected client doesn't implement elicitation at all, so + * `sendRequest` rejects (e.g. with a "method not found" style protocol error) rather + * than resolving with a real `ElicitResult`. Distinguished from a normal decline/cancel + * (a *successful* response the user just said no to) so callers can fall back to a + * cached token instead of surfacing a scary error for something the user never saw. + */ +export class ElicitationUnavailableError extends Error {} + +/** + * Token choice options for preview token elicitation + */ +export type TokenChoice = 'provide' | 'create' | 'auto'; + +/** + * A literal Mapbox temporary token (`tk.*`) is scoped to a single short-lived + * session and is not granted `tokens:write`, so attempting to create a new + * token with one is a guaranteed API rejection. This is a narrow, string-shape + * check on the server's own access token (e.g. `MAPBOX_ACCESS_TOKEN=tk...`) — + * it lets callers skip a doomed round trip to the Tokens API in that specific + * case. + * + * It is not a general test for "can this token create tokens". Servers that + * embed this package behind their own auth (for example, an OAuth-based + * hosted deployment) may pass through a bearer that isn't shaped like a + * Mapbox token at all yet still lacks `tokens:write` for its own reasons — + * this check can't see that, and the create/auto-create paths fall through to + * the Tokens API and surface whatever error it returns (see + * {@link createPreviewToken}). + */ +export function isTemporaryServerToken(accessToken: string): boolean { + return accessToken.startsWith('tk.'); +} + +/** + * Result of an attempt to create a new preview token via the Mapbox Tokens API. + */ +export interface CreatePreviewTokenResult { + success: boolean; + token?: string; + error?: string; +} + +/** + * Result of the initial (form-mode) token-choice elicitation. Deliberately does not + * carry a token value — see {@link collectProvidedToken} for how the `'provide'` choice + * is followed up on. `tokenNote`/`urlRestrictions` are non-sensitive configuration for + * the `'create'` choice, not credentials, so they stay in form mode. + */ +export interface ElicitedTokenInfo { + choice: TokenChoice; + urlRestrictions?: string[]; + tokenNote?: string; +} + +/** + * Existing token info for display + */ +export interface ExistingTokenInfo { + id: string; + note: string; + scopes: string[]; +} + +/** + * Elicits preview token information from the user via MCP elicitation. + * This keeps the token out of chat history for better security. + * + * Takes the per-call `extra.sendRequest` from `RequestHandlerExtra`, not a `Server` + * instance. `Server#elicitInput()` would be the obvious choice, but it reads from + * `this` — and a tool that stashes its `Server` on `this.server` in `installTo()` and + * reads it back later in `execute()` is reading *shared, mutable* state: if the same + * tool instance is ever reused across multiple concurrent sessions (singleton tool + * instances installed onto a new session's server on every connection — exactly what + * `CORE_TOOLS` are, and what mcp-server's own scripts/dev-http-server.ts and + * hosted-mcp-server's request handling both do), `this.server` durably points at + * whichever session connected *last*, not whichever session is making *this* call. + * That sends the "paste your token" prompt to a different, uninvolved client, and + * whatever it submits comes back as this call's result — a real cross-session + * hijack, not a hypothetical. `extra.sendRequest` is supplied fresh per call by the + * SDK, correctly scoped to the session that made the current request, so it can't be + * clobbered by another session connecting in between. + * + * @param sendRequest - The current call's `extra.sendRequest` + * @param existingTokens - List of user's existing public tokens + * @param canCreateTokens - Whether the server's own access token is able to create + * new tokens. When false (the server is authenticated with a `tk.*` temporary + * token, see {@link isTemporaryServerToken}), the "create" and "auto" options are + * omitted from the dialog entirely, since selecting them would only fail against + * the Mapbox API. Defaults to `true` for callers that haven't checked. + * @returns Elicited token information based on user's choice + * @throws {ElicitationUnavailableError} if the client can't be asked at all (e.g. it + * doesn't implement elicitation) + * @throws {Error} if the client was asked but the user declined or cancelled + */ +export async function elicitPreviewToken( + sendRequest: SendRequest, + existingTokens: ExistingTokenInfo[], + canCreateTokens = true +): Promise { + const hasExistingTokens = existingTokens.length > 0; + const tokenList = hasExistingTokens + ? existingTokens + .map((t) => `- ${t.note || t.id}: ${t.scopes.join(', ')}`) + .join('\n') + : 'No existing public tokens found.'; + + const choices: TokenChoice[] = canCreateTokens + ? ['provide', 'create', 'auto'] + : ['provide']; + const choiceNames = canCreateTokens + ? [ + 'I have a token to provide', + 'Create a new preview token with custom settings', + 'Auto-create a basic preview token for me' + ] + : ['I have a token to provide']; + + const creationNote = canCreateTokens + ? 'For best security, consider using a URL-restricted token that only works on your domains.' + : "This server is authenticated with a temporary session token, which can't create new " + + 'Mapbox tokens. Choose "I have a token to provide" below.'; + + // Mirrors what Server#elicitInput() builds internally (method + form-mode params), + // but sent via the current call's own sendRequest rather than a stashed Server. Note + // there is no `token` field here: the MCP spec requires credentials to be collected + // via URL-mode elicitation, not form mode (see collectProvidedToken below) — only + // non-sensitive choice/configuration fields belong in this dialog. + let result; + try { + result = await sendRequest( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: `Preview Token Setup + +Preview URLs require a public token with styles:read scope. This token will be visible in the preview URL. + +${hasExistingTokens ? 'Your existing public tokens:\n' + tokenList : tokenList} + +${creationNote} + +If you choose "I have a token to provide", you'll get a separate link to submit it securely — the token itself is never entered into this form.`, + requestedSchema: { + type: 'object', + properties: { + choice: { + type: 'string', + title: 'Token Option', + description: 'How would you like to provide the preview token?', + enum: choices, + enumNames: choiceNames + }, + tokenNote: { + type: 'string', + title: 'Token Name (Optional)', + description: + 'A descriptive name for your new token (e.g., "Preview Token - Production")', + maxLength: 256 + }, + urlRestrictions: { + type: 'string', + title: 'URL Restrictions (Optional)', + description: + 'Comma-separated URLs to restrict token usage (e.g., "https://yourdomain.com/*,https://staging.yourdomain.com/*")', + maxLength: MAX_URL_RESTRICTIONS_RAW_LENGTH + } + }, + required: ['choice'] + } + } + }, + ElicitResultSchema, + { timeout: ELICITATION_TIMEOUT_MSEC } + ); + } catch (error) { + if ( + error instanceof McpError && + (error.code === ErrorCode.RequestTimeout || + (error.code === ErrorCode.InvalidRequest && + /cancelled/i.test(error.message))) + ) { + // The client understood the request and either never answered it in time, or + // the caller aborted it — distinct from a client that doesn't implement + // elicitation at all. Re-thrown as-is (not wrapped as "unavailable") so callers + // surface it instead of silently falling back to a stale cached token: a caller + // passing `useCustomToken: true` explicitly wants a fresh answer, not the token + // this call was meant to replace. + throw error; + } + throw new ElicitationUnavailableError( + error instanceof Error ? error.message : String(error) + ); + } + + // Check if user accepted or declined + if (result.action !== 'accept' || !result.content) { + throw new Error('Token elicitation was cancelled or declined by user'); + } + + // Parse the result. requestedSchema above is only a hint for the client's own form + // UI — nothing stops a client (malicious, or just not honoring it) from returning a + // differently-shaped payload, so every field's runtime type is checked explicitly + // rather than blindly cast. An unchecked cast here previously let a non-string + // token silently skip the length guard below, and let a non-string urlRestrictions + // reach `.split()` and throw an unclassified TypeError instead of a clear error. + const rawChoice = result.content.choice; + if ( + typeof rawChoice !== 'string' || + !choices.includes(rawChoice as TokenChoice) + ) { + throw new Error( + `Client returned an unrecognized token choice (${JSON.stringify(rawChoice)}); expected one of: ${choices.join(', ')}.` + ); + } + const choice = rawChoice as TokenChoice; + + const tokenNote = result.content.tokenNote; + if (tokenNote !== undefined && typeof tokenNote !== 'string') { + throw new Error( + 'Client returned a non-string value for the tokenNote field.' + ); + } + + const urlRestrictionsStr = result.content.urlRestrictions; + if ( + urlRestrictionsStr !== undefined && + typeof urlRestrictionsStr !== 'string' + ) { + throw new Error( + 'Client returned a non-string value for the urlRestrictions field.' + ); + } + if ( + urlRestrictionsStr !== undefined && + urlRestrictionsStr.length > MAX_URL_RESTRICTIONS_RAW_LENGTH + ) { + throw new Error( + `Provided urlRestrictions value is ${urlRestrictionsStr.length} characters, which exceeds the ${MAX_URL_RESTRICTIONS_RAW_LENGTH}-character maximum.` + ); + } + + const urlRestrictions = urlRestrictionsStr + ? urlRestrictionsStr + .split(',') + .map((url) => url.trim()) + .filter((url) => url.length > 0) + : undefined; + + // The requestedSchema's minLength/maxLength are hints for the client's own form UI, + // not a security boundary — nothing stops a client (malicious, or just not honoring + // the hints) from returning arbitrary content. + if (tokenNote !== undefined && tokenNote.length > MAX_TOKEN_NOTE_LENGTH) { + throw new Error( + `Token name is ${tokenNote.length} characters, which exceeds the ${MAX_TOKEN_NOTE_LENGTH}-character maximum.` + ); + } + if ( + urlRestrictions !== undefined && + urlRestrictions.length > MAX_URL_RESTRICTIONS + ) { + throw new Error( + `Provided ${urlRestrictions.length} URL restrictions, which exceeds the ${MAX_URL_RESTRICTIONS}-URL maximum.` + ); + } + + return { + choice, + urlRestrictions, + tokenNote + }; +} + +/** + * Validates a candidate preview token submitted through URL-mode collection. Applies + * the exact same rules every other path that produces a preview token already enforces + * (the `accessToken` input parameter, and the create/auto-create API responses): must + * be a non-empty string, under {@link MAX_TOKEN_LENGTH}, and `pk.*`-prefixed. A secret + * token (`sk.*`) submitted here is rejected for the same reason it's rejected + * everywhere else — it cannot be safely exposed in a preview URL. + */ +export function validatePublicPreviewToken(token: string): string { + if (token.length === 0) { + throw new Error('No token provided. Please provide a valid public token.'); + } + if (token.length > MAX_TOKEN_LENGTH) { + throw new Error( + `Provided token is ${token.length} characters, which exceeds the ${MAX_TOKEN_LENGTH}-character maximum for a Mapbox token.` + ); + } + if (!token.startsWith('pk.')) { + throw new Error( + 'Invalid access token. Only public tokens (starting with pk.*) are allowed for preview URLs. Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs.' + ); + } + return token; +} + +/** + * Collects a preview token via URL-mode elicitation, per the MCP spec's requirement + * that credentials MUST NOT be requested via form mode. Sends a `mode: 'url'` + * elicitation pointing at the URL `tokenCollectionHandler.collect()` provides, waits for + * the user to both consent (the elicitation response) and actually submit a value (the + * out-of-band collection promise), then validates it exactly like every other token + * source in this file. + * + * @throws {ElicitationUnavailableError} if URL-mode collection is disabled + * ({@link isLocalUrlElicitationEnabled}) or the client can't be asked at all (e.g. it + * doesn't support URL-mode elicitation) — callers should treat this exactly like the + * existing "client doesn't support elicitation" fallback. + * @throws {Error} if the client was asked but declined/cancelled, if the out-of-band + * submission times out, or if the submitted value fails validation. + */ +export async function collectProvidedToken( + sendRequest: SendRequest, + sendNotification: SendNotification, + tokenCollectionHandler: TokenCollectionHandler = localHttpTokenCollectionHandler +): Promise { + if (!isLocalUrlElicitationEnabled()) { + throw new ElicitationUnavailableError( + 'URL-mode token collection is disabled on this deployment (ENABLE_LOCAL_URL_ELICITATION=false).' + ); + } + + const { url, result, cancel } = await tokenCollectionHandler.collect({ + timeoutMs: URL_MODE_COLLECTION_TIMEOUT_MSEC + }); + + const elicitationId = randomUUID(); + let consent; + try { + consent = await sendRequest( + { + method: 'elicitation/create', + params: { + mode: 'url', + elicitationId, + message: + 'Please provide your Mapbox public token to continue. This opens a page ' + + 'served locally on your own machine — the token goes directly to the MCP ' + + 'DevKit server process, not through this chat.', + url + } + }, + ElicitResultSchema, + { timeout: ELICITATION_TIMEOUT_MSEC } + ); + } catch (error) { + cancel(); + if ( + error instanceof McpError && + (error.code === ErrorCode.RequestTimeout || + (error.code === ErrorCode.InvalidRequest && + /cancelled/i.test(error.message))) + ) { + throw error; + } + throw new ElicitationUnavailableError( + error instanceof Error ? error.message : String(error) + ); + } + + if (consent.action !== 'accept') { + cancel(); + throw new Error('Token elicitation was cancelled or declined by user'); + } + + let rawToken: string; + try { + rawToken = await result; + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)); + } + + const token = validatePublicPreviewToken(rawToken); + + // Best-effort UX signal that the out-of-band flow completed; not required for + // correctness (our own `await result` above is what actually drives completion). + try { + await sendNotification({ + method: 'notifications/elicitation/complete', + params: { elicitationId } + }); + } catch { + // Ignore — some clients may not support this notification at all. + } + + return token; +} + +/** + * Derives a `previewTokenStorage` cache key from the server's own access token, rather + * than from the username decoded out of it. `getUserNameFromToken` never verifies a + * JWT's signature — it just base64-decodes the payload — so an unverified `u` claim is + * not a safe cache key: two different presented tokens could decode to the same + * username without this process ever independently confirming that. Hosted deployments + * (e.g. hosted-mcp-server) verify the bearer upstream before it reaches this code, but + * this package is also usable standalone or behind other gateways that may not, so the + * cache itself shouldn't depend on that assumption. Hashing the full token ties the + * cache slot to the exact credential presented instead. + */ +export function cacheKeyFor(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +/** Bounds previewTokenStorage's worst-case memory footprint regardless of how many + * distinct cache keys ever get presented — deliberately independent of *why* the + * count might grow (a long-lived multi-tenant deployment accumulating real distinct + * users over time is just as capable of doing this as anything adversarial). */ +const MAX_CACHED_TOKENS = 1000; + +/** + * Session-level storage for preview token preferences, keyed by {@link cacheKeyFor}. + * In a real implementation, this could be stored in a database or cache. + * + * Bounded LRU: evicts the least-recently-used entry once at capacity, so memory usage + * has a fixed ceiling no matter how many distinct keys are ever presented. + */ +class PreviewTokenStorage { + private tokenCache = new Map(); + + /** + * Store a preview token under the given cache key + */ + set(cacheKey: string, token: string): void { + // Re-inserting moves a key to the end (most-recently-used) in Map's iteration + // order; delete first so an existing key doesn't just get its value updated + // in place at its old position. + this.tokenCache.delete(cacheKey); + this.tokenCache.set(cacheKey, token); + + if (this.tokenCache.size > MAX_CACHED_TOKENS) { + const oldestKey = this.tokenCache.keys().next().value; + if (oldestKey !== undefined) { + this.tokenCache.delete(oldestKey); + } + } + } + + /** + * Get the stored preview token for the given cache key + */ + get(cacheKey: string): string | undefined { + const token = this.tokenCache.get(cacheKey); + if (token !== undefined) { + // Bump to most-recently-used on read too, so an actively-used entry survives + // eviction even if it was one of the first ever inserted. + this.tokenCache.delete(cacheKey); + this.tokenCache.set(cacheKey, token); + } + return token; + } + + /** + * Clear the stored token for the given cache key + */ + clear(cacheKey: string): void { + this.tokenCache.delete(cacheKey); + } + + /** + * Clear all stored tokens + */ + clearAll(): void { + this.tokenCache.clear(); + } +} + +/** + * Global preview token storage instance + */ +export const previewTokenStorage = new PreviewTokenStorage(); + +/** + * Lists the user's existing public tokens with `styles:read` scope, to show as options + * during elicitation. Goes through the shared HttpPipeline rather than a bare `fetch`, + * so retry/User-Agent policies and span redaction apply to this call like any other + * Mapbox API request. Failures are treated as non-fatal (an empty list) since this is + * only used to populate a picker, not required for the elicitation flow to work. + */ +export async function listPublicPreviewTokens( + httpRequest: HttpRequest, + mapboxApiEndpoint: string, + accessToken: string, + userName: string +): Promise { + try { + const response = await httpRequest( + `${mapboxApiEndpoint}tokens/v2/${encodeURIComponent(userName)}?access_token=${accessToken}` + ); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + const tokens = data as Array<{ + id: string; + note: string; + scopes: string[]; + token?: string; + }>; + + return tokens + .filter( + (t) => t.token?.startsWith('pk.') && t.scopes.includes('styles:read') + ) + .map((t) => ({ + id: t.id, + note: t.note || t.id, + scopes: t.scopes + })); + } catch { + return []; + } +} + +/** + * Creates a new preview token via the Mapbox Tokens API, scoped to the minimum needed + * for a style/comparison preview URL (`styles:read`, `styles:tiles`, `fonts:read` — all + * public scopes, so the API is guaranteed to hand back a `pk.*` token rather than `sk.*`; + * `styles:download` in particular is a secret-only scope and must not be requested here). + * + * Skips the request and returns a structured failure immediately when the server's own + * access token is a literal Mapbox temporary token (`tk.*`, see + * {@link isTemporaryServerToken}) — that shape is a guaranteed rejection. Any other + * caller that lacks `tokens:write` (for instance a hosted deployment's own auth bearer, + * which isn't shaped like a Mapbox token at all) isn't detectable ahead of time, so that + * case falls through to the API call below and gets a scope-shortage hint appended to + * whatever error the Tokens API returns. + */ +export async function createPreviewToken( + httpRequest: HttpRequest, + mapboxApiEndpoint: string, + accessToken: string, + userName: string, + note?: string, + urlRestrictions?: string[] +): Promise { + if (isTemporaryServerToken(accessToken)) { + return { + success: false, + error: + "This server is authenticated with a temporary session token (tk.*), which can't " + + 'create new Mapbox tokens. Provide an existing public token (pk.*) instead, either ' + + 'via the elicitation dialog\'s "I have a token to provide" option or the ' + + '`accessToken` parameter.' + }; + } + + try { + const tokenNote = + note || `MCP Preview Token - ${new Date().toISOString().split('T')[0]}`; + + const body: { + note: string; + scopes: string[]; + allowedUrls?: string[]; + } = { + note: tokenNote, + scopes: ['styles:read', 'styles:tiles', 'fonts:read'] + }; + + if (urlRestrictions && urlRestrictions.length > 0) { + body.allowedUrls = urlRestrictions; + } + + const response = await httpRequest( + `${mapboxApiEndpoint}tokens/v2/${encodeURIComponent(userName)}?access_token=${accessToken}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + let message = `Failed to create token: ${response.status} ${redactToken(errorText)}`; + + // Creating a token always requires `tokens:write` on the caller's own access + // token, so a 401/403 here is a permission problem — surface the same + // scope-shortage hint MapboxApiBasedTool#handleApiError gives other tools, + // rather than leaving the caller to guess from a bare status code and body. + const looksLikePermissionError = + response.status === 401 || + response.status === 403 || + /scope|permission/i.test(errorText); + if (looksLikePermissionError) { + message += + '\n\nThis looks like a scope/permission issue: creating a token requires ' + + "`tokens:write` on the caller's own access token. If you're running behind a " + + 'hosted or proxied deployment, that token may not carry it even though it ' + + 'works for other operations. Use "I have a token to provide" with an ' + + 'existing public token instead.'; + } + + return { + success: false, + error: message + }; + } + + const data = (await response.json()) as { token?: unknown }; + + if (typeof data.token !== 'string') { + return { + success: false, + error: + 'API response did not include a token. Unexpected response shape from the Mapbox Tokens API.' + }; + } + + if (!data.token.startsWith('pk.')) { + return { + success: false, + error: `API returned a non-public token (${data.token.substring(0, 3)}...). Preview tokens must be public tokens (pk.*) that can be safely exposed in URLs.` + }; + } + + return { + success: true, + token: data.token + }; + } catch (error) { + // A network-level failure (e.g. a misconfigured endpoint) can throw with the full + // request URL — including this call's own `access_token=...` query param — in its + // message. Redact before this reaches a caller, the same as every other Mapbox API + // tool's errors do via MapboxApiBasedTool#run(); these tools don't extend that + // class, and this is a returned value rather than a thrown rejection regardless, + // so that redaction wouldn't apply here even if they did. + return { + success: false, + error: redactToken( + error instanceof Error ? error.message : 'Unknown error creating token' + ) + }; + } +} diff --git a/test/integration/elicitationOverHttp.test.ts b/test/integration/elicitationOverHttp.test.ts new file mode 100644 index 0000000..bee880a --- /dev/null +++ b/test/integration/elicitationOverHttp.test.ts @@ -0,0 +1,402 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Drives preview_style_tool / style_comparison_tool's elicitation flow over a real + * Streamable HTTP MCP connection — a real `Server` sending an `elicitation/create` + * request and a real `Client` answering it, not a hand-built stand-in for `this.server`. + * This is the only place that exercises the actual wire protocol; every other test for + * these tools fakes `tool['server']` directly and never proves the SDK's own capability + * negotiation and request/response plumbing works end to end. + * + * The server is session-scoped (one `McpServer`/transport pair per `Mcp-Session-Id`, + * matching the SDK's documented stateful-mode example) — see the comment on + * `startHarness` below for why that matters specifically for elicitation. The bearer + * token from the `Authorization` header is attached to the raw Node request as `.auth` + * before handing off to `StreamableHTTPServerTransport`, mirroring hosted-mcp-server's + * src/routes/mcp.ts. + * + * The Mapbox Tokens API itself is never hit — `httpRequest` is a mock, so this stays + * fully offline and deterministic (per CLAUDE.md: real network calls are never + * acceptable in tests). + */ + +import { createServer, type IncomingMessage, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + ElicitRequestSchema, + type ElicitRequest, + type ElicitResult +} from '@modelcontextprotocol/sdk/types.js'; +import { PreviewStyleTool } from '../../src/tools/preview-style-tool/PreviewStyleTool.js'; +import { StyleComparisonTool } from '../../src/tools/style-comparison-tool/StyleComparisonTool.js'; +import { previewTokenStorage } from '../../src/utils/tokenElicitation.js'; +import type { TokenCollectionHandler } from '../../src/utils/tokenCollectionServer.js'; +import type { HttpRequest } from '../../src/utils/types.js'; + +const TK_SERVER_TOKEN = + 'tk.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; +// Shaped like the hosted MCP endpoint's real bearer: a plain 3-part JWT with no +// pk./sk./tk. prefix (see PR #57 discussion) — `isTemporaryServerToken` can't +// recognize this as unable to create tokens, only the API call itself can. +const OAUTH_STYLE_SERVER_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.signature'; +const EXISTING_PUBLIC_TOKEN = + 'pk.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +/** A mock HttpRequest that fails any call not explicitly queued, so an unexpected + * network attempt (e.g. the tk.* guard failing to short-circuit) shows up as a loud + * test failure instead of a silent pass. */ +function mockHttpRequest( + handlers: Record<'GET' | 'POST', () => Response> +): HttpRequest { + return vi.fn(async (_url, init) => { + const method = ((init?.method as string) || 'GET').toUpperCase() as + | 'GET' + | 'POST'; + const handler = handlers[method]; + if (!handler) { + throw new Error(`Unexpected ${method} request in test`); + } + return handler(); + }) as unknown as HttpRequest; +} + +interface TestHarness { + baseUrl: URL; + close(): Promise; +} + +/** A fake TokenCollectionHandler that resolves immediately with `token`, instead of + * starting a real local server and waiting for an actual browser submission that will + * never come in a test — the URL-mode consent round trip itself is still real (driven + * by the real Client/Server elicitation exchange below); only the out-of-band + * submission step is faked. */ +function fakeTokenCollectionHandler(token: string): TokenCollectionHandler { + return { + collect: vi.fn().mockResolvedValue({ + url: 'http://127.0.0.1:1/fake-collection-url', + result: Promise.resolve(token), + cancel: vi.fn() + }) + }; +} + +/** + * Session-scoped stateful Streamable HTTP server: one `McpServer`/transport pair per + * `Mcp-Session-Id`, created on the first (`initialize`) request and reused for every + * subsequent request in that session — the documented SDK pattern for stateful mode. + * + * This matters specifically for elicitation: `Server#getClientCapabilities()` (which + * `PreviewStyleTool`/`StyleComparisonTool` check before calling `elicitInput`) is set + * once, on whichever `Server` instance processes the client's `initialize` request, and + * never persists anywhere else. A server that hands each incoming HTTP request to a + * brand-new `McpServer` (the "stateless" pattern used by mcp-server's + * scripts/dev-http-server.ts and by hosted-mcp-server's src/routes/mcp.ts, both + * `sessionIdGenerator: undefined`) means the `initialize` request and every later + * `tools/call` request land on *different* `Server` objects — the tool call's instance + * never saw the initialize handshake, so `getClientCapabilities()` is always + * `undefined` there regardless of what the connecting client actually declared. That + * was discovered by this test failing under a first attempt at a stateless harness; see + * PR #57 discussion. Whether that also silently breaks elicitation on the real hosted + * endpoint (independently of the tk.* issue) is worth following up on separately — it's + * not this PR's tool code, so it isn't re-litigated here. + */ +function startHarness( + previewHttpRequest: HttpRequest, + comparisonHttpRequest: HttpRequest = previewHttpRequest, + tokenCollectionHandler: TokenCollectionHandler = fakeTokenCollectionHandler( + EXISTING_PUBLIC_TOKEN + ) +): Promise { + const previewTool = new PreviewStyleTool({ + httpRequest: previewHttpRequest, + tokenCollectionHandler + }); + const comparisonTool = new StyleComparisonTool({ + httpRequest: comparisonHttpRequest, + tokenCollectionHandler + }); + + const sessions = new Map(); + + async function buildTransport(): Promise { + const mcpServer = new McpServer( + { name: 'elicitation-http-test', version: '1.0.0' }, + { capabilities: { tools: { listChanged: true } } } + ); + previewTool.installTo(mcpServer); + comparisonTool.installTo(mcpServer); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + sessions.set(sessionId, transport); + }, + onsessionclosed: (sessionId) => { + sessions.delete(sessionId); + } + }); + transport.onclose = () => { + if (transport.sessionId) sessions.delete(transport.sessionId); + }; + // Must resolve before handleRequest is called on this transport, or the + // first request (the client's `initialize`) races the server's own + // connect/wiring — harmless most of the time locally, but a real + // intermittent hang under CI's different scheduling/timing (surfaced as + // an MCP "Request timed out" on whichever test happened to lose the race). + await mcpServer.connect(transport); + return transport; + } + + const httpServer: Server = createServer((req, res) => { + void (async () => { + const authHeader = req.headers.authorization; + const match = authHeader?.match(/^Bearer (.+)$/); + const reqWithAuth = req as IncomingMessage & { + auth?: { token: string; clientId: string; scopes: string[] }; + }; + if (match) { + // Mirrors hosted-mcp-server's src/routes/mcp.ts: attach the bearer to the + // raw request as `.auth` before the transport touches it. + reqWithAuth.auth = { + token: match[1], + clientId: 'test-client', + scopes: [] + }; + } + + const sessionIdHeader = req.headers['mcp-session-id']; + const existing = + typeof sessionIdHeader === 'string' + ? sessions.get(sessionIdHeader) + : undefined; + const transport = existing ?? (await buildTransport()); + + try { + await transport.handleRequest(reqWithAuth, res); + } catch (error) { + if (!res.headersSent) { + res.writeHead(500).end(String(error)); + } + } + })(); + }); + + return new Promise((resolve) => { + httpServer.listen(0, '127.0.0.1', () => { + const { port } = httpServer.address() as AddressInfo; + resolve({ + baseUrl: new URL(`http://127.0.0.1:${port}/mcp`), + close: () => + new Promise((res, rej) => + httpServer.close((err) => (err ? rej(err) : res())) + ) + }); + }); + }); +} + +async function connectClient( + baseUrl: URL, + bearerToken: string, + onElicit: (request: ElicitRequest) => ElicitResult +): Promise { + const client = new Client( + { name: 'elicitation-http-test-client', version: '1.0.0' }, + // Declare both modes — an empty `elicitation: {}` is normalized by the SDK to + // form-only support, which would make the follow-up URL-mode request (see + // collectProvidedToken) look unsupported to these tests. + { capabilities: { elicitation: { form: {}, url: {} } } } + ); + client.setRequestHandler(ElicitRequestSchema, (request) => onElicit(request)); + + const transport = new StreamableHTTPClientTransport(baseUrl, { + requestInit: { headers: { Authorization: `Bearer ${bearerToken}` } } + }); + await client.connect(transport); + return client; +} + +/** + * `ElicitRequest.params` is a union (form-mode vs. url-mode); this narrows to the + * form-mode shape (`message` + `requestedSchema`) that `elicitPreviewToken` builds for + * the initial choice dialog — callers must only invoke this for that request, not for + * the follow-up URL-mode consent request `collectProvidedToken` sends afterward. + */ +function getChoiceEnum(request: ElicitRequest): string[] { + const params = request.params as unknown as { + requestedSchema: { properties: { choice: { enum: string[] } } }; + }; + return params.requestedSchema.properties.choice.enum; +} + +describe('preview/comparison token elicitation over real Streamable HTTP', () => { + let harness: TestHarness | undefined; + let client: Client | undefined; + const ORIGINAL_ENABLE_LOCAL_URL_ELICITATION = + process.env.ENABLE_LOCAL_URL_ELICITATION; + + beforeEach(() => { + previewTokenStorage.clearAll(); + // Opt-in (disabled by default) — this harness simulates the stdio entry point, + // the one context where src/index.ts enables this automatically. + process.env.ENABLE_LOCAL_URL_ELICITATION = 'true'; + }); + + afterEach(async () => { + await client?.close().catch(() => {}); + await harness?.close(); + client = undefined; + harness = undefined; + if (ORIGINAL_ENABLE_LOCAL_URL_ELICITATION === undefined) { + delete process.env.ENABLE_LOCAL_URL_ELICITATION; + } else { + process.env.ENABLE_LOCAL_URL_ELICITATION = + ORIGINAL_ENABLE_LOCAL_URL_ELICITATION; + } + }); + + it('trims the dialog to "provide" and never calls the Tokens API to create a token when the server token is tk.*', async () => { + const httpRequest = mockHttpRequest({ + // Listing only needs tokens:read, a separate scope from the tokens:write a + // tk.* token lacks, so it's still attempted — it just isn't used here since + // the client answers with 'provide'. + GET: () => jsonResponse(200, []), + POST: () => { + throw new Error('should not create a token for a tk.* server token'); + } + }); + harness = await startHarness(httpRequest); + + let receivedEnum: unknown; + client = await connectClient( + harness.baseUrl, + TK_SERVER_TOKEN, + (request) => { + // First the form-mode choice dialog, then the follow-up URL-mode consent + // request (see collectProvidedToken) — content is irrelevant for the + // latter; the actual token comes from the fake tokenCollectionHandler. + if (request.params.mode === 'url') { + return { action: 'accept' }; + } + receivedEnum = getChoiceEnum(request); + return { action: 'accept', content: { choice: 'provide' } }; + } + ); + + const result = await client.callTool({ + name: 'preview_style_tool', + arguments: { styleId: 'test-style' } + }); + + expect(result.isError).toBeFalsy(); + expect(receivedEnum).toEqual(['provide']); + expect(httpRequest).toHaveBeenCalledTimes(1); + }); + + it('offers all three choices for a non-tk.*-shaped server token and surfaces a scope hint when auto-create fails (the hosted-endpoint case)', async () => { + const httpRequest = mockHttpRequest({ + GET: () => jsonResponse(200, []), + POST: () => jsonResponse(403, { message: 'insufficient scopes' }) + }); + harness = await startHarness(httpRequest); + + let receivedEnum: unknown; + client = await connectClient( + harness.baseUrl, + OAUTH_STYLE_SERVER_TOKEN, + (request) => { + receivedEnum = getChoiceEnum(request); + return { action: 'accept', content: { choice: 'auto' } }; + } + ); + + const result = await client.callTool({ + name: 'preview_style_tool', + arguments: { styleId: 'test-style' } + }); + + expect(receivedEnum).toEqual(['provide', 'create', 'auto']); + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text?: string }>)[0] + .text as string; + expect(text).toContain('insufficient scopes'); + expect(text).toContain('tokens:write'); + }); + + it('completes auto-create end to end for a server token that can create tokens', async () => { + const httpRequest = mockHttpRequest({ + GET: () => jsonResponse(200, []), + POST: () => jsonResponse(200, { token: EXISTING_PUBLIC_TOKEN }) + }); + harness = await startHarness(httpRequest); + + client = await connectClient( + harness.baseUrl, + OAUTH_STYLE_SERVER_TOKEN, + () => ({ action: 'accept', content: { choice: 'auto' } }) + ); + + const result = await client.callTool({ + name: 'preview_style_tool', + arguments: { styleId: 'test-style' } + }); + + expect(result.isError).toBeFalsy(); + const text = (result.content as Array<{ type: string; text?: string }>)[0] + .text as string; + expect(text).toContain(`access_token=${EXISTING_PUBLIC_TOKEN}`); + }); + + it("trims style_comparison_tool's dialog the same way for a tk.* server token", async () => { + const httpRequest = mockHttpRequest({ + // Listing only needs tokens:read, a separate scope from the tokens:write a + // tk.* token lacks, so it's still attempted — it just isn't used here since + // the client answers with 'provide'. + GET: () => jsonResponse(200, []), + POST: () => { + throw new Error('should not create a token for a tk.* server token'); + } + }); + harness = await startHarness(httpRequest); + + let receivedEnum: unknown; + client = await connectClient( + harness.baseUrl, + TK_SERVER_TOKEN, + (request) => { + // First the form-mode choice dialog, then the follow-up URL-mode consent + // request (see collectProvidedToken) — content is irrelevant for the + // latter; the actual token comes from the fake tokenCollectionHandler. + if (request.params.mode === 'url') { + return { action: 'accept' }; + } + receivedEnum = getChoiceEnum(request); + return { action: 'accept', content: { choice: 'provide' } }; + } + ); + + const result = await client.callTool({ + name: 'style_comparison_tool', + arguments: { before: 'mapbox/streets-v12', after: 'mapbox/outdoors-v12' } + }); + + expect(result.isError).toBeFalsy(); + expect(receivedEnum).toEqual(['provide']); + expect(httpRequest).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/security/cross-session-elicitation-hijack.test.ts b/test/security/cross-session-elicitation-hijack.test.ts new file mode 100644 index 0000000..11e849c --- /dev/null +++ b/test/security/cross-session-elicitation-hijack.test.ts @@ -0,0 +1,284 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Regression test for a cross-session elicitation hijack. + * + * `BaseTool.installTo(server)` does `this.server = server` — mutable state on the tool + * object itself. `CORE_TOOLS` (src/tools/toolRegistry.ts) instantiates tools once as + * module-level singletons, and any embedder that reuses those singletons across + * multiple concurrent sessions (calling `installTo()` again for each new session — the + * pattern mcp-server's own scripts/dev-http-server.ts uses, and that hosted-mcp-server's + * dynamic `import()` caching produces too) clobbers `this.server` on every new + * connection. + * + * Before this PR, `this.server` was only ever read for logging. PreviewStyleTool / + * StyleComparisonTool's elicitation flow is the first thing that reads it for something + * session-sensitive: `elicitPreviewToken(this.server.server, ...)`. No race or timing + * window is even needed to trigger it — `this.server` is durably overwritten by whichever + * session's `installTo()` ran most recently, and stays that way until another session + * connects. So once session B connects (after session A), *every* subsequent tool call + * on the shared instance — including one made over session A's own, already-established + * connection — sends its elicitation request to session B instead. An uninvolved client + * gets an unprompted "paste your token" dialog for a tool call it never made, and + * whatever it submits comes back as the *other* session's tool result: an + * unprompted-dialog-injection + credential-exfiltration primitive, not a hypothetical. + */ + +import { createServer, type IncomingMessage, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + ElicitRequestSchema, + type ElicitRequest, + type ElicitResult +} from '@modelcontextprotocol/sdk/types.js'; +import { PreviewStyleTool } from '../../src/tools/preview-style-tool/PreviewStyleTool.js'; +import { previewTokenStorage } from '../../src/utils/tokenElicitation.js'; +import type { TokenCollectionHandler } from '../../src/utils/tokenCollectionServer.js'; +import type { HttpRequest } from '../../src/utils/types.js'; + +// Non-tk.* tokens so `canCreateTokens` is true and the code actually awaits +// `listPublicPreviewTokens` before reaching the elicitation call. +const SESSION_A_TOKEN = + 'sk.eyJ1IjoidGVzdC11c2VyLWEiLCJhIjoidGVzdC1hcGkifQ.signature-a'; +const SESSION_B_TOKEN = + 'sk.eyJ1IjoidGVzdC11c2VyLWIiLCJhIjoidGVzdC1hcGkifQ.signature-b'; + +// Realistic pk..sig shape — PreviewStyleTool decodes the `u` claim +// out of whichever token collectProvidedToken returns to build the preview URL, so this +// needs to parse cleanly for the test to observe that it made it into the result. +const SESSION_A_SUPPLIED_TOKEN = 'pk.eyJ1IjoiYXR0YWNrZXItYWNjb3VudCJ9.sig-a'; + +interface Harness { + baseUrl: URL; + close(): Promise; +} + +/** A fake TokenCollectionHandler that resolves immediately with `token`, instead of + * starting a real local server and waiting for an actual browser submission that will + * never come in a test. */ +function fakeTokenCollectionHandler(token: string): TokenCollectionHandler { + return { + collect: vi.fn().mockResolvedValue({ + url: 'http://127.0.0.1:1/fake-collection-url', + result: Promise.resolve(token), + cancel: vi.fn() + }) + }; +} + +/** + * Installs a *shared* `PreviewStyleTool` instance onto a fresh `McpServer` for every + * new session — mirroring how CORE_TOOLS' singletons get reused across sessions in a + * real multi-tenant deployment. Session-scoped (one transport per Mcp-Session-Id), not + * the fully-stateless-per-request pattern, so this isn't about the separate capability- + * negotiation issue documented in test/integration/elicitationOverHttp.test.ts — this + * harness's whole point is that both sessions' capability negotiation works correctly, + * and the tool still sends the request to the wrong one. + */ +function startSharedSingletonHarness( + previewTool: PreviewStyleTool +): Promise { + const sessions = new Map(); + + async function buildTransport(): Promise { + const mcpServer = new McpServer( + { name: 'cross-session-hijack-test', version: '1.0.0' }, + { capabilities: { tools: { listChanged: true } } } + ); + // The exact line under test: reusing one tool instance across sessions. + previewTool.installTo(mcpServer); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + sessions.set(sessionId, transport); + }, + onsessionclosed: (sessionId) => { + sessions.delete(sessionId); + } + }); + transport.onclose = () => { + if (transport.sessionId) sessions.delete(transport.sessionId); + }; + await mcpServer.connect(transport); + return transport; + } + + const httpServer: Server = createServer((req, res) => { + void (async () => { + const authHeader = req.headers.authorization; + const match = authHeader?.match(/^Bearer (.+)$/); + const reqWithAuth = req as IncomingMessage & { + auth?: { token: string; clientId: string; scopes: string[] }; + }; + if (match) { + reqWithAuth.auth = { + token: match[1], + clientId: 'test-client', + scopes: [] + }; + } + + const sessionIdHeader = req.headers['mcp-session-id']; + const existing = + typeof sessionIdHeader === 'string' + ? sessions.get(sessionIdHeader) + : undefined; + const transport = existing ?? (await buildTransport()); + + try { + await transport.handleRequest(reqWithAuth, res); + } catch (error) { + if (!res.headersSent) { + res.writeHead(500).end(String(error)); + } + } + })(); + }); + + return new Promise((resolve) => { + httpServer.listen(0, '127.0.0.1', () => { + const { port } = httpServer.address() as AddressInfo; + resolve({ + baseUrl: new URL(`http://127.0.0.1:${port}/mcp`), + close: () => + new Promise((res, rej) => + httpServer.close((err) => (err ? rej(err) : res())) + ) + }); + }); + }); +} + +async function connectClient( + baseUrl: URL, + bearerToken: string, + onElicit: (request: ElicitRequest) => ElicitResult +): Promise { + const client = new Client( + { name: 'cross-session-hijack-test-client', version: '1.0.0' }, + // Declare both modes — an empty `elicitation: {}` is normalized by the SDK to + // form-only support, which would make the follow-up URL-mode request (see + // collectProvidedToken) look unsupported and short-circuit to a different + // fallback path than the one this test is actually exercising. + { capabilities: { elicitation: { form: {}, url: {} } } } + ); + client.setRequestHandler(ElicitRequestSchema, (request) => onElicit(request)); + + const transport = new StreamableHTTPClientTransport(baseUrl, { + requestInit: { headers: { Authorization: `Bearer ${bearerToken}` } } + }); + await client.connect(transport); + return client; +} + +describe('cross-session elicitation hijack (singleton tool instance reused across sessions)', () => { + let harness: Harness | undefined; + let clientA: Client | undefined; + let clientB: Client | undefined; + const ORIGINAL_ENABLE_LOCAL_URL_ELICITATION = + process.env.ENABLE_LOCAL_URL_ELICITATION; + + beforeEach(() => { + previewTokenStorage.clearAll(); + // Opt-in (disabled by default) — this harness simulates the stdio entry point, + // the one context where src/index.ts enables this automatically. + process.env.ENABLE_LOCAL_URL_ELICITATION = 'true'; + }); + + afterEach(async () => { + await clientA?.close().catch(() => {}); + await clientB?.close().catch(() => {}); + await harness?.close(); + clientA = undefined; + clientB = undefined; + harness = undefined; + if (ORIGINAL_ENABLE_LOCAL_URL_ELICITATION === undefined) { + delete process.env.ENABLE_LOCAL_URL_ELICITATION; + } else { + process.env.ENABLE_LOCAL_URL_ELICITATION = + ORIGINAL_ENABLE_LOCAL_URL_ELICITATION; + } + }); + + it("does not send session A's elicitation prompt to session B, when B connected more recently and B's installTo() call is the last one to touch the shared tool's this.server", async () => { + const httpRequest: HttpRequest = vi.fn( + async () => new Response(JSON.stringify([]), { status: 200 }) + ) as unknown as HttpRequest; + + // ONE shared instance, installed onto two different sessions below — this is the + // exact shape of CORE_TOOLS being reused across concurrent sessions. Only session + // A's tool call actually completes an elicitation flow in this test, so a single + // fake token-collection handler resolving with session A's token is enough. + const previewTool = new PreviewStyleTool({ + httpRequest, + tokenCollectionHandler: fakeTokenCollectionHandler( + SESSION_A_SUPPLIED_TOKEN + ) + }); + + harness = await startSharedSingletonHarness(previewTool); + + // Answers both the form-mode choice dialog and the follow-up URL-mode consent + // request with the same "accept" response — content.token is unused for either + // step now (see collectProvidedToken); the actual token comes from the fake + // tokenCollectionHandler above instead. + const elicitReceivedByA = vi.fn().mockReturnValue({ + action: 'accept', + content: { choice: 'provide' } + }); + const elicitReceivedByB = vi.fn().mockReturnValue({ + action: 'accept', + content: { choice: 'provide' } + }); + + // Session A connects and installs the shared tool onto its own McpServer — + // `this.server` points at session A's server at this instant. + clientA = await connectClient( + harness.baseUrl, + SESSION_A_TOKEN, + elicitReceivedByA + ); + + // Session B connects afterward, reusing the SAME `previewTool` instance. Its + // installTo() call overwrites `this.server` to session B's server — durably, not + // just for a brief race window. No timing/interleaving is needed for this to + // matter: it stays this way until a third session connects. + clientB = await connectClient( + harness.baseUrl, + SESSION_B_TOKEN, + elicitReceivedByB + ); + + // Session A now calls the tool over its own, already-established connection — + // it never calls installTo() again, so this doesn't touch `this.server`. Whatever + // is currently in `this.server` (session B's, from the previous step) is what the + // shared tool instance will use for elicitation, regardless of which session's + // request is actually being handled. + const resultForSessionA = await clientA.callTool({ + name: 'preview_style_tool', + arguments: { styleId: 'session-a-style' } + }); + + // The fix: session A's own client must be the one asked, regardless of what + // installTo() calls happened on the shared tool instance in the meantime. Called + // twice — the form-mode choice dialog, then the follow-up URL-mode consent + // request (see collectProvidedToken) — both correctly routed to session A. + expect(elicitReceivedByA).toHaveBeenCalledTimes(2); + expect(elicitReceivedByB).not.toHaveBeenCalled(); + + // And the result that comes back for session A's tool call must reflect session + // A's own answer, not whatever an uninvolved session happened to submit. + const text = ( + resultForSessionA.content as Array<{ type: string; text?: string }> + )[0].text as string; + expect(text).toContain(`access_token=${SESSION_A_SUPPLIED_TOKEN}`); + }); +}); diff --git a/test/security/path-traversal.test.ts b/test/security/path-traversal.test.ts index f7b27bd..c82e93e 100644 --- a/test/security/path-traversal.test.ts +++ b/test/security/path-traversal.test.ts @@ -199,8 +199,9 @@ describe('path traversal security', () => { it('PreviewStyleTool encodes username containing "/" in preview URL and resource URI', async () => { const maliciousPublicToken = makePublicToken('user/attacker'); + const { httpRequest } = setupHttpRequest(); - const result = await new PreviewStyleTool().run({ + const result = await new PreviewStyleTool({ httpRequest }).run({ styleId: VALID_STYLE_ID, accessToken: maliciousPublicToken }); diff --git a/test/tools/preview-style-tool/PreviewStyleTool.test.ts b/test/tools/preview-style-tool/PreviewStyleTool.test.ts index 93ee55e..0c8b67d 100644 --- a/test/tools/preview-style-tool/PreviewStyleTool.test.ts +++ b/test/tools/preview-style-tool/PreviewStyleTool.test.ts @@ -4,16 +4,40 @@ process.env.MAPBOX_ACCESS_TOKEN = 'sk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { PreviewStyleTool } from '../../../src/tools/preview-style-tool/PreviewStyleTool.js'; +import { + cacheKeyFor, + previewTokenStorage +} from '../../../src/utils/tokenElicitation.js'; +import type { TokenCollectionHandler } from '../../../src/utils/tokenCollectionServer.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; + +/** A fake TokenCollectionHandler that resolves immediately with `token`, instead of + * starting a real local server and waiting for an actual browser submission that will + * never come in a test. */ +function fakeTokenCollectionHandler(token: string): TokenCollectionHandler { + return { + collect: vi.fn().mockResolvedValue({ + url: 'http://127.0.0.1:1/fake-collection-url', + result: Promise.resolve(token), + cancel: vi.fn() + }) + }; +} describe('PreviewStyleTool', () => { const TEST_ACCESS_TOKEN = 'pk.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; + function previewStyleTool() { + const { httpRequest } = setupHttpRequest(); + return new PreviewStyleTool({ httpRequest }); + } + describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = new PreviewStyleTool(); + const tool = previewStyleTool(); expect(tool.name).toBe('preview_style_tool'); expect(tool.description).toBe( 'Generate preview URL for a Mapbox style using an existing public token' @@ -28,7 +52,7 @@ describe('PreviewStyleTool', () => { }); it('uses user-provided public token and returns preview URL', async () => { - const result = await new PreviewStyleTool().run({ + const result = await previewStyleTool().run({ styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, title: false, @@ -45,7 +69,7 @@ describe('PreviewStyleTool', () => { }); it('includes styleId in URL', async () => { - const result = await new PreviewStyleTool().run({ + const result = await previewStyleTool().run({ styleId: 'cmojrmkc9002t01ry96yi6h49', accessToken: TEST_ACCESS_TOKEN, title: false, @@ -61,7 +85,7 @@ describe('PreviewStyleTool', () => { }); it('includes title parameter when provided', async () => { - const result = await new PreviewStyleTool().run({ + const result = await previewStyleTool().run({ styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, title: true, @@ -75,7 +99,7 @@ describe('PreviewStyleTool', () => { }); it('includes zoomwheel parameter when provided', async () => { - const result = await new PreviewStyleTool().run({ + const result = await previewStyleTool().run({ styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, zoomwheel: false, @@ -89,7 +113,7 @@ describe('PreviewStyleTool', () => { }); it('includes fresh parameter for secure access', async () => { - const result = await new PreviewStyleTool().run({ + const result = await previewStyleTool().run({ styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, title: false, @@ -103,7 +127,7 @@ describe('PreviewStyleTool', () => { }); it('rejects secret tokens', async () => { - const result = await new PreviewStyleTool().run({ + const result = await previewStyleTool().run({ styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: 'sk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.secret_token', @@ -121,7 +145,7 @@ describe('PreviewStyleTool', () => { }); it('rejects temporary tokens', async () => { - const result = await new PreviewStyleTool().run({ + const result = await previewStyleTool().run({ styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: 'tk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.temp_token', title: false, @@ -138,7 +162,7 @@ describe('PreviewStyleTool', () => { }); it('returns URL and MCP-UI resource on success (default)', async () => { - const result = await new PreviewStyleTool().run({ + const result = await previewStyleTool().run({ styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, title: false, @@ -174,7 +198,7 @@ describe('PreviewStyleTool', () => { }); it('returns URL and MCP-UI resource for backward compatibility', async () => { - const result = await new PreviewStyleTool().run({ + const result = await previewStyleTool().run({ styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, title: false, @@ -195,4 +219,136 @@ describe('PreviewStyleTool', () => { type: 'resource' }); }); + + describe('elicitation behavior', () => { + const ORIGINAL_ENABLE_LOCAL_URL_ELICITATION = + process.env.ENABLE_LOCAL_URL_ELICITATION; + + beforeEach(() => { + previewTokenStorage.clearAll(); + // Opt-in (disabled by default) — these tests simulate the stdio entry point, + // the one context where src/index.ts enables this automatically. + process.env.ENABLE_LOCAL_URL_ELICITATION = 'true'; + }); + + afterEach(() => { + if (ORIGINAL_ENABLE_LOCAL_URL_ELICITATION === undefined) { + delete process.env.ENABLE_LOCAL_URL_ELICITATION; + } else { + process.env.ENABLE_LOCAL_URL_ELICITATION = + ORIGINAL_ENABLE_LOCAL_URL_ELICITATION; + } + }); + + it('returns error when no accessToken and no valid server token', async () => { + const tool = previewStyleTool(); + + // Remove env var temporarily to test error path + const oldToken = process.env.MAPBOX_ACCESS_TOKEN; + delete process.env.MAPBOX_ACCESS_TOKEN; + + const result = await tool.run({ + styleId: 'test-style' + // No accessToken, no authInfo.token either + }); + + expect(result.isError).toBe(true); + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining( + 'Server access token is required when no preview token is provided' + ) + }); + + // Restore env var + process.env.MAPBOX_ACCESS_TOKEN = oldToken; + }); + + it('works with backward compatibility when accessToken is provided', async () => { + const tool = previewStyleTool(); + // Even without server initialization, providing accessToken directly should work + + const result = await tool.run({ + styleId: 'test-style', + accessToken: TEST_ACCESS_TOKEN + }); + + expect(result.isError).toBe(false); + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining( + '/styles/v1/test-user/test-style.html?access_token=pk.' + ) + }); + }); + + it('omits create/auto options and skips token creation calls when the server token is temporary (tk.*)', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const tokenCollectionHandler = + fakeTokenCollectionHandler(TEST_ACCESS_TOKEN); + const tool = new PreviewStyleTool({ + httpRequest, + tokenCollectionHandler + }); + + // The per-call sendRequest a real MCP session would pass via `extra` — + // not a stashed `this.server`, which a singleton tool instance can't + // safely rely on across sessions (see tokenElicitation.ts). Answers both + // the form-mode choice dialog and the follow-up URL-mode consent request + // with the same "accept" response. + const sendRequest = vi.fn().mockResolvedValue({ + action: 'accept', + content: { choice: 'provide' } + }); + const sendNotification = vi.fn().mockResolvedValue(undefined); + + const tkToken = + 'tk.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; + + const result = await tool.run( + { styleId: 'test-style' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { authInfo: { token: tkToken }, sendRequest, sendNotification } as any + ); + + expect(result.isError).toBe(false); + // First call is the form-mode choice dialog, second is the follow-up + // URL-mode elicitation for the token itself (see collectProvidedToken). + expect(sendRequest).toHaveBeenCalledTimes(2); + const requestedSchema = + sendRequest.mock.calls[0][0].params.requestedSchema; + expect(requestedSchema.properties.choice.enum).toEqual(['provide']); + expect(requestedSchema.properties.token).toBeUndefined(); + expect(sendRequest.mock.calls[1][0].params.mode).toBe('url'); + expect(tokenCollectionHandler.collect).toHaveBeenCalledTimes(1); + + // A tk.* server token can never create tokens (tokens:write), but listing only + // needs tokens:read — a separate scope — so it's still attempted (and fails + // safe to an empty list if the token can't do that either). Only creation + // (a POST) must never be attempted. + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + expect(mockHttpRequest.mock.calls[0][1]?.method).not.toBe('POST'); + }); + + it('reuses a cached token instead of erroring when useCustomToken is set but the client cannot act on it', async () => { + const serverToken = + 'sk.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; + previewTokenStorage.set(cacheKeyFor(serverToken), TEST_ACCESS_TOKEN); + + // No `this.server` is attached in these tests (installTo() was never + // called), so this exercises exactly the "client can't support the + // selection dialog" case a reviewer asked about on PR #57. + const result = await previewStyleTool().run( + { styleId: 'test-style', useCustomToken: true }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { authInfo: { token: serverToken } } as any + ); + + expect(result.isError).toBe(false); + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining(`access_token=${TEST_ACCESS_TOKEN}`) + }); + }); + }); }); diff --git a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts index 5024af7..de66f1c 100644 --- a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts +++ b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -4,12 +4,36 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { StyleComparisonTool } from '../../../src/tools/style-comparison-tool/StyleComparisonTool.js'; import * as jwtUtils from '../../../src/utils/jwtUtils.js'; +import { + cacheKeyFor, + previewTokenStorage +} from '../../../src/utils/tokenElicitation.js'; +import type { TokenCollectionHandler } from '../../../src/utils/tokenCollectionServer.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; + +function styleComparisonTool() { + const { httpRequest } = setupHttpRequest(); + return new StyleComparisonTool({ httpRequest }); +} + +/** A fake TokenCollectionHandler that resolves immediately with `token`, instead of + * starting a real local server and waiting for an actual browser submission that will + * never come in a test. */ +function fakeTokenCollectionHandler(token: string): TokenCollectionHandler { + return { + collect: vi.fn().mockResolvedValue({ + url: 'http://127.0.0.1:1/fake-collection-url', + result: Promise.resolve(token), + cancel: vi.fn() + }) + }; +} describe('StyleComparisonTool', () => { let tool: StyleComparisonTool; beforeEach(() => { - tool = new StyleComparisonTool(); + tool = styleComparisonTool(); }); afterEach(() => { @@ -50,19 +74,18 @@ describe('StyleComparisonTool', () => { }); }); - it('should require access token', async () => { + it('should work with provided access token (backward compatibility)', async () => { const input = { before: 'mapbox/streets-v12', - after: 'mapbox/satellite-v9' - // Missing accessToken - } as any; + after: 'mapbox/satellite-v9', + accessToken: 'pk.test.token' + }; const result = await tool.run(input); - expect(result.isError).toBe(true); - expect( - (result.content[0] as { type: 'text'; text: string }).text - ).toContain('invalid_type'); + expect(result.isError).toBe(false); + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain('access_token=pk.test.token'); }); it('should handle full style URLs', async () => { @@ -262,6 +285,142 @@ describe('StyleComparisonTool', () => { }); }); + describe('elicitation behavior', () => { + const ORIGINAL_ENABLE_LOCAL_URL_ELICITATION = + process.env.ENABLE_LOCAL_URL_ELICITATION; + + beforeEach(() => { + previewTokenStorage.clearAll(); + // Opt-in (disabled by default) — these tests simulate the stdio entry point, + // the one context where src/index.ts enables this automatically. + process.env.ENABLE_LOCAL_URL_ELICITATION = 'true'; + }); + + afterEach(() => { + if (ORIGINAL_ENABLE_LOCAL_URL_ELICITATION === undefined) { + delete process.env.ENABLE_LOCAL_URL_ELICITATION; + } else { + process.env.ENABLE_LOCAL_URL_ELICITATION = + ORIGINAL_ENABLE_LOCAL_URL_ELICITATION; + } + }); + + it('returns error when no accessToken and no valid server token', async () => { + const tool = styleComparisonTool(); + + // Remove env var temporarily to test error path + const oldToken = process.env.MAPBOX_ACCESS_TOKEN; + delete process.env.MAPBOX_ACCESS_TOKEN; + + const result = await tool.run({ + before: 'mapbox/streets-v12', + after: 'mapbox/satellite-v9' + // No accessToken, no authInfo.token either + }); + + expect(result.isError).toBe(true); + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining( + 'Server access token is required when no preview token is provided' + ) + }); + + // Restore env var + process.env.MAPBOX_ACCESS_TOKEN = oldToken; + }); + + it('works with backward compatibility when accessToken is provided', async () => { + const tool = styleComparisonTool(); + // Even without server initialization, providing accessToken directly should work + + const result = await tool.run({ + before: 'mapbox/streets-v12', + after: 'mapbox/satellite-v9', + accessToken: 'pk.test.token' + }); + + expect(result.isError).toBe(false); + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('access_token=pk.test.token') + }); + }); + + it('omits create/auto options and skips token creation calls when the server token is temporary (tk.*)', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const tokenCollectionHandler = + fakeTokenCollectionHandler('pk.test.token'); + const tool = new StyleComparisonTool({ + httpRequest, + tokenCollectionHandler + }); + + // The per-call sendRequest a real MCP session would pass via `extra` — + // not a stashed `this.server`, which a singleton tool instance can't + // safely rely on across sessions (see tokenElicitation.ts). Answers both + // the form-mode choice dialog and the follow-up URL-mode consent request + // with the same "accept" response. + const sendRequest = vi.fn().mockResolvedValue({ + action: 'accept', + content: { choice: 'provide' } + }); + const sendNotification = vi.fn().mockResolvedValue(undefined); + + const tkToken = + 'tk.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; + + const result = await tool.run( + { before: 'mapbox/streets-v12', after: 'mapbox/satellite-v9' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { authInfo: { token: tkToken }, sendRequest, sendNotification } as any + ); + + expect(result.isError).toBe(false); + // First call is the form-mode choice dialog, second is the follow-up + // URL-mode elicitation for the token itself (see collectProvidedToken). + expect(sendRequest).toHaveBeenCalledTimes(2); + const requestedSchema = + sendRequest.mock.calls[0][0].params.requestedSchema; + expect(requestedSchema.properties.choice.enum).toEqual(['provide']); + expect(requestedSchema.properties.token).toBeUndefined(); + expect(sendRequest.mock.calls[1][0].params.mode).toBe('url'); + expect(tokenCollectionHandler.collect).toHaveBeenCalledTimes(1); + + // A tk.* server token can never create tokens (tokens:write), but listing only + // needs tokens:read — a separate scope — so it's still attempted (and fails + // safe to an empty list if the token can't do that either). Only creation + // (a POST) must never be attempted. + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + expect(mockHttpRequest.mock.calls[0][1]?.method).not.toBe('POST'); + }); + + it('reuses a cached token instead of erroring when useCustomToken is set but the client cannot act on it', async () => { + const serverToken = + 'sk.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; + previewTokenStorage.set(cacheKeyFor(serverToken), 'pk.test.token'); + + // No `this.server` is attached in these tests (installTo() was never + // called), so this exercises exactly the "client can't support the + // selection dialog" case a reviewer asked about on PR #57. + const result = await styleComparisonTool().run( + { + before: 'mapbox/streets-v12', + after: 'mapbox/satellite-v9', + useCustomToken: true + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { authInfo: { token: serverToken } } as any + ); + + expect(result.isError).toBe(false); + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('access_token=pk.test.token') + }); + }); + }); + describe('metadata', () => { it('should have correct name and description', () => { expect(tool.name).toBe('style_comparison_tool'); diff --git a/test/utils/tokenCollectionServer.test.ts b/test/utils/tokenCollectionServer.test.ts new file mode 100644 index 0000000..8e1bec8 --- /dev/null +++ b/test/utils/tokenCollectionServer.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { LocalHttpTokenCollectionHandler } from '../../src/utils/tokenCollectionServer.js'; + +describe('LocalHttpTokenCollectionHandler', () => { + it('binds to the loopback interface only', async () => { + const handler = new LocalHttpTokenCollectionHandler(); + const { url, result, cancel } = await handler.collect({ timeoutMs: 5000 }); + // Attach the rejection assertion before cancelling so it's never briefly unhandled. + const assertion = expect(result).rejects.toThrow(/cancelled/); + + expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{48}$/); + cancel(); + await assertion; + }); + + it('resolves result with the submitted token on POST', async () => { + const handler = new LocalHttpTokenCollectionHandler(); + const { url, result } = await handler.collect({ timeoutMs: 5000 }); + + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token: 'pk.submitted-token' }).toString() + }); + + expect(response.status).toBe(200); + await expect(result).resolves.toBe('pk.submitted-token'); + }); + + it('returns 404 for any path other than the assigned one', async () => { + const handler = new LocalHttpTokenCollectionHandler(); + const { url, result, cancel } = await handler.collect({ timeoutMs: 5000 }); + const assertion = expect(result).rejects.toThrow(/cancelled/); + const wrongUrl = new URL(url); + wrongUrl.pathname = '/some-other-path'; + + const response = await fetch(wrongUrl); + expect(response.status).toBe(404); + cancel(); + await assertion; + }); + + it('serves a form page on GET', async () => { + const handler = new LocalHttpTokenCollectionHandler(); + const { url, result, cancel } = await handler.collect({ timeoutMs: 5000 }); + const assertion = expect(result).rejects.toThrow(/cancelled/); + + const response = await fetch(url); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/html'); + const body = await response.text(); + expect(body).toContain(' { + const handler = new LocalHttpTokenCollectionHandler(); + const { url, result, cancel } = await handler.collect({ timeoutMs: 5000 }); + + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({}).toString() + }); + + expect(response.status).toBe(400); + + // The server is still up and can accept a subsequent valid submission. + const retry = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token: 'pk.retry-token' }).toString() + }); + expect(retry.status).toBe(200); + await expect(result).resolves.toBe('pk.retry-token'); + cancel(); + }); + + it('rejects a body exceeding the maximum accepted size', async () => { + const handler = new LocalHttpTokenCollectionHandler(); + const { url, result } = await handler.collect({ timeoutMs: 5000 }); + // Attach the assertion before the request so it's never briefly unhandled, + // regardless of exactly when the server-side rejection actually settles. + const assertion = expect(result).rejects.toThrow( + /exceeds the maximum accepted size/ + ); + + const hugeBody = new URLSearchParams({ + token: 'pk.' + 'a'.repeat(20 * 1024) + }).toString(); + + // The connection may be reset mid-write once the size cap is hit; either + // outcome (fetch throwing, or a non-2xx response) is an acceptable way for the + // oversized submission to fail — what matters is `result` never resolves with it. + await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: hugeBody + }).catch(() => undefined); + + await assertion; + }); + + it('rejects after the timeout elapses and closes the server', async () => { + const handler = new LocalHttpTokenCollectionHandler(); + const { url, result } = await handler.collect({ timeoutMs: 50 }); + + await expect(result).rejects.toThrow(/Timed out after 50ms/); + + // The server has been torn down; a request to it should now fail to connect. + await expect(fetch(url)).rejects.toThrow(); + }); + + it('cancel() closes the server without resolving or rejecting result observably as a "submitted" outcome', async () => { + const handler = new LocalHttpTokenCollectionHandler(); + const { url, result, cancel } = await handler.collect({ timeoutMs: 5000 }); + + cancel(); + + await expect(result).rejects.toThrow(/cancelled/); + await expect(fetch(url)).rejects.toThrow(); + }); + + it('cancel() after result has already settled is a safe no-op', async () => { + const handler = new LocalHttpTokenCollectionHandler(); + const { url, result, cancel } = await handler.collect({ timeoutMs: 5000 }); + + await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token: 'pk.already-done' }).toString() + }); + await expect(result).resolves.toBe('pk.already-done'); + + expect(() => cancel()).not.toThrow(); + }); +}); diff --git a/test/utils/tokenElicitation.test.ts b/test/utils/tokenElicitation.test.ts new file mode 100644 index 0000000..098df12 --- /dev/null +++ b/test/utils/tokenElicitation.test.ts @@ -0,0 +1,749 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { + cacheKeyFor, + collectProvidedToken, + createPreviewToken, + ElicitationUnavailableError, + elicitPreviewToken, + isTemporaryServerToken, + listPublicPreviewTokens, + previewTokenStorage, + validatePublicPreviewToken +} from '../../src/utils/tokenElicitation.js'; +import type { TokenCollectionHandler } from '../../src/utils/tokenCollectionServer.js'; +import { setupHttpRequest } from './httpPipelineUtils.js'; + +const MAPBOX_API_ENDPOINT = 'https://api.mapbox.com/'; + +describe('PreviewTokenStorage', () => { + // Clean up before each test to ensure isolation + beforeEach(() => { + previewTokenStorage.clearAll(); + }); + + it('stores and retrieves tokens by username', () => { + previewTokenStorage.set('test-user', 'pk.test-token-123'); + expect(previewTokenStorage.get('test-user')).toBe('pk.test-token-123'); + }); + + it('returns undefined for non-existent username', () => { + expect(previewTokenStorage.get('non-existent-user')).toBeUndefined(); + }); + + it('overwrites existing token for same username', () => { + previewTokenStorage.set('test-user', 'pk.old-token'); + previewTokenStorage.set('test-user', 'pk.new-token'); + expect(previewTokenStorage.get('test-user')).toBe('pk.new-token'); + }); + + it('stores tokens for multiple users independently', () => { + previewTokenStorage.set('user1', 'pk.token1'); + previewTokenStorage.set('user2', 'pk.token2'); + previewTokenStorage.set('user3', 'pk.token3'); + + expect(previewTokenStorage.get('user1')).toBe('pk.token1'); + expect(previewTokenStorage.get('user2')).toBe('pk.token2'); + expect(previewTokenStorage.get('user3')).toBe('pk.token3'); + }); + + it('clears specific username token', () => { + previewTokenStorage.set('user1', 'pk.token1'); + previewTokenStorage.set('user2', 'pk.token2'); + + previewTokenStorage.clear('user1'); + + expect(previewTokenStorage.get('user1')).toBeUndefined(); + expect(previewTokenStorage.get('user2')).toBe('pk.token2'); // Other token unaffected + }); + + it('clearing non-existent username does not throw', () => { + expect(() => { + previewTokenStorage.clear('non-existent-user'); + }).not.toThrow(); + }); + + it('clears all tokens', () => { + previewTokenStorage.set('user1', 'pk.token1'); + previewTokenStorage.set('user2', 'pk.token2'); + previewTokenStorage.set('user3', 'pk.token3'); + + previewTokenStorage.clearAll(); + + expect(previewTokenStorage.get('user1')).toBeUndefined(); + expect(previewTokenStorage.get('user2')).toBeUndefined(); + expect(previewTokenStorage.get('user3')).toBeUndefined(); + }); + + it('works correctly after clearAll and new sets', () => { + previewTokenStorage.set('user1', 'pk.old-token'); + previewTokenStorage.clearAll(); + previewTokenStorage.set('user2', 'pk.new-token'); + + expect(previewTokenStorage.get('user1')).toBeUndefined(); + expect(previewTokenStorage.get('user2')).toBe('pk.new-token'); + }); + + it('handles empty string username', () => { + previewTokenStorage.set('', 'pk.empty-user-token'); + expect(previewTokenStorage.get('')).toBe('pk.empty-user-token'); + }); + + it('handles special characters in username', () => { + const specialUsername = 'user@example.com'; + previewTokenStorage.set(specialUsername, 'pk.special-token'); + expect(previewTokenStorage.get(specialUsername)).toBe('pk.special-token'); + }); + + it('evicts the least-recently-used entry once at capacity, bounding memory regardless of how many distinct keys are presented', () => { + const MAX_CACHED_TOKENS = 1000; // matches the private constant in tokenElicitation.ts + + for (let i = 0; i < MAX_CACHED_TOKENS; i++) { + previewTokenStorage.set(`key-${i}`, `pk.token-${i}`); + } + + // One more insert should evict the oldest (key-0), not grow unbounded. Checking + // key-0 here (rather than before this point) matters: `get()` itself counts as a + // "use" and would otherwise protect key-0 from being the next eviction target. + previewTokenStorage.set('key-overflow', 'pk.token-overflow'); + + expect(previewTokenStorage.get('key-0')).toBeUndefined(); + expect(previewTokenStorage.get('key-overflow')).toBe('pk.token-overflow'); + // The rest of the original entries are still present. + expect(previewTokenStorage.get('key-1')).toBe('pk.token-1'); + }); + + it('reading an entry protects it from eviction, even if it was inserted first', () => { + const MAX_CACHED_TOKENS = 1000; + + previewTokenStorage.set('key-0', 'pk.token-0'); + for (let i = 1; i < MAX_CACHED_TOKENS; i++) { + previewTokenStorage.set(`key-${i}`, `pk.token-${i}`); + } + + // Touch key-0 so it's no longer the least-recently-used entry. + previewTokenStorage.get('key-0'); + + // This overflow should now evict key-1 (the new least-recently-used), not key-0. + previewTokenStorage.set('key-overflow', 'pk.token-overflow'); + + expect(previewTokenStorage.get('key-0')).toBe('pk.token-0'); + expect(previewTokenStorage.get('key-1')).toBeUndefined(); + }); +}); + +describe('isTemporaryServerToken', () => { + it('identifies tk.* tokens as temporary', () => { + expect(isTemporaryServerToken('tk.eyJ1IjoidGVzdCJ9.sig')).toBe(true); + }); + + it('does not treat pk.* tokens as temporary', () => { + expect(isTemporaryServerToken('pk.eyJ1IjoidGVzdCJ9.sig')).toBe(false); + }); + + it('does not treat sk.* tokens as temporary', () => { + expect(isTemporaryServerToken('sk.eyJ1IjoidGVzdCJ9.sig')).toBe(false); + }); +}); + +describe('cacheKeyFor', () => { + it('is deterministic for the same token', () => { + const token = 'sk.eyJ1IjoidGVzdC11c2VyIn0.sig'; + expect(cacheKeyFor(token)).toBe(cacheKeyFor(token)); + }); + + it('returns a sha256 hex digest', () => { + expect(cacheKeyFor('sk.eyJ1IjoidGVzdC11c2VyIn0.sig')).toMatch( + /^[0-9a-f]{64}$/ + ); + }); + + it('differs for two distinct tokens that decode to the same username', () => { + // Same `u` claim ('test-user'), different signatures — a naive username-keyed + // cache would conflate these two distinct, unverified bearers into one slot. + const tokenA = 'sk.eyJ1IjoidGVzdC11c2VyIn0.signature-a'; + const tokenB = 'sk.eyJ1IjoidGVzdC11c2VyIn0.signature-b'; + expect(cacheKeyFor(tokenA)).not.toBe(cacheKeyFor(tokenB)); + }); +}); + +describe('createPreviewToken', () => { + it('rejects tk.* server tokens without making a network call', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + + const result = await createPreviewToken( + httpRequest, + MAPBOX_API_ENDPOINT, + 'tk.eyJ1IjoidGVzdCJ9.sig', + 'test-user' + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('temporary session token'); + expect(mockHttpRequest).not.toHaveBeenCalled(); + }); + + it('creates a public token using only public scopes', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => ({ token: 'pk.new-token' }) + }); + + const result = await createPreviewToken( + httpRequest, + MAPBOX_API_ENDPOINT, + 'sk.eyJ1IjoidGVzdCJ9.sig', + 'test-user', + 'My Token', + ['https://example.com/*'] + ); + + expect(result.success).toBe(true); + expect(result.token).toBe('pk.new-token'); + + const [url, init] = mockHttpRequest.mock.calls[0]; + expect(String(url)).toContain('tokens/v2/test-user'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body.scopes).toEqual(['styles:read', 'styles:tiles', 'fonts:read']); + expect(body.scopes).not.toContain('styles:download'); + expect(body.allowedUrls).toEqual(['https://example.com/*']); + }); + + it('rejects a non-public token returned by the API', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => ({ token: 'sk.unexpected-secret' }) + }); + + const result = await createPreviewToken( + httpRequest, + MAPBOX_API_ENDPOINT, + 'sk.eyJ1IjoidGVzdCJ9.sig', + 'test-user' + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('non-public token'); + }); + + it('surfaces API errors with a scope hint on 403', async () => { + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 403, + text: async () => 'insufficient scopes' + }); + + const result = await createPreviewToken( + httpRequest, + MAPBOX_API_ENDPOINT, + 'sk.eyJ1IjoidGVzdCJ9.sig', + 'test-user' + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('insufficient scopes'); + expect(result.error).toContain('tokens:write'); + }); + + it('does not add a scope hint for unrelated server errors', async () => { + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 500, + text: async () => 'internal server error' + }); + + const result = await createPreviewToken( + httpRequest, + MAPBOX_API_ENDPOINT, + 'sk.eyJ1IjoidGVzdCJ9.sig', + 'test-user' + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('internal server error'); + expect(result.error).not.toContain('tokens:write'); + }); + + it('rejects an API response that omits the token field instead of throwing a raw TypeError', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => ({}) + }); + + const result = await createPreviewToken( + httpRequest, + MAPBOX_API_ENDPOINT, + 'sk.eyJ1IjoidGVzdCJ9.sig', + 'test-user' + ); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/did not include a token/); + }); + + it('redacts the caller access token out of a network-error message before returning it', async () => { + const secretToken = 'sk.eyJ1IjoidGVzdC11c2VyIn0.super-secret-signature'; + const httpRequest = vi + .fn() + .mockRejectedValue( + new Error( + `fetch failed: connect ECONNREFUSED, request to https://api.mapbox.com/tokens/v2/test-user?access_token=${secretToken}` + ) + ); + + const result = await createPreviewToken( + httpRequest, + MAPBOX_API_ENDPOINT, + secretToken, + 'test-user' + ); + + expect(result.success).toBe(false); + expect(result.error).not.toContain(secretToken); + expect(result.error).toContain('redacted'); + }); + + it('redacts a token echoed back in a non-ok response body before returning it', async () => { + const secretToken = 'sk.eyJ1IjoidGVzdC11c2VyIn0.super-secret-signature'; + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 400, + text: async () => + `Bad request for access_token=${secretToken}: malformed body` + }); + + const result = await createPreviewToken( + httpRequest, + MAPBOX_API_ENDPOINT, + secretToken, + 'test-user' + ); + + expect(result.success).toBe(false); + expect(result.error).not.toContain(secretToken); + }); +}); + +describe('listPublicPreviewTokens', () => { + it('filters to public tokens with styles:read scope', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => [ + { id: '1', note: 'public', scopes: ['styles:read'], token: 'pk.abc' }, + { id: '2', note: 'secret', scopes: ['styles:read'], token: 'sk.abc' }, + { id: '3', note: 'no-read', scopes: ['styles:tiles'], token: 'pk.abc' } + ] + }); + + const tokens = await listPublicPreviewTokens( + httpRequest, + MAPBOX_API_ENDPOINT, + 'sk.eyJ1IjoidGVzdCJ9.sig', + 'test-user' + ); + + expect(tokens).toEqual([ + { id: '1', note: 'public', scopes: ['styles:read'] } + ]); + }); + + it('returns an empty list on API failure instead of throwing', async () => { + const { httpRequest } = setupHttpRequest({ ok: false, status: 500 }); + + const tokens = await listPublicPreviewTokens( + httpRequest, + MAPBOX_API_ENDPOINT, + 'sk.eyJ1IjoidGVzdCJ9.sig', + 'test-user' + ); + + expect(tokens).toEqual([]); + }); +}); + +describe('elicitPreviewToken', () => { + function fakeSendRequest(choice: string) { + return vi.fn().mockResolvedValue({ + action: 'accept', + content: { choice } + }); + } + + it('offers all three choices when the server token can create tokens', async () => { + const sendRequest = fakeSendRequest('provide'); + await elicitPreviewToken(sendRequest, [], true); + + const request = sendRequest.mock.calls[0][0]; + expect(request.params.requestedSchema.properties.choice.enum).toEqual([ + 'provide', + 'create', + 'auto' + ]); + }); + + it('omits create/auto choices when the server token cannot create tokens', async () => { + const sendRequest = fakeSendRequest('provide'); + await elicitPreviewToken(sendRequest, [], false); + + const request = sendRequest.mock.calls[0][0]; + expect(request.params.requestedSchema.properties.choice.enum).toEqual([ + 'provide' + ]); + expect(request.params.message).toContain('temporary session token'); + }); + + it('throws when the user declines elicitation', async () => { + const sendRequest = vi.fn().mockResolvedValue({ action: 'decline' }); + + await expect(elicitPreviewToken(sendRequest, [], true)).rejects.toThrow( + 'Token elicitation was cancelled or declined by user' + ); + }); + + it('wraps a failed sendRequest (e.g. the client has no elicitation support) in ElicitationUnavailableError', async () => { + const sendRequest = vi + .fn() + .mockRejectedValue(new Error('Method not found')); + + await expect(elicitPreviewToken(sendRequest, [], true)).rejects.toThrow( + ElicitationUnavailableError + ); + }); + + it('passes an explicit timeout to sendRequest rather than relying on the SDK default', async () => { + const sendRequest = fakeSendRequest('provide'); + await elicitPreviewToken(sendRequest, [], true); + + const options = sendRequest.mock.calls[0][2]; + expect(options).toMatchObject({ timeout: expect.any(Number) }); + expect(options.timeout).toBeGreaterThan(0); + }); + + it('rejects a client-returned tokenNote that exceeds the max length', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + action: 'accept', + content: { + choice: 'create', + tokenNote: 'a'.repeat(300) + } + }); + + await expect(elicitPreviewToken(sendRequest, [], true)).rejects.toThrow( + /Token name.*exceeds/ + ); + }); + + it('rejects a client-returned urlRestrictions list that exceeds the max count', async () => { + const tooManyUrls = Array.from( + { length: 101 }, + (_, i) => `https://example${i}.com/*` + ).join(','); + const sendRequest = vi.fn().mockResolvedValue({ + action: 'accept', + content: { choice: 'create', urlRestrictions: tooManyUrls } + }); + + await expect(elicitPreviewToken(sendRequest, [], true)).rejects.toThrow( + /URL restrictions.*exceeds/ + ); + }); + + it('rejects a client-returned urlRestrictions raw string that exceeds the max length, even as a single unsplit value', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + action: 'accept', + content: { + choice: 'create', + // One URL far longer than the array-count cap alone would ever stop. + urlRestrictions: 'https://example.com/' + 'a'.repeat(5000) + } + }); + + await expect(elicitPreviewToken(sendRequest, [], true)).rejects.toThrow( + /urlRestrictions value is .* characters/ + ); + }); + + it('rejects an unrecognized choice value instead of silently treating it as auto-create', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + action: 'accept', + content: { choice: 'delete-everything' } + }); + + await expect(elicitPreviewToken(sendRequest, [], true)).rejects.toThrow( + /unrecognized token choice/ + ); + }); + + it('rejects a choice offered only when canCreateTokens is true if the server token cannot create tokens', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + action: 'accept', + content: { choice: 'auto' } + }); + + // canCreateTokens=false means only 'provide' was ever offered; a client + // returning 'auto' anyway must not be honored. + await expect(elicitPreviewToken(sendRequest, [], false)).rejects.toThrow( + /unrecognized token choice/ + ); + }); + + it('rejects a non-string urlRestrictions instead of crashing on .split()', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + action: 'accept', + content: { + choice: 'create', + urlRestrictions: ['https://example.com/*'] + } + }); + + await expect(elicitPreviewToken(sendRequest, [], true)).rejects.toThrow( + /non-string value for the urlRestrictions field/ + ); + }); + + it('rejects a non-string tokenNote', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + action: 'accept', + content: { choice: 'create', tokenNote: 42 } + }); + + await expect(elicitPreviewToken(sendRequest, [], true)).rejects.toThrow( + /non-string value for the tokenNote field/ + ); + }); + + it('propagates a request-timeout error instead of treating it as "client does not support elicitation"', async () => { + const sendRequest = vi + .fn() + .mockRejectedValue( + new McpError(ErrorCode.RequestTimeout, 'Request timed out') + ); + + const promise = elicitPreviewToken(sendRequest, [], true); + await expect(promise).rejects.not.toBeInstanceOf( + ElicitationUnavailableError + ); + await expect(promise).rejects.toThrow('Request timed out'); + }); + + it('propagates a cancellation error instead of treating it as "client does not support elicitation"', async () => { + const sendRequest = vi + .fn() + .mockRejectedValue( + new McpError(ErrorCode.InvalidRequest, 'Request cancelled') + ); + + const promise = elicitPreviewToken(sendRequest, [], true); + await expect(promise).rejects.not.toBeInstanceOf( + ElicitationUnavailableError + ); + await expect(promise).rejects.toThrow('Request cancelled'); + }); + + it('still wraps a genuine "client does not support elicitation" failure', async () => { + const sendRequest = vi + .fn() + .mockRejectedValue( + new McpError(ErrorCode.MethodNotFound, 'Method not found') + ); + + await expect(elicitPreviewToken(sendRequest, [], true)).rejects.toThrow( + ElicitationUnavailableError + ); + }); +}); + +describe('validatePublicPreviewToken', () => { + it('returns a valid pk.* token unchanged', () => { + expect(validatePublicPreviewToken('pk.valid-token')).toBe('pk.valid-token'); + }); + + it('rejects an empty string', () => { + expect(() => validatePublicPreviewToken('')).toThrow(/No token provided/); + }); + + it('rejects a token exceeding the max length', () => { + expect(() => validatePublicPreviewToken('pk.' + 'a'.repeat(3000))).toThrow( + /exceeds the .* maximum/ + ); + }); + + it('rejects a secret token, the same way the accessToken parameter is rejected', () => { + expect(() => + validatePublicPreviewToken('sk.eyJ1IjoidGVzdC11c2VyIn0.secret-signature') + ).toThrow(/Only public tokens \(starting with pk\.\*\) are allowed/); + }); +}); + +describe('collectProvidedToken', () => { + const ORIGINAL_ENV = process.env.ENABLE_LOCAL_URL_ELICITATION; + + beforeEach(() => { + // Opt-in by default (disabled-by-default is covered explicitly further down) — + // most tests below exercise the enabled path. + process.env.ENABLE_LOCAL_URL_ELICITATION = 'true'; + }); + + afterEach(() => { + if (ORIGINAL_ENV === undefined) { + delete process.env.ENABLE_LOCAL_URL_ELICITATION; + } else { + process.env.ENABLE_LOCAL_URL_ELICITATION = ORIGINAL_ENV; + } + }); + + /** A fake TokenCollectionHandler whose `result` settles with `outcome` (a token + * string on success, an Error to reject with). Rejections get a no-op `.catch` + * attached separately so they don't trigger an unhandled-rejection warning before + * the code under test awaits the original `result` promise. */ + function fakeTokenCollectionHandler(outcome: string | Error): { + handler: TokenCollectionHandler; + cancel: ReturnType; + } { + const cancel = vi.fn(); + const result = + outcome instanceof Error + ? Promise.reject(outcome) + : Promise.resolve(outcome); + result.catch(() => {}); + return { + handler: { + collect: vi.fn().mockResolvedValue({ + url: 'http://127.0.0.1:9999/fake-path', + result, + cancel + }) + }, + cancel + }; + } + + function fakeSendRequest(action: 'accept' | 'decline' | 'cancel' = 'accept') { + return vi.fn().mockResolvedValue({ action }); + } + + function fakeSendNotification() { + return vi.fn().mockResolvedValue(undefined); + } + + it('sends a URL-mode (not form-mode) elicitation request', async () => { + const sendRequest = fakeSendRequest(); + const { handler } = fakeTokenCollectionHandler('pk.good-token'); + + await collectProvidedToken(sendRequest, fakeSendNotification(), handler); + + const request = sendRequest.mock.calls[0][0]; + expect(request.params.mode).toBe('url'); + expect(request.params.url).toBe('http://127.0.0.1:9999/fake-path'); + expect(request.params.elicitationId).toEqual(expect.any(String)); + }); + + it('returns the validated token once the client accepts and the out-of-band submission resolves', async () => { + const { handler } = fakeTokenCollectionHandler('pk.good-token'); + + const token = await collectProvidedToken( + fakeSendRequest(), + fakeSendNotification(), + handler + ); + + expect(token).toBe('pk.good-token'); + }); + + it('rejects a secret token submitted through URL-mode collection', async () => { + const { handler } = fakeTokenCollectionHandler( + 'sk.eyJ1IjoidGVzdC11c2VyIn0.secret-signature' + ); + + await expect( + collectProvidedToken(fakeSendRequest(), fakeSendNotification(), handler) + ).rejects.toThrow( + /Only public tokens \(starting with pk\.\*\) are allowed/ + ); + }); + + it('cancels collection and wraps a failed sendRequest in ElicitationUnavailableError', async () => { + const sendRequest = vi + .fn() + .mockRejectedValue(new Error('Method not found')); + const { handler, cancel } = fakeTokenCollectionHandler('pk.good-token'); + + await expect( + collectProvidedToken(sendRequest, fakeSendNotification(), handler) + ).rejects.toThrow(ElicitationUnavailableError); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('cancels collection and throws (not ElicitationUnavailableError) when the user declines the URL-mode consent', async () => { + const { handler, cancel } = fakeTokenCollectionHandler('pk.good-token'); + + const promise = collectProvidedToken( + fakeSendRequest('decline'), + fakeSendNotification(), + handler + ); + await expect(promise).rejects.not.toBeInstanceOf( + ElicitationUnavailableError + ); + await expect(promise).rejects.toThrow(/cancelled or declined/); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('propagates a timeout from the out-of-band submission itself', async () => { + const { handler } = fakeTokenCollectionHandler( + new Error( + 'Timed out after 300000ms waiting for the token to be submitted.' + ) + ); + + await expect( + collectProvidedToken(fakeSendRequest(), fakeSendNotification(), handler) + ).rejects.toThrow(/Timed out/); + }); + + it('sends notifications/elicitation/complete after a successful collection', async () => { + const sendRequest = fakeSendRequest(); + const sendNotification = fakeSendNotification(); + const { handler } = fakeTokenCollectionHandler('pk.good-token'); + + await collectProvidedToken(sendRequest, sendNotification, handler); + + const elicitationId = sendRequest.mock.calls[0][0].params.elicitationId; + expect(sendNotification).toHaveBeenCalledWith({ + method: 'notifications/elicitation/complete', + params: { elicitationId } + }); + }); + + it('does not fail overall if sendNotification itself rejects', async () => { + const sendNotification = vi + .fn() + .mockRejectedValue( + new Error('client does not support this notification') + ); + const { handler } = fakeTokenCollectionHandler('pk.good-token'); + + await expect( + collectProvidedToken(fakeSendRequest(), sendNotification, handler) + ).resolves.toBe('pk.good-token'); + }); + + it('short-circuits to ElicitationUnavailableError without starting collection when explicitly disabled', async () => { + process.env.ENABLE_LOCAL_URL_ELICITATION = 'false'; + const { handler } = fakeTokenCollectionHandler('pk.good-token'); + + await expect( + collectProvidedToken(fakeSendRequest(), fakeSendNotification(), handler) + ).rejects.toThrow(ElicitationUnavailableError); + expect(handler.collect).not.toHaveBeenCalled(); + }); + + it('is disabled by default when the env var is unset (opt-in, not opt-out)', async () => { + delete process.env.ENABLE_LOCAL_URL_ELICITATION; + const { handler } = fakeTokenCollectionHandler('pk.good-token'); + + await expect( + collectProvidedToken(fakeSendRequest(), fakeSendNotification(), handler) + ).rejects.toThrow(ElicitationUnavailableError); + expect(handler.collect).not.toHaveBeenCalled(); + }); +});