diff --git a/CHANGELOG.md b/CHANGELOG.md index e4611d3..5d0aaee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## 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 uses MCP elicitation to ask for a public token instead — either 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 account for the session; pass `useCustomToken: true` to force re-selection. + - 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". + +### 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. + ## 0.8.2 - 2026-07-30 ### Fixed diff --git a/README.md b/README.md index 9759c7a..dce7279 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,17 @@ 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) + +**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. + ### 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 +107,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. + +Choose **"I have a token to provide"** and paste an existing public token (`pk.*`, with `styles:read` scope), or provide `accessToken` directly. 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 +187,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 +251,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. 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..3f2292a 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -8,6 +8,14 @@ import { PreviewStyleInput } from './PreviewStyleTool.input.schema.js'; import { getUserNameFromToken } from '../../utils/jwtUtils.js'; +import { + createPreviewToken, + elicitPreviewToken, + isTemporaryServerToken, + listPublicPreviewTokens, + previewTokenStorage +} from '../../utils/tokenElicitation.js'; +import type { HttpRequest } from '../../utils/types.js'; export class PreviewStyleTool extends BaseTool { readonly name = 'preview_style_tool'; @@ -32,14 +40,165 @@ export class PreviewStyleTool extends BaseTool { } }; - constructor() { + private readonly httpRequest: HttpRequest; + + constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: PreviewStyleSchema }); + this.httpRequest = params.httpRequest; } - protected async execute(input: PreviewStyleInput): Promise { + protected async execute( + input: PreviewStyleInput, + serverAccessToken?: string + ): Promise { + 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)) + } + ] + }; + } + + // Check for stored preview token (unless user wants to use custom) + const storedToken = previewTokenStorage.get(userName); + if (storedToken && !input.useCustomToken) { + publicToken = storedToken; + } else { + // Need to elicit token from user + if (!this.server) { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Server not initialized. Cannot elicit token from user.' + } + ] + }; + } + + // Check if client supports elicitation capability + const clientCapabilities = this.server.server.getClientCapabilities(); + if (!clientCapabilities?.elicitation) { + 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).' + } + ] + }; + } + + // 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!); + + // Get existing public tokens to show user + const existingTokens = canCreateTokens + ? await listPublicPreviewTokens( + this.httpRequest, + MapboxApiBasedTool.mapboxApiEndpoint, + serverAccessToken!, + userName + ) + : []; + + // Elicit token choice from user + const elicited = await elicitPreviewToken( + this.server.server, + existingTokens, + canCreateTokens + ); + + // Handle user's choice + if (elicited.choice === 'provide') { + if (!elicited.token) { + return { + isError: true, + content: [ + { + type: 'text', + text: 'No token provided. Please provide a valid public token.' + } + ] + }; + } + publicToken = elicited.token; + } 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(userName, publicToken); + } + } + + // Step 2: Get username from the preview token try { - userName = getUserNameFromToken(input.accessToken); + userName = getUserNameFromToken(publicToken); } catch (error) { return { isError: true, @@ -52,9 +211,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..f429dc9 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -5,11 +5,20 @@ import { randomUUID } from 'node:crypto'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.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 { + createPreviewToken, + elicitPreviewToken, + isTemporaryServerToken, + listPublicPreviewTokens, + previewTokenStorage +} from '../../utils/tokenElicitation.js'; +import type { HttpRequest } from '../../utils/types.js'; export class StyleComparisonTool extends BaseTool< typeof StyleComparisonSchema @@ -36,8 +45,11 @@ export class StyleComparisonTool extends BaseTool< } }; - constructor() { + private readonly httpRequest: HttpRequest; + + constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: StyleComparisonSchema }); + this.httpRequest = params.httpRequest; } /** @@ -86,14 +98,151 @@ export class StyleComparisonTool extends BaseTool< } protected async execute( - input: StyleComparisonInput + input: StyleComparisonInput, + serverAccessToken?: string ): Promise { + 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)) + } + ] + }; + } + + // Check for stored preview token (unless user wants to use custom) + const storedToken = previewTokenStorage.get(userName); + if (storedToken && !input.useCustomToken) { + publicToken = storedToken; + } else { + if (!this.server) { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Server not initialized. Cannot elicit token from user.' + } + ] + }; + } + + const clientCapabilities = this.server.server.getClientCapabilities(); + if (!clientCapabilities?.elicitation) { + 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).' + } + ] + }; + } + + // 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!); + + const existingTokens = canCreateTokens + ? await listPublicPreviewTokens( + this.httpRequest, + MapboxApiBasedTool.mapboxApiEndpoint, + serverAccessToken!, + userName + ) + : []; + + const elicited = await elicitPreviewToken( + this.server.server, + existingTokens, + canCreateTokens + ); + + if (elicited.choice === 'provide') { + if (!elicited.token) { + return { + isError: true, + content: [ + { + type: 'text', + text: 'No token provided. Please provide a valid public token.' + } + ] + }; + } + publicToken = elicited.token; + } 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(userName, publicToken); + } + } + 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 +260,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..64f181f 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -36,13 +36,13 @@ export const CORE_TOOLS = [ new RetrieveStyleTool({ httpRequest }), new UpdateStyleTool({ httpRequest }), new DeleteStyleTool({ httpRequest }), - new PreviewStyleTool(), + new PreviewStyleTool({ httpRequest }), new StyleBuilderTool(), new GeojsonPreviewTool(), new CheckColorContrastTool(), new CompareStylesTool(), new OptimizeStyleTool(), - new StyleComparisonTool(), + new StyleComparisonTool({ httpRequest }), new CreateTokenTool({ httpRequest }), new ListTokensTool({ httpRequest }), new BoundingBoxTool(), diff --git a/src/utils/tokenElicitation.ts b/src/utils/tokenElicitation.ts new file mode 100644 index 0000000..bc887bd --- /dev/null +++ b/src/utils/tokenElicitation.ts @@ -0,0 +1,365 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { HttpRequest } from './types.js'; + +/** + * 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 token elicitation + */ +export interface ElicitedTokenInfo { + choice: TokenChoice; + token?: string; + 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. + * + * @param server - MCP Server instance + * @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 + */ +export async function elicitPreviewToken( + server: Server, + 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. Paste an existing public token (pk.*) with styles:read scope below.'; + + const result = await server.elicitInput({ + 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}`, + requestedSchema: { + type: 'object', + properties: { + choice: { + type: 'string', + title: 'Token Option', + description: 'How would you like to provide the preview token?', + enum: choices, + enumNames: choiceNames + }, + token: { + type: 'string', + title: 'Your Token', + description: + 'Paste your public Mapbox token here (must have styles:read scope)', + minLength: 10 + }, + 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/*")' + } + }, + required: ['choice'] + } + }); + + // 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 + const choice = (result.content.choice as TokenChoice) || choices[0]; + const token = result.content.token as string | undefined; + const tokenNote = result.content.tokenNote as string | undefined; + const urlRestrictionsStr = result.content.urlRestrictions as + | string + | undefined; + + const urlRestrictions = urlRestrictionsStr + ? urlRestrictionsStr + .split(',') + .map((url) => url.trim()) + .filter((url) => url.length > 0) + : undefined; + + return { + choice, + token, + urlRestrictions, + tokenNote + }; +} + +/** + * Session-level storage for preview token preferences. + * In a real implementation, this could be stored in a database or cache. + */ +class PreviewTokenStorage { + private tokenCache = new Map(); + + /** + * Store a preview token for a specific username + */ + set(username: string, token: string): void { + this.tokenCache.set(username, token); + } + + /** + * Get stored preview token for a username + */ + get(username: string): string | undefined { + return this.tokenCache.get(username); + } + + /** + * Clear stored token for a username + */ + clear(username: string): void { + this.tokenCache.delete(username); + } + + /** + * 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} ${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: string }; + + 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) { + return { + success: false, + error: + 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..516d6d8 --- /dev/null +++ b/test/integration/elicitationOverHttp.test.ts @@ -0,0 +1,356 @@ +// 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 { 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; +} + +/** + * 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 +): Promise { + const previewTool = new PreviewStyleTool({ httpRequest: previewHttpRequest }); + const comparisonTool = new StyleComparisonTool({ + httpRequest: comparisonHttpRequest + }); + + 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' }, + { capabilities: { elicitation: {} } } + ); + 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. other elicitation modes); this + * server only ever sends the form-mode shape (`message` + `requestedSchema`) that + * `elicitPreviewToken` builds, so narrowing here is safe for these tests. + */ +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; + + beforeEach(() => { + previewTokenStorage.clearAll(); + }); + + afterEach(async () => { + await client?.close().catch(() => {}); + await harness?.close(); + client = undefined; + harness = undefined; + }); + + it('trims the dialog to "provide" and never calls the Tokens API when the server token is tk.*', async () => { + const httpRequest = mockHttpRequest({ + GET: () => { + throw new Error('should not list tokens for a tk.* server token'); + }, + 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) => { + receivedEnum = getChoiceEnum(request); + return { + action: 'accept', + content: { choice: 'provide', token: EXISTING_PUBLIC_TOKEN } + }; + } + ); + + const result = await client.callTool({ + name: 'preview_style_tool', + arguments: { styleId: 'test-style' } + }); + + expect(result.isError).toBeFalsy(); + expect(receivedEnum).toEqual(['provide']); + expect(httpRequest).not.toHaveBeenCalled(); + }); + + 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({ + GET: () => { + throw new Error('should not list tokens for a tk.* server token'); + }, + 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) => { + receivedEnum = getChoiceEnum(request); + return { + action: 'accept', + content: { choice: 'provide', token: EXISTING_PUBLIC_TOKEN } + }; + } + ); + + 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).not.toHaveBeenCalled(); + }); +}); 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..d6c81ac 100644 --- a/test/tools/preview-style-tool/PreviewStyleTool.test.ts +++ b/test/tools/preview-style-tool/PreviewStyleTool.test.ts @@ -4,16 +4,22 @@ process.env.MAPBOX_ACCESS_TOKEN = 'sk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { PreviewStyleTool } from '../../../src/tools/preview-style-tool/PreviewStyleTool.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; 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 +34,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 +51,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 +67,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 +81,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 +95,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 +109,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 +127,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 +144,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 +180,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 +201,84 @@ describe('PreviewStyleTool', () => { type: 'resource' }); }); + + describe('elicitation behavior', () => { + 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 tool = new PreviewStyleTool({ httpRequest }); + + const elicitInput = vi.fn().mockResolvedValue({ + action: 'accept', + content: { choice: 'provide', token: TEST_ACCESS_TOKEN } + }); + // Simulate what BaseTool#installTo does, without a full MCP server. + tool['server'] = { + server: { + getClientCapabilities: () => ({ elicitation: {} }), + elicitInput + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + 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 } } as any + ); + + expect(result.isError).toBe(false); + expect(elicitInput).toHaveBeenCalledTimes(1); + const requestedSchema = elicitInput.mock.calls[0][0].requestedSchema; + expect(requestedSchema.properties.choice.enum).toEqual(['provide']); + + // A tk.* server token can never create tokens, so listing/creating + // tokens against the Mapbox API should never even be attempted. + expect(mockHttpRequest).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts index 5024af7..a301541 100644 --- a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts +++ b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -4,12 +4,18 @@ 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 { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; + +function styleComparisonTool() { + const { httpRequest } = setupHttpRequest(); + return new StyleComparisonTool({ httpRequest }); +} describe('StyleComparisonTool', () => { let tool: StyleComparisonTool; beforeEach(() => { - tool = new StyleComparisonTool(); + tool = styleComparisonTool(); }); afterEach(() => { @@ -50,19 +56,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 +267,86 @@ describe('StyleComparisonTool', () => { }); }); + describe('elicitation behavior', () => { + 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 tool = new StyleComparisonTool({ httpRequest }); + + const elicitInput = vi.fn().mockResolvedValue({ + action: 'accept', + content: { choice: 'provide', token: 'pk.test.token' } + }); + // Simulate what BaseTool#installTo does, without a full MCP server. + tool['server'] = { + server: { + getClientCapabilities: () => ({ elicitation: {} }), + elicitInput + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + 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 } } as any + ); + + expect(result.isError).toBe(false); + expect(elicitInput).toHaveBeenCalledTimes(1); + const requestedSchema = elicitInput.mock.calls[0][0].requestedSchema; + expect(requestedSchema.properties.choice.enum).toEqual(['provide']); + + // A tk.* server token can never create tokens, so listing/creating + // tokens against the Mapbox API should never even be attempted. + expect(mockHttpRequest).not.toHaveBeenCalled(); + }); + }); + describe('metadata', () => { it('should have correct name and description', () => { expect(tool.name).toBe('style_comparison_tool'); diff --git a/test/utils/tokenElicitation.test.ts b/test/utils/tokenElicitation.test.ts new file mode 100644 index 0000000..e472e8a --- /dev/null +++ b/test/utils/tokenElicitation.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + createPreviewToken, + elicitPreviewToken, + isTemporaryServerToken, + listPublicPreviewTokens, + previewTokenStorage +} from '../../src/utils/tokenElicitation.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'); + }); +}); + +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('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'); + }); +}); + +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 fakeServer(choice: string) { + const elicitInput = vi.fn().mockResolvedValue({ + action: 'accept', + content: { choice, token: 'pk.provided-token' } + }); + return { elicitInput } as unknown as Server; + } + + it('offers all three choices when the server token can create tokens', async () => { + const server = fakeServer('provide'); + await elicitPreviewToken(server, [], true); + + const request = (server.elicitInput as ReturnType).mock + .calls[0][0]; + expect(request.requestedSchema.properties.choice.enum).toEqual([ + 'provide', + 'create', + 'auto' + ]); + }); + + it('omits create/auto choices when the server token cannot create tokens', async () => { + const server = fakeServer('provide'); + await elicitPreviewToken(server, [], false); + + const request = (server.elicitInput as ReturnType).mock + .calls[0][0]; + expect(request.requestedSchema.properties.choice.enum).toEqual(['provide']); + expect(request.message).toContain('temporary session token'); + }); + + it('throws when the user declines elicitation', async () => { + const elicitInput = vi.fn().mockResolvedValue({ action: 'decline' }); + const server = { elicitInput } as unknown as Server; + + await expect(elicitPreviewToken(server, [], true)).rejects.toThrow( + 'Token elicitation was cancelled or declined by user' + ); + }); +});