From 19cf40f9b751957c098a9fc8595ec61279afd116 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 13 Jan 2026 10:00:21 -0500 Subject: [PATCH 01/17] Add MCP elicitation for secure preview token handling - Implement token elicitation to keep tokens out of chat history - Users can provide, create, or auto-create preview tokens - Add session-level token storage to avoid repeated prompts - Support URL-restricted tokens for enhanced security - Maintain backward compatibility with direct token provision - Update README with security best practices Security improvements: - Preview tokens no longer appear in chat history via elicitation - Users can create URL-restricted tokens inline - Token caching reduces friction while maintaining security Co-Authored-By: Claude Sonnet 4.5 --- README.md | 20 +- .../PreviewStyleTool.input.schema.ts | 10 +- .../preview-style-tool/PreviewStyleTool.ts | 236 +++++++++++++++++- src/utils/tokenElicitation.ts | 163 ++++++++++++ 4 files changed, 418 insertions(+), 11 deletions(-) create mode 100644 src/utils/tokenElicitation.ts diff --git a/README.md b/README.md index 8d7a47e..281685c 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,21 @@ 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 - -- Input: `styleId`, `title` (optional), `zoomwheel` (optional), `zoom` (optional), `center` (optional), `bearing` (optional), `pitch` (optional) +**PreviewStyleTool** - Generate preview URL for a Mapbox style with secure token handling + +- 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 Handling**: If `accessToken` is not provided, this tool uses MCP **elicitation** to securely request a preview token from you without storing it in chat history. You'll be prompted to: + 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 +- **Session Storage**: Your token choice is cached for the session, so you only need to provide it once +- **Best Practice**: Use URL-restricted tokens (option 2) to limit token usage to specific domains **ValidateStyleTool** - Validate Mapbox style JSON against the Mapbox Style Specification @@ -211,7 +221,7 @@ 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 **Note:** The username is automatically extracted from the JWT token payload. diff --git a/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts index eec52c3..93a46b0 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts @@ -8,8 +8,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 cf028cf..dee980d 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -8,6 +8,11 @@ import { } from './PreviewStyleTool.input.schema.js'; import { getUserNameFromToken } from '../../utils/jwtUtils.js'; import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { + elicitPreviewToken, + previewTokenStorage, + type ExistingTokenInfo +} from '../../utils/tokenElicitation.js'; export class PreviewStyleTool extends BaseTool { readonly name = 'preview_style_tool'; @@ -25,10 +30,121 @@ export class PreviewStyleTool extends BaseTool { super({ inputSchema: PreviewStyleSchema }); } - 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.' + } + ] + }; + } + + // Get existing public tokens to show user + const existingTokens = await this.listPublicTokens(serverAccessToken); + + // Elicit token choice from user + const elicited = await elicitPreviewToken( + this.server.server, + existingTokens + ); + + // 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 this.createPreviewToken( + serverAccessToken, + 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 this.createPreviewToken(serverAccessToken); + 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, @@ -41,9 +157,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); @@ -94,4 +207,117 @@ export class PreviewStyleTool extends BaseTool { isError: false }; } + + /** + * List existing public tokens from the user's Mapbox account + */ + private async listPublicTokens( + accessToken?: string + ): Promise { + if (!accessToken) { + return []; + } + + try { + const userName = getUserNameFromToken(accessToken); + const response = await fetch( + `${MapboxApiBasedTool.mapboxApiEndpoint}tokens/v2/${userName}?access_token=${accessToken}` + ); + + if (!response.ok) { + // If we can't list tokens, return empty array (non-fatal) + return []; + } + + const data = await response.json(); + const tokens = data as Array<{ + id: string; + note: string; + scopes: string[]; + token?: string; + }>; + + // Filter to public tokens with styles:read scope + 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 { + // Non-fatal error - return empty array + return []; + } + } + + /** + * Create a new preview token via Mapbox API + */ + private async createPreviewToken( + accessToken?: string, + note?: string, + urlRestrictions?: string[] + ): Promise<{ success: boolean; token?: string; error?: string }> { + if (!accessToken) { + return { + success: false, + error: 'Server access token is required to create preview tokens' + }; + } + + try { + const userName = getUserNameFromToken(accessToken); + 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', 'styles:download'] + }; + + if (urlRestrictions && urlRestrictions.length > 0) { + body.allowedUrls = urlRestrictions; + } + + const response = await fetch( + `${MapboxApiBasedTool.mapboxApiEndpoint}tokens/v2/${userName}?access_token=${accessToken}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + error: `Failed to create token: ${response.status} ${errorText}` + }; + } + + const data = (await response.json()) as { token: string }; + return { + success: true, + token: data.token + }; + } catch (error) { + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Unknown error creating token' + }; + } + } } diff --git a/src/utils/tokenElicitation.ts b/src/utils/tokenElicitation.ts new file mode 100644 index 0000000..9b0a90c --- /dev/null +++ b/src/utils/tokenElicitation.ts @@ -0,0 +1,163 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; + +/** + * Token choice options for preview token elicitation + */ +export type TokenChoice = 'provide' | 'create' | 'auto'; + +/** + * 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 + * @returns Elicited token information based on user's choice + */ +export async function elicitPreviewToken( + server: Server, + existingTokens: ExistingTokenInfo[] +): 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 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} + +For best security, consider using a URL-restricted token that only works on your domains.`, + requestedSchema: { + type: 'object', + properties: { + choice: { + type: 'string', + title: 'Token Option', + description: 'How would you like to provide the preview token?', + enum: ['provide', 'create', 'auto'], + enumNames: [ + 'I have a token to provide', + 'Create a new preview token with custom settings', + 'Auto-create a basic preview token for me' + ] + }, + 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) || 'auto'; + 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(); From 2b81c6778961c981cd8fd706713d4221a24b38c4 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 13 Jan 2026 10:30:31 -0500 Subject: [PATCH 02/17] Fix: Ensure preview tokens are created as public tokens (pk.*) Critical security fix for PreviewStyleTool: - Add `public: true` flag to token creation API request body - Validate that created tokens start with 'pk.' prefix - Prevent accidental creation of secret tokens (sk.*) which should never be exposed in browser URLs This ensures preview URLs always use public tokens that can be safely shared in preview URLs without security risk. Co-Authored-By: Claude Sonnet 4.5 --- src/tools/preview-style-tool/PreviewStyleTool.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index dee980d..76ba8ad 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -277,9 +277,11 @@ export class PreviewStyleTool extends BaseTool { note: string; scopes: string[]; allowedUrls?: string[]; + public?: boolean; } = { note: tokenNote, - scopes: ['styles:read', 'styles:tiles', 'styles:download'] + scopes: ['styles:read', 'styles:tiles', 'styles:download'], + public: true // CRITICAL: Must be public token for browser URLs }; if (urlRestrictions && urlRestrictions.length > 0) { @@ -306,6 +308,15 @@ export class PreviewStyleTool extends BaseTool { } const data = (await response.json()) as { token: string }; + + // Validate that we got a public token (starts with pk.) + 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 From 0e687432360d071e002fbea633d2eb374f332e19 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 13 Jan 2026 10:34:27 -0500 Subject: [PATCH 03/17] Fix: Use only public scopes to create public tokens (pk.*) Root cause: The Mapbox Tokens API automatically determines token type (public vs secret) based on the SCOPES requested, not an explicit parameter. Problem: - We were requesting 'styles:download' which is a SECRET scope - This forced the API to create a secret token (sk.*) instead of public (pk.*) - Secret tokens cannot be safely exposed in browser URLs Solution: - Changed scopes to only public scopes: ['styles:read', 'styles:tiles', 'fonts:read'] - These are sufficient for preview URLs and guarantee public token creation - Removed the unsupported 'public: true' parameter - Updated comments to explain the scope selection rationale Testing: Verified in MCP Inspector that auto-create now produces pk.* tokens Co-Authored-By: Claude Sonnet 4.5 --- src/tools/preview-style-tool/PreviewStyleTool.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index 76ba8ad..74f2432 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -277,11 +277,11 @@ export class PreviewStyleTool extends BaseTool { note: string; scopes: string[]; allowedUrls?: string[]; - public?: boolean; } = { note: tokenNote, - scopes: ['styles:read', 'styles:tiles', 'styles:download'], - public: true // CRITICAL: Must be public token for browser URLs + // CRITICAL: Only use public scopes to get a public token (pk.*) + // styles:download is a secret scope and would create sk.* token + scopes: ['styles:read', 'styles:tiles', 'fonts:read'] }; if (urlRestrictions && urlRestrictions.length > 0) { From f51feff5734a132c2c60d24c4615670e21ab1afd Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 13 Jan 2026 11:00:39 -0500 Subject: [PATCH 04/17] Fix: Check client elicitation capability before using elicitInput() According to the MCP specification, servers must verify that the client supports elicitation capability before attempting to use elicitInput(). Changes: - Added client capability check before calling elicitPreviewToken() - Returns clear error message if client doesn't support elicitation - Suggests providing accessToken parameter directly as fallback - Prevents "Method not found" errors when client lacks capability This fixes the issue where tools using elicitation would fail on clients that don't advertise elicitation support in their capabilities. Reference: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation Co-Authored-By: Claude Sonnet 4.5 --- src/tools/preview-style-tool/PreviewStyleTool.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index 74f2432..9a316e8 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -78,6 +78,22 @@ export class PreviewStyleTool extends BaseTool { }; } + // 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., Claude Desktop, Claude Code).' + } + ] + }; + } + // Get existing public tokens to show user const existingTokens = await this.listPublicTokens(serverAccessToken); From ec35e385be966761f92ffad34bb8f92634059f57 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 13 Jan 2026 11:04:56 -0500 Subject: [PATCH 05/17] Docs: Clarify varying MCP elicitation support across clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added documentation to clarify that MCP elicitation support varies by client: - MCP Inspector has full support for secure token elicitation - Claude Desktop does not support elicitation yet, but Claude intelligently falls back to offering token creation via create_token_tool - Other clients should check their documentation for elicitation support Changes: - Added "Note on MCP Elicitation Support" in Quick Start section - Updated PreviewStyleTool description with client-specific behavior - Clarified that tokens appear in chat history when elicitation is unavailable - Added visual indicators (✅/⚠️) for support status This helps users understand expected behavior based on their MCP client. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 281685c..8501818 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ 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`) use [MCP elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to securely request tokens without exposing them in chat history. Elicitation support varies by client: + +- **MCP Inspector**: ✅ Full support +- **Claude Desktop**: ⚠️ Not yet supported (Claude will fall back to creating tokens via chat) +- **Claude Code, Cursor, VS Code**: Check client documentation for elicitation support status + ### 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. @@ -194,12 +200,15 @@ Complete set of tools for managing Mapbox styles via the Styles API: - `title` (optional): Show title in preview - `zoomwheel` (optional): Enable zoom wheel control - Returns: URL to open the style preview in browser -- **🔐 Secure Token Handling**: If `accessToken` is not provided, this tool uses MCP **elicitation** to securely request a preview token from you without storing it in chat history. You'll be prompted to: - 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 -- **Session Storage**: Your token choice is cached for the session, so you only need to provide it once -- **Best Practice**: Use URL-restricted tokens (option 2) to limit token usage to specific domains +- **🔐 Secure Token Handling**: If `accessToken` is not provided, this tool attempts to use MCP **elicitation** to securely request a preview token without storing it in chat history. However, **elicitation support varies by client**: + - **MCP Inspector**: ✅ Full support - Shows secure 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 + - **Claude Desktop**: ⚠️ Not yet supported - When elicitation is unavailable, Claude will intelligently offer to create a token for you using `create_token_tool` (token will appear in chat history) + - **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 limit token usage to specific domains **ValidateStyleTool** - Validate Mapbox style JSON against the Mapbox Style Specification From b9a22462c2dc14fb6427145cd23b79a4d9aae290 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 13 Jan 2026 11:15:27 -0500 Subject: [PATCH 06/17] Docs: Update elicitation support status for Cursor and VS Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed that Cursor and VS Code both have full MCP elicitation support. Updated README to accurately reflect support status: ✅ Full support: - MCP Inspector - Cursor - VS Code (with Copilot) ⚠️ Not yet supported: - Claude Desktop (falls back to create_token_tool) Co-Authored-By: Claude Sonnet 4.5 --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8501818..030f774 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,10 @@ Get started by integrating with your preferred AI development environment: **Note on MCP Elicitation Support**: Some tools (like `preview_style_tool`) use [MCP elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to securely request tokens without exposing them in chat history. Elicitation support varies by client: - **MCP Inspector**: ✅ Full support +- **Cursor**: ✅ Full support +- **VS Code (with Copilot)**: ✅ Full support - **Claude Desktop**: ⚠️ Not yet supported (Claude will fall back to creating tokens via chat) -- **Claude Code, Cursor, VS Code**: Check client documentation for elicitation support status +- **Claude Code**: Check for latest support status ### DXT Package Distribution @@ -200,8 +202,8 @@ Complete set of tools for managing Mapbox styles via the Styles API: - `title` (optional): Show title in preview - `zoomwheel` (optional): Enable zoom wheel control - Returns: URL to open the style preview in browser -- **🔐 Secure Token Handling**: If `accessToken` is not provided, this tool attempts to use MCP **elicitation** to securely request a preview token without storing it in chat history. However, **elicitation support varies by client**: - - **MCP Inspector**: ✅ Full support - Shows secure form dialog with three options: +- **🔐 Secure Token Handling**: If `accessToken` is not provided, this tool attempts to use MCP **elicitation** to securely request a preview token without storing it in chat history. **Elicitation support varies by client**: + - **MCP Inspector, Cursor, VS Code**: ✅ Full support - Shows secure 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 From c974bcc0015c5f9600f5d3874dbd944154dae970 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 13 Jan 2026 11:37:25 -0500 Subject: [PATCH 07/17] Docs: Add Goose elicitation bug report and documentation Created comprehensive bug report for Goose's MCP elicitation timing issue where forms display after timeout instead of during tool execution. Added: - docs/goose-elicitation-bug-report.md - Detailed bug report for Goose team with reproduction steps, expected vs actual behavior, technical details, and suggested fix - Updated README to document Goose's known elicitation bug with link to bug report in both Quick Start and PreviewStyleTool sections Bug Summary: Goose advertises elicitation capability but displays forms after tool execution completes/times out, preventing user input. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 2 + docs/goose-elicitation-bug-report.md | 177 +++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 docs/goose-elicitation-bug-report.md diff --git a/README.md b/README.md index 030f774..027825c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Get started by integrating with your preferred AI development environment: - **MCP Inspector**: ✅ Full support - **Cursor**: ✅ Full support - **VS Code (with Copilot)**: ✅ Full support +- **Goose**: ⚠️ Known bug - Form displays after timeout ([bug report](./docs/goose-elicitation-bug-report.md)) - **Claude Desktop**: ⚠️ Not yet supported (Claude will fall back to creating tokens via chat) - **Claude Code**: Check for latest support status @@ -207,6 +208,7 @@ Complete set of tools for managing Mapbox styles via the Styles API: 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 (see [bug report](./docs/goose-elicitation-bug-report.md)) - **Claude Desktop**: ⚠️ Not yet supported - When elicitation is unavailable, Claude will intelligently offer to create a token for you using `create_token_tool` (token will appear in chat history) - **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) diff --git a/docs/goose-elicitation-bug-report.md b/docs/goose-elicitation-bug-report.md new file mode 100644 index 0000000..d32434b --- /dev/null +++ b/docs/goose-elicitation-bug-report.md @@ -0,0 +1,177 @@ +# Goose MCP Elicitation Bug Report + +## Summary + +MCP elicitation forms display after tool execution timeout, preventing user input and making the elicitation feature unusable. + +## Environment + +- **Goose Version**: [Please specify] +- **MCP Server**: @mapbox/mcp-devkit-server v0.4.6 +- **MCP SDK Version**: @modelcontextprotocol/sdk v1.17.5 +- **Operating System**: macOS (confirmed), likely affects all platforms + +## Bug Description + +When an MCP tool calls `server.elicitInput()` to request user input, Goose advertises the `elicitation` capability but does not display the form in time for the user to interact with it. The form appears only **after** the tool call has timed out and completed, making elicitation unusable. + +## Steps to Reproduce + +1. Connect Goose to the Mapbox MCP DevKit Server +2. Call `preview_style_tool` without providing an `accessToken` parameter: + ``` + preview_style_tool({ styleId: "streets-v12" }) + ``` +3. Observe that: + - No elicitation form appears immediately + - Tool appears to hang/wait indefinitely + - After timeout period, tool fails or falls back + - **Then** the elicitation form appears in the UI + - Form is non-interactive/too late to provide input + +## Expected Behavior + +The elicitation form should: + +1. Appear **immediately** when `server.elicitInput()` is called +2. Block tool execution until user provides input or cancels +3. Allow user to interact with the form before any timeout +4. Return user input to the tool for processing + +This is how elicitation works correctly in: + +- MCP Inspector ✅ +- Cursor ✅ +- VS Code with GitHub Copilot ✅ + +## Actual Behavior + +The elicitation form: + +1. Does not appear when `server.elicitInput()` is called +2. Tool execution waits/hangs with no visible UI +3. Request times out after waiting period +4. Form appears **after** timeout in the UI +5. User never had opportunity to provide input +6. Creates misleading impression that elicitation is supported + +## Technical Details + +### Server-side code (working in other clients) + +```typescript +// Check if client supports elicitation capability +const clientCapabilities = this.server.server.getClientCapabilities(); +if (!clientCapabilities?.elicitation) { + return { + isError: true, + content: [{ type: 'text', text: 'Client does not support elicitation' }] + }; +} + +// Goose advertises elicitation capability, so this check passes ✅ + +// Attempt to elicit user input +const result = await server.elicitInput({ + message: 'Preview Token Setup...', + requestedSchema: { + type: 'object', + properties: { + choice: { + type: 'string', + enum: ['provide', 'create', 'auto'], + enumNames: [ + 'I have a token to provide', + 'Create a new preview token with custom settings', + 'Auto-create a basic preview token for me' + ] + }, + token: { type: 'string', minLength: 10 } + // ... other fields + }, + required: ['choice'] + } +}); + +// This await hangs indefinitely in Goose ❌ +// Form appears only after this times out +``` + +### What Goose advertises + +Goose correctly advertises elicitation capability during MCP handshake: + +```json +{ + "capabilities": { + "elicitation": {} + } +} +``` + +### Suspected Issue + +The elicitation form rendering appears to be: + +- Queued asynchronously rather than displayed synchronously +- Rendered after tool execution completes rather than during the `elicitInput()` call +- Not blocking the tool execution as required by MCP spec + +## Impact + +**High** - Renders MCP elicitation completely unusable in Goose: + +- Tools that require secure user input cannot function +- Users cannot use features designed to keep sensitive data out of chat history +- Creates poor UX with delayed/non-functional form + +## Workaround + +Users must provide sensitive parameters directly in tool calls: + +```typescript +preview_style_tool({ + styleId: 'streets-v12', + accessToken: 'pk.secret-token-in-chat-history' // Not ideal for security +}); +``` + +This defeats the purpose of elicitation (keeping tokens out of chat history). + +## References + +- MCP Elicitation Spec: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation +- MCP SDK elicitInput: https://github.com/modelcontextprotocol/sdk +- Issue discovered in PR: https://github.com/mapbox/mcp-devkit-server/pull/57 + +## Suggested Fix + +The elicitation form should be displayed **synchronously** when the server calls `elicitInput()`: + +1. Server sends elicitation request via MCP protocol +2. Goose immediately renders form UI (blocking) +3. User interacts with form +4. Form submission/cancellation returns to server +5. Tool execution continues with result + +The form render should **not** be queued or delayed until after tool completion. + +## Additional Context + +This bug was discovered while implementing secure token handling for the Mapbox MCP DevKit Server. The same code works perfectly in MCP Inspector, Cursor, and VS Code, suggesting the issue is specific to Goose's elicitation implementation. + +## Testing + +To verify a fix: + +1. Install @mapbox/mcp-devkit-server: `npx @modelcontextprotocol/create-server mapbox` +2. Configure with a Mapbox access token +3. Call `preview_style_tool` without `accessToken` parameter +4. Verify form appears **immediately** and accepts user input **before** timeout +5. Verify tool completes successfully with user-provided token + +--- + +**Report Date**: 2026-01-13 +**Reporter**: Mapbox MCP DevKit Server Team +**Goose Team**: Please let us know if you need any additional information or test cases! From ffdc83a34de596d170d8a3111e81d068bf0a9918 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 13 Jan 2026 11:47:06 -0500 Subject: [PATCH 08/17] Docs: Link to filed Goose elicitation bug issue Updated bug report and README to reference the filed GitHub issue: https://github.com/block/goose/issues/6471 This allows users and developers to track the bug status directly with the Goose team. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 4 ++-- docs/goose-elicitation-bug-report.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 027825c..b922624 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Get started by integrating with your preferred AI development environment: - **MCP Inspector**: ✅ Full support - **Cursor**: ✅ Full support - **VS Code (with Copilot)**: ✅ Full support -- **Goose**: ⚠️ Known bug - Form displays after timeout ([bug report](./docs/goose-elicitation-bug-report.md)) +- **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**: Check for latest support status @@ -208,7 +208,7 @@ Complete set of tools for managing Mapbox styles via the Styles API: 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 (see [bug report](./docs/goose-elicitation-bug-report.md)) + - **Goose**: ⚠️ Known bug - Form displays after timeout ([goose#6471](https://github.com/block/goose/issues/6471)) - **Claude Desktop**: ⚠️ Not yet supported - When elicitation is unavailable, Claude will intelligently offer to create a token for you using `create_token_tool` (token will appear in chat history) - **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) diff --git a/docs/goose-elicitation-bug-report.md b/docs/goose-elicitation-bug-report.md index d32434b..8aef87a 100644 --- a/docs/goose-elicitation-bug-report.md +++ b/docs/goose-elicitation-bug-report.md @@ -1,5 +1,7 @@ # Goose MCP Elicitation Bug Report +**Status**: 🐛 Filed - https://github.com/block/goose/issues/6471 + ## Summary MCP elicitation forms display after tool execution timeout, preventing user input and making the elicitation feature unusable. From 08b2460e51a4d895751d3d019c3335fef736da63 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 13 Jan 2026 11:49:15 -0500 Subject: [PATCH 09/17] Remove redundant Goose bug report file Bug is now tracked on GitHub at https://github.com/block/goose/issues/6471 No need to maintain a duplicate markdown file in the repo. Co-Authored-By: Claude Sonnet 4.5 --- docs/goose-elicitation-bug-report.md | 179 --------------------------- 1 file changed, 179 deletions(-) delete mode 100644 docs/goose-elicitation-bug-report.md diff --git a/docs/goose-elicitation-bug-report.md b/docs/goose-elicitation-bug-report.md deleted file mode 100644 index 8aef87a..0000000 --- a/docs/goose-elicitation-bug-report.md +++ /dev/null @@ -1,179 +0,0 @@ -# Goose MCP Elicitation Bug Report - -**Status**: 🐛 Filed - https://github.com/block/goose/issues/6471 - -## Summary - -MCP elicitation forms display after tool execution timeout, preventing user input and making the elicitation feature unusable. - -## Environment - -- **Goose Version**: [Please specify] -- **MCP Server**: @mapbox/mcp-devkit-server v0.4.6 -- **MCP SDK Version**: @modelcontextprotocol/sdk v1.17.5 -- **Operating System**: macOS (confirmed), likely affects all platforms - -## Bug Description - -When an MCP tool calls `server.elicitInput()` to request user input, Goose advertises the `elicitation` capability but does not display the form in time for the user to interact with it. The form appears only **after** the tool call has timed out and completed, making elicitation unusable. - -## Steps to Reproduce - -1. Connect Goose to the Mapbox MCP DevKit Server -2. Call `preview_style_tool` without providing an `accessToken` parameter: - ``` - preview_style_tool({ styleId: "streets-v12" }) - ``` -3. Observe that: - - No elicitation form appears immediately - - Tool appears to hang/wait indefinitely - - After timeout period, tool fails or falls back - - **Then** the elicitation form appears in the UI - - Form is non-interactive/too late to provide input - -## Expected Behavior - -The elicitation form should: - -1. Appear **immediately** when `server.elicitInput()` is called -2. Block tool execution until user provides input or cancels -3. Allow user to interact with the form before any timeout -4. Return user input to the tool for processing - -This is how elicitation works correctly in: - -- MCP Inspector ✅ -- Cursor ✅ -- VS Code with GitHub Copilot ✅ - -## Actual Behavior - -The elicitation form: - -1. Does not appear when `server.elicitInput()` is called -2. Tool execution waits/hangs with no visible UI -3. Request times out after waiting period -4. Form appears **after** timeout in the UI -5. User never had opportunity to provide input -6. Creates misleading impression that elicitation is supported - -## Technical Details - -### Server-side code (working in other clients) - -```typescript -// Check if client supports elicitation capability -const clientCapabilities = this.server.server.getClientCapabilities(); -if (!clientCapabilities?.elicitation) { - return { - isError: true, - content: [{ type: 'text', text: 'Client does not support elicitation' }] - }; -} - -// Goose advertises elicitation capability, so this check passes ✅ - -// Attempt to elicit user input -const result = await server.elicitInput({ - message: 'Preview Token Setup...', - requestedSchema: { - type: 'object', - properties: { - choice: { - type: 'string', - enum: ['provide', 'create', 'auto'], - enumNames: [ - 'I have a token to provide', - 'Create a new preview token with custom settings', - 'Auto-create a basic preview token for me' - ] - }, - token: { type: 'string', minLength: 10 } - // ... other fields - }, - required: ['choice'] - } -}); - -// This await hangs indefinitely in Goose ❌ -// Form appears only after this times out -``` - -### What Goose advertises - -Goose correctly advertises elicitation capability during MCP handshake: - -```json -{ - "capabilities": { - "elicitation": {} - } -} -``` - -### Suspected Issue - -The elicitation form rendering appears to be: - -- Queued asynchronously rather than displayed synchronously -- Rendered after tool execution completes rather than during the `elicitInput()` call -- Not blocking the tool execution as required by MCP spec - -## Impact - -**High** - Renders MCP elicitation completely unusable in Goose: - -- Tools that require secure user input cannot function -- Users cannot use features designed to keep sensitive data out of chat history -- Creates poor UX with delayed/non-functional form - -## Workaround - -Users must provide sensitive parameters directly in tool calls: - -```typescript -preview_style_tool({ - styleId: 'streets-v12', - accessToken: 'pk.secret-token-in-chat-history' // Not ideal for security -}); -``` - -This defeats the purpose of elicitation (keeping tokens out of chat history). - -## References - -- MCP Elicitation Spec: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation -- MCP SDK elicitInput: https://github.com/modelcontextprotocol/sdk -- Issue discovered in PR: https://github.com/mapbox/mcp-devkit-server/pull/57 - -## Suggested Fix - -The elicitation form should be displayed **synchronously** when the server calls `elicitInput()`: - -1. Server sends elicitation request via MCP protocol -2. Goose immediately renders form UI (blocking) -3. User interacts with form -4. Form submission/cancellation returns to server -5. Tool execution continues with result - -The form render should **not** be queued or delayed until after tool completion. - -## Additional Context - -This bug was discovered while implementing secure token handling for the Mapbox MCP DevKit Server. The same code works perfectly in MCP Inspector, Cursor, and VS Code, suggesting the issue is specific to Goose's elicitation implementation. - -## Testing - -To verify a fix: - -1. Install @mapbox/mcp-devkit-server: `npx @modelcontextprotocol/create-server mapbox` -2. Configure with a Mapbox access token -3. Call `preview_style_tool` without `accessToken` parameter -4. Verify form appears **immediately** and accepts user input **before** timeout -5. Verify tool completes successfully with user-provided token - ---- - -**Report Date**: 2026-01-13 -**Reporter**: Mapbox MCP DevKit Server Team -**Goose Team**: Please let us know if you need any additional information or test cases! From 3c6f8a629d5603255b0e53484726f8bd0de734cb Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 13 Jan 2026 14:53:44 -0500 Subject: [PATCH 10/17] Tests: Add unit tests for elicitation and token storage Added comprehensive test coverage for the new elicitation features: Token Storage Tests (test/utils/tokenElicitation.test.ts): - Store and retrieve tokens by username - Return undefined for non-existent username - Overwrite existing tokens - Store tokens for multiple users independently - Clear specific username token - Clear all tokens - Handle edge cases (empty string, special characters) PreviewStyleTool Elicitation Tests: - Error when no accessToken and no server token - Backward compatibility when accessToken provided directly Test Results: All 527 tests pass (12 new tests added) These tests ensure the elicitation feature works correctly and maintains backward compatibility with existing usage patterns. Co-Authored-By: Claude Sonnet 4.5 --- .../PreviewStyleTool.test.ts | 44 ++++++++++ test/utils/tokenElicitation.test.ts | 85 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 test/utils/tokenElicitation.test.ts diff --git a/test/tools/preview-style-tool/PreviewStyleTool.test.ts b/test/tools/preview-style-tool/PreviewStyleTool.test.ts index e8315c0..32c6793 100644 --- a/test/tools/preview-style-tool/PreviewStyleTool.test.ts +++ b/test/tools/preview-style-tool/PreviewStyleTool.test.ts @@ -195,4 +195,48 @@ describe('PreviewStyleTool', () => { // Clean up delete process.env.ENABLE_MCP_UI; }); + + describe('elicitation behavior', () => { + it('returns error when no accessToken and no valid server token', async () => { + const tool = new 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 = new 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.' + ) + }); + }); + }); }); diff --git a/test/utils/tokenElicitation.test.ts b/test/utils/tokenElicitation.test.ts new file mode 100644 index 0000000..dec2f92 --- /dev/null +++ b/test/utils/tokenElicitation.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { previewTokenStorage } from '../../src/utils/tokenElicitation.js'; + +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'); + }); +}); From 90cbd1b48176a68a9d4fbcdf736a1423ef14d97d Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 14 Jan 2026 11:11:16 -0500 Subject: [PATCH 11/17] Docs: Confirm Claude Code does not support elicitation yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tested preview_style_tool directly via MCP and confirmed that Claude Code does not advertise elicitation capability. The tool correctly returns the error message we designed for clients without elicitation support. Updated README to reflect: - Claude Code: ⚠️ Not yet supported (provide accessToken directly) - Grouped with Claude Desktop in the "not yet supported" category This was confirmed by calling the tool through the registered MCP server and observing the capability check work as expected. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b922624..27b2377 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Get started by integrating with your preferred AI development environment: - **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**: Check for latest support status +- **Claude Code**: ⚠️ Not yet supported (provide `accessToken` parameter directly) ### DXT Package Distribution @@ -209,7 +209,7 @@ Complete set of tools for managing Mapbox styles via the Styles API: 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**: ⚠️ Not yet supported - When elicitation is unavailable, Claude will intelligently offer to create a token for you using `create_token_tool` (token will appear in chat history) + - **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` (token will appear in chat history) - **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 limit token usage to specific domains From 1e3e9e7f0f3cc5342179db4539df7b27bbfdf4cc Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Thu, 15 Jan 2026 12:41:12 -0500 Subject: [PATCH 12/17] Add elicitation support to style_comparison_tool - Made accessToken optional and added useCustomToken parameter - Integrated elicitation flow with capability checks - Added token creation/listing methods with minimal public scopes - Session caching via shared previewTokenStorage - Added 2 elicitation behavior tests - Updated README with security-focused documentation - All 529 tests pass --- README.md | 33 ++- .../StyleComparisonTool.schema.ts | 10 +- .../StyleComparisonTool.ts | 212 +++++++++++++++++- .../StyleComparisonTool.test.ts | 59 ++++- 4 files changed, 296 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 27b2377..2739578 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ 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`) use [MCP elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to securely request tokens without exposing them in chat history. Elicitation support varies by client: +**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 @@ -203,16 +203,38 @@ Complete set of tools for managing Mapbox styles via the Styles API: - `title` (optional): Show title in preview - `zoomwheel` (optional): Enable zoom wheel control - Returns: URL to open the style preview in browser -- **🔐 Secure Token Handling**: If `accessToken` is not provided, this tool attempts to use MCP **elicitation** to securely request a preview token without storing it in chat history. **Elicitation support varies by client**: - - **MCP Inspector, Cursor, VS Code**: ✅ Full support - Shows secure form dialog with three options: +- **🔐 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` (token will appear in chat history) + - **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` - **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 limit token usage to specific domains +- **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` + - **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 @@ -235,6 +257,7 @@ Complete set of tools for managing Mapbox styles via the Styles API: - **UpdateStyleTool**: Requires `styles:write` scope - **DeleteStyleTool**: Requires `styles:write` 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/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts index ec25e25..1183589 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts @@ -20,8 +20,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 f9ce238..6fbe9aa 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -10,6 +10,11 @@ import { } from './StyleComparisonTool.schema.js'; import { getUserNameFromToken } from '../../utils/jwtUtils.js'; import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { + elicitPreviewToken, + previewTokenStorage +} from '../../utils/tokenElicitation.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; export class StyleComparisonTool extends BaseTool< typeof StyleComparisonSchema @@ -29,6 +34,122 @@ export class StyleComparisonTool extends BaseTool< super({ inputSchema: StyleComparisonSchema }); } + /** + * Override run to handle elicitation via RequestHandlerExtra + */ + async run( + rawInput: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extra?: RequestHandlerExtra + ): Promise { + try { + const input = this.inputSchema.parse(rawInput); + const serverAccessToken = + extra?.authInfo?.token || process.env.MAPBOX_ACCESS_TOKEN; + + // Validate server token exists + if (!serverAccessToken) { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Server access token is required when no preview token is provided. Please configure MAPBOX_ACCESS_TOKEN environment variable.' + } + ] + }; + } + + return this.execute(input, serverAccessToken); + } catch (error) { + return { + isError: true, + content: [{ type: 'text', text: (error as Error).message }] + }; + } + } + + /** + * List existing public tokens for elicitation + */ + private async listPublicTokens( + serverAccessToken?: string + ): Promise<{ id: string; note: string; scopes: string[] }[]> { + if (!serverAccessToken) return []; + + try { + const response = await fetch( + 'https://api.mapbox.com/tokens/v2?limit=100&usage=pk', + { + headers: { + Authorization: `Bearer ${serverAccessToken}` + } + } + ); + + if (!response.ok) return []; + + const data = (await response.json()) as Array<{ + id: string; + note: string; + scopes: string[]; + }>; + return data.map((token) => ({ + id: token.id, + note: token.note || 'Unnamed token', + scopes: token.scopes + })); + } catch { + return []; + } + } + + /** + * Create a new public preview token + */ + private async createPreviewToken( + serverAccessToken?: string, + tokenNote?: string, + urlRestrictions?: string[] + ): Promise<{ token: string }> { + if (!serverAccessToken) { + throw new Error('Server access token required to create preview tokens'); + } + + const body: { + note: string; + scopes: string[]; + allowedUrls?: string[]; + } = { + note: tokenNote || 'Auto-created preview token', + // CRITICAL: Only use public scopes to get a public token (pk.*) + // styles:download is a secret scope and would create sk.* token + scopes: ['styles:read', 'styles:tiles', 'fonts:read'] + }; + + // Add URL restrictions if provided + if (urlRestrictions && urlRestrictions.length > 0) { + body.allowedUrls = urlRestrictions; + } + + const response = await fetch('https://api.mapbox.com/tokens/v2', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${serverAccessToken}` + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to create preview token: ${error}`); + } + + const data = (await response.json()) as { token: string }; + return { token: data.token }; + } + /** * Processes style input to extract username/styleId format */ @@ -59,14 +180,97 @@ export class StyleComparisonTool extends BaseTool< } protected async execute( - input: StyleComparisonInput + input: StyleComparisonInput, + serverAccessToken?: string ): Promise { + // Handle token elicitation if accessToken not provided + let publicToken: string; + + if (input.accessToken) { + // Backward compatibility - use provided token directly + publicToken = input.accessToken; + } else { + // Need to elicit token from user + const userName = getUserNameFromToken(serverAccessToken || ''); + const storedToken = previewTokenStorage.get(userName); + + if (storedToken && !input.useCustomToken) { + // Use cached token + publicToken = storedToken; + } else { + // Check if client supports elicitation + if (!this.server?.server) { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Server not initialized. Cannot use elicitation.' + } + ] + }; + } + + 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 (MCP Inspector, Cursor, VS Code).' + } + ] + }; + } + + // Elicit from user + try { + const existingTokens = await this.listPublicTokens(serverAccessToken); + const elicited = await elicitPreviewToken( + this.server.server, + existingTokens + ); + + if (elicited.choice === 'provide') { + publicToken = elicited.token!; + } else if (elicited.choice === 'create') { + const created = await this.createPreviewToken( + serverAccessToken, + elicited.tokenNote, + elicited.urlRestrictions + ); + publicToken = created.token!; + } else { + // auto-create + const created = await this.createPreviewToken(serverAccessToken); + publicToken = created.token!; + } + + // Cache the token for this session + previewTokenStorage.set(userName, publicToken); + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Failed to elicit or create preview token: ${error instanceof Error ? error.message : 'Unknown 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: [ @@ -84,7 +288,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/test/tools/style-comparison-tool/StyleComparisonTool.test.ts b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts index b90ecfa..a7a9b72 100644 --- a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts +++ b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -50,19 +50,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('Required'); + 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 () => { @@ -235,6 +234,50 @@ describe('StyleComparisonTool', () => { }); }); + describe('elicitation behavior', () => { + it('returns error when no accessToken and no valid server token', async () => { + const tool = new 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 = new 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') + }); + }); + }); + describe('metadata', () => { it('should have correct name and description', () => { expect(tool.name).toBe('style_comparison_tool'); From 19d363770609cdbc682ef17e951a4ed9dfd6018a Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Fri, 31 Jul 2026 10:08:25 -0400 Subject: [PATCH 13/17] Add tk.* guard, HttpPipeline DI, and hosted-endpoint docs for token elicitation - Reject token creation up front when the server's access token is a temporary tk.* token (used by the hosted MCP endpoint), instead of letting the Mapbox API round-trip fail. The elicitation dialog now omits "create"/"auto-create" in that case and only offers "provide an existing token". - Move token-listing/creation off raw fetch() onto the shared HttpPipeline (constructor-injected httpRequest), consistent with other Mapbox API tools. - Document the hosted-endpoint limitation in README and CHANGELOG. --- CHANGELOG.md | 9 +++++++++ README.md | 11 +++++++++++ src/utils/tokenElicitation.ts | 6 +++--- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4611d3..3764769 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. + - On servers authenticated with a temporary `tk.*` token — notably the hosted MCP endpoint — Mapbox's Tokens API cannot create new tokens, so the "create a new token" and "auto-create" options are automatically omitted from the elicitation dialog rather than being offered and failing. + +### 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 d228100..e7df2e6 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Get started by integrating with your preferred AI development environment: - **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, the [hosted endpoint](#hosted-mcp-endpoint) only offers **"I have a token to provide"** — 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. @@ -105,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 server authenticates each request with a short-lived, per-session token (`tk.*`), not your own Mapbox account token. Mapbox's Tokens API never grants `tokens:write` to a `tk.*` token, so it cannot be used to create new tokens. As a result, on the hosted endpoint: + +- `preview_style_tool` / `style_comparison_tool`'s elicitation dialog only offers **"I have a token to provide"** — the "create a new token" and "auto-create" options are hidden automatically, rather than being offered and then failing. +- `create_token_tool` will fail with a permissions error if called directly. + +Paste an existing public token (`pk.*`, with `styles:read` scope) when prompted, or create one ahead of time from your [Mapbox Account page](https://account.mapbox.com/). Running this server **locally** with your own `pk.*`/`sk.*` access token (which can carry `tokens:write`) restores all three elicitation options, including create and auto-create. + ### Getting Your Mapbox Access Token **A Mapbox access token is required to use this MCP server.** @@ -194,6 +203,7 @@ Complete set of tools for managing Mapbox styles via the Styles API: 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**: ⚠️ Only "provide an existing token" is offered, 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 @@ -216,6 +226,7 @@ Complete set of tools for managing Mapbox styles via the Styles API: 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**: ⚠️ Only "provide an existing token" is offered, 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 diff --git a/src/utils/tokenElicitation.ts b/src/utils/tokenElicitation.ts index c63dbe9..23d7357 100644 --- a/src/utils/tokenElicitation.ts +++ b/src/utils/tokenElicitation.ts @@ -75,9 +75,9 @@ export async function elicitPreviewToken( .join('\n') : 'No existing public tokens found.'; - const choices = canCreateTokens - ? (['provide', 'create', 'auto'] as const) - : (['provide'] as const); + const choices: TokenChoice[] = canCreateTokens + ? ['provide', 'create', 'auto'] + : ['provide']; const choiceNames = canCreateTokens ? [ 'I have a token to provide', From 8f9fcf26a3f03a38f0ad0455b34a46e1d651ffbc Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Fri, 31 Jul 2026 10:30:17 -0400 Subject: [PATCH 14/17] Correct tk.* guard claims and improve the actual create-token failure message The tk.* prefix check only catches a literal Mapbox temporary token supplied directly (e.g. MAPBOX_ACCESS_TOKEN=tk...). It does not detect the hosted MCP endpoint's lack of tokens:write: that deployment passes through its own access token, which isn't tk.*-shaped, so the guard never fires there. Corrected the doc comments, README, and CHANGELOG, which previously stated this as a general fact about the hosted endpoint's token shape. Since the guard can't see that case, createPreviewToken() now appends a scope/permission hint to whatever error the Tokens API returns on a 401/403 (or a message containing "scope"/"permission"), steering back to "provide an existing token" instead of leaving the caller to interpret a bare API error. --- CHANGELOG.md | 2 +- README.md | 14 ++++---- src/utils/tokenElicitation.ts | 53 ++++++++++++++++++++++------- test/utils/tokenElicitation.test.ts | 22 +++++++++++- 4 files changed, 70 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3764769..5d0aaee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ### 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. - - On servers authenticated with a temporary `tk.*` token — notably the hosted MCP endpoint — Mapbox's Tokens API cannot create new tokens, so the "create a new token" and "auto-create" options are automatically omitted from the elicitation dialog rather than being offered and failing. + - 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 diff --git a/README.md b/README.md index e7df2e6..dce7279 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Get started by integrating with your preferred AI development environment: - **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, the [hosted endpoint](#hosted-mcp-endpoint) only offers **"I have a token to provide"** — see below for why. +**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 @@ -107,12 +107,12 @@ 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 server authenticates each request with a short-lived, per-session token (`tk.*`), not your own Mapbox account token. Mapbox's Tokens API never grants `tokens:write` to a `tk.*` token, so it cannot be used to create new tokens. As a result, on the hosted endpoint: +**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 only offers **"I have a token to provide"** — the "create a new token" and "auto-create" options are hidden automatically, rather than being offered and then failing. -- `create_token_tool` will fail with a permissions error if called directly. +- `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. -Paste an existing public token (`pk.*`, with `styles:read` scope) when prompted, or create one ahead of time from your [Mapbox Account page](https://account.mapbox.com/). Running this server **locally** with your own `pk.*`/`sk.*` access token (which can carry `tokens:write`) restores all three elicitation options, including create and auto-create. +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 @@ -203,7 +203,7 @@ Complete set of tools for managing Mapbox styles via the Styles API: 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**: ⚠️ Only "provide an existing token" is offered, regardless of client — see [Token creation is unavailable on the hosted endpoint](#hosted-mcp-endpoint) + - **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 @@ -226,7 +226,7 @@ Complete set of tools for managing Mapbox styles via the Styles API: 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**: ⚠️ Only "provide an existing token" is offered, regardless of client — see [Token creation is unavailable on the hosted endpoint](#hosted-mcp-endpoint) + - **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 diff --git a/src/utils/tokenElicitation.ts b/src/utils/tokenElicitation.ts index 23d7357..bc887bd 100644 --- a/src/utils/tokenElicitation.ts +++ b/src/utils/tokenElicitation.ts @@ -10,13 +10,20 @@ import type { HttpRequest } from './types.js'; export type TokenChoice = 'provide' | 'create' | 'auto'; /** - * Mapbox's Tokens API rejects requests to create a token when the caller is - * authenticated with a temporary token (`tk.*`) — temporary tokens are scoped - * to a single short-lived session and are never granted `tokens:write`. This - * is the case for the hosted MCP DevKit Server, which authenticates each - * request with a per-session `tk.*` token rather than the caller's own - * pk./sk. token. Detecting this upfront lets callers skip a doomed API round - * trip and steer the user straight to "provide an existing token" instead. + * 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.'); @@ -253,10 +260,13 @@ export async function listPublicPreviewTokens( * 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). * - * Returns a structured failure instead of throwing when the server's own access token is - * a temporary `tk.*` token (see {@link isTemporaryServerToken}) — the Tokens API rejects - * token-creation requests from those, so this is checked before making the request rather - * than surfacing whatever generic error the API happens to return for it. + * 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, @@ -307,9 +317,28 @@ export async function createPreviewToken( 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: `Failed to create token: ${response.status} ${errorText}` + error: message }; } diff --git a/test/utils/tokenElicitation.test.ts b/test/utils/tokenElicitation.test.ts index 00d1e22..e472e8a 100644 --- a/test/utils/tokenElicitation.test.ts +++ b/test/utils/tokenElicitation.test.ts @@ -165,7 +165,7 @@ describe('createPreviewToken', () => { expect(result.error).toContain('non-public token'); }); - it('surfaces API errors', async () => { + it('surfaces API errors with a scope hint on 403', async () => { const { httpRequest } = setupHttpRequest({ ok: false, status: 403, @@ -181,6 +181,26 @@ describe('createPreviewToken', () => { 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'); }); }); From 9eaf79f44a28713edb2f175e72ee5b01140d06e0 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Fri, 31 Jul 2026 10:50:55 -0400 Subject: [PATCH 15/17] Add HTTP integration test for elicitation over the real MCP wire protocol Every existing elicitation test fakes tool['server'] directly and never proves the SDK's own capability negotiation and request/response plumbing works end to end. This spins up a real Streamable HTTP MCP server (session-scoped, one McpServer/transport pair per Mcp-Session-Id) and drives it with a real Client that answers elicitation/create requests, modeled on hosted-mcp-server's own request handling (bearer token from Authorization attached to the raw request as .auth). Covers, fully offline (httpRequest mocked, no real network calls): - tk.* server token: dialog trims to ["provide"], Tokens API never called - non-tk.*-shaped token (the hosted-endpoint case): dialog offers all three choices, auto-create fails against a mocked 403 and the error includes the scope/permission hint - non-tk.*-shaped token: auto-create succeeds end to end - style_comparison_tool gets the same tk.* trimming as preview_style_tool Building this surfaced a real, separate finding worth a follow-up: a first attempt used a fresh McpServer per HTTP request (the "stateless" pattern both mcp-server's scripts/dev-http-server.ts and hosted-mcp-server's src/routes/mcp.ts use), and every elicitation call failed with "client does not support elicitation" regardless of what the client declared. Server#getClientCapabilities() is only ever set on whichever Server instance processes the client's initialize request; a fresh Server per request means the tools/call request's instance never saw that handshake. Documented in the harness's doc comment; not otherwise addressed here since it isn't this PR's tool code. --- test/integration/elicitationOverHttp.test.ts | 351 +++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 test/integration/elicitationOverHttp.test.ts diff --git a/test/integration/elicitationOverHttp.test.ts b/test/integration/elicitationOverHttp.test.ts new file mode 100644 index 0000000..23778d9 --- /dev/null +++ b/test/integration/elicitationOverHttp.test.ts @@ -0,0 +1,351 @@ +// 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(); + + function buildTransport(): StreamableHTTPServerTransport { + 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); + }; + void 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 ?? 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(); + }); +}); From ae725b0211dfdb8b91932e3db3e7ce6e99abd923 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Fri, 31 Jul 2026 10:56:30 -0400 Subject: [PATCH 16/17] Fix race in HTTP integration test harness that caused CI flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildTransport() called mcpServer.connect(transport) without awaiting it, then returned the transport for immediate use by the very next handleRequest() call. Locally the connect() promise happened to settle before that mattered; under CI's different scheduling, the first request (the client's initialize) sometimes raced ahead of the server's own wiring, and the request never got a response — observed as the style_comparison_tool test timing out after 60s with "MCP error -32001: Request timed out" while the other three tests in the same file passed. Made buildTransport async and awaited it at the call site. Ran the suite 8x locally with no failures after the fix (it never reproduced locally to begin with, consistent with a narrow scheduling-dependent race rather than a logic bug in the tools themselves). --- test/integration/elicitationOverHttp.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/integration/elicitationOverHttp.test.ts b/test/integration/elicitationOverHttp.test.ts index 23778d9..516d6d8 100644 --- a/test/integration/elicitationOverHttp.test.ts +++ b/test/integration/elicitationOverHttp.test.ts @@ -110,7 +110,7 @@ function startHarness( const sessions = new Map(); - function buildTransport(): StreamableHTTPServerTransport { + async function buildTransport(): Promise { const mcpServer = new McpServer( { name: 'elicitation-http-test', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } } @@ -130,7 +130,12 @@ function startHarness( transport.onclose = () => { if (transport.sessionId) sessions.delete(transport.sessionId); }; - void mcpServer.connect(transport); + // 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; } @@ -156,7 +161,7 @@ function startHarness( typeof sessionIdHeader === 'string' ? sessions.get(sessionIdHeader) : undefined; - const transport = existing ?? buildTransport(); + const transport = existing ?? (await buildTransport()); try { await transport.handleRequest(reqWithAuth, res); From 2b3fddca4877ddc762911341cd38072a4cc4a79e Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Fri, 31 Jul 2026 11:01:16 -0400 Subject: [PATCH 17/17] Fix backwards client list in the no-elicitation-support error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tools' fallback error (shown when the client lacks the elicitation capability) named Claude Desktop and Claude Code as example clients that support MCP elicitation — exactly backwards. Per the README's own support matrix, those two are the ones that *don't* support it; only MCP Inspector, Cursor, and VS Code do. Confirmed live in Claude Desktop, where the model was relaying this text almost verbatim while correctly working around it by asking the user for a pk. token directly. --- src/tools/preview-style-tool/PreviewStyleTool.ts | 2 +- src/tools/style-comparison-tool/StyleComparisonTool.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index 5d272a6..3f2292a 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -105,7 +105,7 @@ export class PreviewStyleTool extends BaseTool { 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., Claude Desktop, Claude Code).' + 'Please provide an accessToken parameter directly, or use a client that supports MCP elicitation (e.g., MCP Inspector, Cursor, VS Code).' } ] }; diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index 1e5b973..f429dc9 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -152,7 +152,7 @@ export class StyleComparisonTool extends BaseTool< 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., Claude Desktop, Claude Code).' + 'Please provide an accessToken parameter directly, or use a client that supports MCP elicitation (e.g., MCP Inspector, Cursor, VS Code).' } ] };