Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
19cf40f
Add MCP elicitation for secure preview token handling
mattpodwysocki Jan 13, 2026
2b81c67
Fix: Ensure preview tokens are created as public tokens (pk.*)
mattpodwysocki Jan 13, 2026
0e68743
Fix: Use only public scopes to create public tokens (pk.*)
mattpodwysocki Jan 13, 2026
f51feff
Fix: Check client elicitation capability before using elicitInput()
mattpodwysocki Jan 13, 2026
ec35e38
Docs: Clarify varying MCP elicitation support across clients
mattpodwysocki Jan 13, 2026
b9a2246
Docs: Update elicitation support status for Cursor and VS Code
mattpodwysocki Jan 13, 2026
c974bcc
Docs: Add Goose elicitation bug report and documentation
mattpodwysocki Jan 13, 2026
ffdc83a
Docs: Link to filed Goose elicitation bug issue
mattpodwysocki Jan 13, 2026
08b2460
Remove redundant Goose bug report file
mattpodwysocki Jan 13, 2026
3c6f8a6
Tests: Add unit tests for elicitation and token storage
mattpodwysocki Jan 13, 2026
90cbd1b
Docs: Confirm Claude Code does not support elicitation yet
mattpodwysocki Jan 14, 2026
1e3e9e7
Add elicitation support to style_comparison_tool
mattpodwysocki Jan 15, 2026
14e54b3
Merge remote-tracking branch 'origin/main' into add-preview-token-eli…
mattpodwysocki Jul 31, 2026
19d3637
Add tk.* guard, HttpPipeline DI, and hosted-endpoint docs for token e…
mattpodwysocki Jul 31, 2026
8f9fcf2
Correct tk.* guard claims and improve the actual create-token failure…
mattpodwysocki Jul 31, 2026
9eaf79f
Add HTTP integration test for elicitation over the real MCP wire prot…
mattpodwysocki Jul 31, 2026
ae725b0
Fix race in HTTP integration test harness that caused CI flake
mattpodwysocki Jul 31, 2026
2b3fddc
Fix backwards client list in the no-elicitation-support error message
mattpodwysocki Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
## Unreleased

### New Features

- **Secure token elicitation for `preview_style_tool` / `style_comparison_tool`** (#57): `accessToken` is now optional on both tools. When omitted, the tool uses MCP elicitation to ask for a public token instead — either pasting an existing `pk.*` token, creating a new one with optional URL restrictions, or auto-creating a minimally-scoped one (`styles:read`, `styles:tiles`, `fonts:read`). This keeps your server's `sk.*`/`pk.*` access token out of chat history and preview URLs. Falls back to requiring `accessToken` directly on clients without elicitation support (Claude Desktop, Claude Code). A chosen/created token is cached in memory per account for the session; pass `useCustomToken: true` to force re-selection.
- If the server's own access token is a literal Mapbox temporary token (`tk.*`), the "create a new token" and "auto-create" options are omitted from the dialog up front, since that token shape is guaranteed to lack `tokens:write`. Other callers lacking `tokens:write` (e.g. the hosted MCP endpoint, which authenticates with its own access token rather than a Mapbox `pk.*`/`sk.*`/`tk.*` token) aren't detectable ahead of time; for those, choosing "create"/"auto-create" fails against the Tokens API with a scope/permission hint appended to the error, steering back to "I have a token to provide".

### Changed

- **`preview_style_tool` / `style_comparison_tool`**: token-listing and token-creation HTTP calls now go through the shared `HttpPipeline` (constructor-injected `httpRequest`) instead of a bare `fetch`, consistent with the rest of the API-calling tools.

## 0.8.2 - 2026-07-30

### Fixed
Expand Down
65 changes: 61 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ Get started by integrating with your preferred AI development environment:
- [Cursor Integration](./docs/cursor-integration.md) - Cursor IDE integration
- [VS Code Integration](./docs/vscode-integration.md) - Visual Studio Code with GitHub Copilot

**Note on MCP Elicitation Support**: Some tools (like `preview_style_tool` and `style_comparison_tool`) use [MCP elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to provide secure token management following the principle of least privilege. Elicitation ensures that only minimal-scope public tokens (pk._) appear in preview URLs, while your powerful server token (sk._) stays secure. This guided workflow also improves UX for token selection and creation. Elicitation support varies by client:

- **MCP Inspector**: ✅ Full support
- **Cursor**: ✅ Full support
- **VS Code (with Copilot)**: ✅ Full support
- **Goose**: ⚠️ Known bug - Form displays after timeout ([goose#6471](https://github.com/block/goose/issues/6471))
- **Claude Desktop**: ⚠️ Not yet supported (Claude will fall back to creating tokens via chat)
- **Claude Code**: ⚠️ Not yet supported (provide `accessToken` parameter directly)

**Note on the hosted MCP endpoint**: even on a client with full elicitation support, "create a new token" and "auto-create" will fail on the [hosted endpoint](#hosted-mcp-endpoint) — see below for why.

### DXT Package Distribution

This MCP server can be packaged as a DXT (Desktop Extension) file for easy distribution and installation. DXT is a standardized format for distributing local MCP servers, similar to browser extensions.
Expand Down Expand Up @@ -96,6 +107,13 @@ For quick access, you can use our hosted MCP endpoint:

For detailed setup instructions for different clients and API usage, see the [Hosted MCP Server Guide](https://github.com/mapbox/mcp-server/blob/main/docs/hosted-mcp-guide.md). Note: This guide references the standard MCP endpoint - you'll need to update the endpoint URL to use the devkit endpoint above.

**Token creation is unavailable on the hosted endpoint**: the hosted deployment authenticates each request with its own access token rather than your personal Mapbox account token, and that token is not granted `tokens:write`. As a result, on the hosted endpoint:

- `preview_style_tool` / `style_comparison_tool`'s elicitation dialog still offers all three options, but choosing "create a new token" or "auto-create" fails against the Mapbox Tokens API with a scope/permission error (the dialog can't know ahead of time that this particular deployment's token lacks `tokens:write` — see the `isTemporaryServerToken` caveat in `src/utils/tokenElicitation.ts` for tokens where it can tell).
- `create_token_tool` is not exposed on the hosted endpoint at all.

Choose **"I have a token to provide"** and paste an existing public token (`pk.*`, with `styles:read` scope), or provide `accessToken` directly. Create a token ahead of time from your [Mapbox Account page](https://account.mapbox.com/) if you don't have one. Running this server **locally** with your own `pk.*`/`sk.*` access token (which can carry `tokens:write`) also enables create and auto-create.

### Getting Your Mapbox Access Token

**A Mapbox access token is required to use this MCP server.**
Expand Down Expand Up @@ -169,11 +187,49 @@ Complete set of tools for managing Mapbox styles via the Styles API:
- Input: `styleId`
- Returns: Success confirmation

**PreviewStyleTool** - Generate preview URL for a Mapbox style using an existing public token
**PreviewStyleTool** - Generate preview URL for a Mapbox style with secure token handling

- Input: `styleId`, `title` (optional), `zoomwheel` (optional), `zoom` (optional), `center` (optional), `bearing` (optional), `pitch` (optional)
- Input:
- `styleId` (required): Style ID to preview
- `accessToken` (optional): Provide a specific public token (for backward compatibility)
- `useCustomToken` (optional): Force token selection dialog even if a token is cached
- `title` (optional): Show title in preview
- `zoomwheel` (optional): Enable zoom wheel control
- Returns: URL to open the style preview in browser
- **Note**: This tool automatically fetches the first available public token from your account for the preview URL. Requires at least one public token with `styles:read` scope.
- **🔐 Secure Token Management**: If `accessToken` is not provided, this tool uses MCP **elicitation** to create minimal-scope public tokens (pk._) instead of exposing your powerful server token. This follows the **principle of least privilege** - preview/comparison URLs only contain read-only tokens (styles:read, styles:tiles, fonts:read), keeping your server token (sk._) with write permissions secure. **Elicitation support varies by client**:
- **MCP Inspector, Cursor, VS Code**: ✅ Full support - Shows guided form dialog with three options:
1. **Provide an existing token** - Paste a token you already have
2. **Create a new preview token** - Create a new token with optional URL restrictions for enhanced security
3. **Auto-create a basic token** - Let the tool create a simple preview token for you
- **Goose**: ⚠️ Known bug - Form displays after timeout ([goose#6471](https://github.com/block/goose/issues/6471))
- **Claude Desktop, Claude Code**: ⚠️ Not yet supported - Provide `accessToken` parameter directly, or Claude will intelligently offer to create a token for you using `create_token_tool`
- **Hosted MCP endpoint**: ⚠️ "Create" and "auto-create" will fail regardless of client — see [Token creation is unavailable on the hosted endpoint](#hosted-mcp-endpoint)
- **Alternative**: Provide `accessToken` parameter directly for backward compatibility with any client
- **Session Storage**: Your token choice is cached for the session, so you only need to provide it once (when elicitation is supported)
- **Best Practice**: Use URL-restricted tokens to further limit token usage to specific domains. While public tokens in URLs are read-only, URL restrictions add an extra layer of security by ensuring tokens only work on your specified domains

**StyleComparisonTool** - Generate side-by-side comparison URL for two Mapbox styles

- Input:
- `before` (required): Mapbox style for the "before" side (accepts full style URL, username/styleId format, or just styleId)
- `after` (required): Mapbox style for the "after" side (accepts full style URL, username/styleId format, or just styleId)
- `accessToken` (optional): Provide a specific public token (for backward compatibility)
- `useCustomToken` (optional): Force token selection dialog even if a token is cached
- `zoom` (optional): Initial zoom level (0-22)
- `latitude` (optional): Latitude coordinate for initial map center (-90 to 90)
- `longitude` (optional): Longitude coordinate for initial map center (-180 to 180)
- Returns: URL to open the side-by-side style comparison in browser
- **🔐 Secure Token Management**: If `accessToken` is not provided, this tool uses MCP **elicitation** to create minimal-scope public tokens (pk._) instead of exposing your powerful server token. This follows the **principle of least privilege** - preview/comparison URLs only contain read-only tokens (styles:read, styles:tiles, fonts:read), keeping your server token (sk._) with write permissions secure. **Elicitation support varies by client**:
- **MCP Inspector, Cursor, VS Code**: ✅ Full support - Shows guided form dialog with three options:
1. **Provide an existing token** - Paste a token you already have
2. **Create a new preview token** - Create a new token with optional URL restrictions for enhanced security
3. **Auto-create a basic token** - Let the tool create a simple preview token for you
- **Goose**: ⚠️ Known bug - Form displays after timeout ([goose#6471](https://github.com/block/goose/issues/6471))
- **Claude Desktop, Claude Code**: ⚠️ Not yet supported - Provide `accessToken` parameter directly, or Claude will intelligently offer to create a token for you using `create_token_tool`
- **Hosted MCP endpoint**: ⚠️ "Create" and "auto-create" will fail regardless of client — see [Token creation is unavailable on the hosted endpoint](#hosted-mcp-endpoint)
- **Alternative**: Provide `accessToken` parameter directly for backward compatibility with any client
- **Session Storage**: Your token choice is cached for the session, so you only need to provide it once (when elicitation is supported)
- **Best Practice**: Use URL-restricted tokens to further limit token usage to specific domains. While public tokens in URLs are read-only, URL restrictions add an extra layer of security by ensuring tokens only work on your specified domains

**ValidateStyleTool** - Validate Mapbox style JSON against the Mapbox Style Specification

Expand All @@ -195,7 +251,8 @@ Complete set of tools for managing Mapbox styles via the Styles API:
- **RetrieveStyleTool**: Requires `styles:download` scope
- **UpdateStyleTool**: Requires `styles:write` scope
- **DeleteStyleTool**: Requires `styles:write` scope
- **PreviewStyleTool**: Requires `tokens:read` scope (to list tokens) and at least one public token with `styles:read` scope
- **PreviewStyleTool**: Can work without token scopes via elicitation, or optionally accepts a direct public token. If using automatic token listing, requires `tokens:read` scope
- **StyleComparisonTool**: Can work without token scopes via elicitation, or optionally accepts a direct public token. If using automatic token listing, requires `tokens:read` scope

**Note:** The username is automatically extracted from the JWT token payload.

Expand Down
4 changes: 2 additions & 2 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export const listTokens = new ListTokensTool({ httpRequest });
export const optimizeStyle = new OptimizeStyleTool();

/** Preview a Mapbox style */
export const previewStyle = new PreviewStyleTool();
export const previewStyle = new PreviewStyleTool({ httpRequest });

/** Retrieve a Mapbox style */
export const retrieveStyle = new RetrieveStyleTool({ httpRequest });
Expand All @@ -133,7 +133,7 @@ export const retrieveStyle = new RetrieveStyleTool({ httpRequest });
export const styleBuilder = new StyleBuilderTool();

/** Compare styles side-by-side */
export const styleComparison = new StyleComparisonTool();
export const styleComparison = new StyleComparisonTool({ httpRequest });

/** Query tiles at a location */
export const tilequery = new TilequeryTool({ httpRequest });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ export const PreviewStyleSchema = z.object({
'pk.',
'Invalid access token. Only public tokens (starting with pk.*) are allowed for preview URLs. Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs.'
)
.optional()
.describe(
'Mapbox public access token (optional). If not provided, you will be prompted to provide, create, or auto-create a preview token. Must start with pk.* and have styles:read permission. Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs.'
),
useCustomToken: z
.boolean()
.optional()
.default(false)
.describe(
'Mapbox public access token (required, must start with pk.* and have styles:read permission). Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs. Please use an existing public token or get one from list_tokens_tool or create one with create_token_tool with styles:read permission.'
'Force token selection dialog even if a preview token is already stored for this session. Useful when you want to use a different token.'
),
title: z
.boolean()
Expand Down
168 changes: 162 additions & 6 deletions src/tools/preview-style-tool/PreviewStyleTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import {
PreviewStyleInput
} from './PreviewStyleTool.input.schema.js';
import { getUserNameFromToken } from '../../utils/jwtUtils.js';
import {
createPreviewToken,
elicitPreviewToken,
isTemporaryServerToken,
listPublicPreviewTokens,
previewTokenStorage
} from '../../utils/tokenElicitation.js';
import type { HttpRequest } from '../../utils/types.js';

export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
readonly name = 'preview_style_tool';
Expand All @@ -32,14 +40,165 @@ export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
}
};

constructor() {
private readonly httpRequest: HttpRequest;

constructor(params: { httpRequest: HttpRequest }) {
super({ inputSchema: PreviewStyleSchema });
this.httpRequest = params.httpRequest;
}

protected async execute(input: PreviewStyleInput): Promise<CallToolResult> {
protected async execute(
input: PreviewStyleInput,
serverAccessToken?: string
): Promise<CallToolResult> {
let publicToken: string;
let userName: string;

// Step 1: Determine which token to use for preview
if (input.accessToken) {
// User provided token directly (backward compatibility)
publicToken = input.accessToken;
} else {
// No token provided - use elicitation flow
try {
// Get username from server access token to check storage
userName = getUserNameFromToken(serverAccessToken || '');
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text:
'Server access token is required when no preview token is provided. ' +
(error instanceof Error ? error.message : String(error))
}
]
};
}

// Check for stored preview token (unless user wants to use custom)
const storedToken = previewTokenStorage.get(userName);
if (storedToken && !input.useCustomToken) {
publicToken = storedToken;
} else {
// Need to elicit token from user
if (!this.server) {
return {
isError: true,
content: [
{
type: 'text',
text: 'Server not initialized. Cannot elicit token from user.'
}
]
};
}

// Check if client supports elicitation capability
const clientCapabilities = this.server.server.getClientCapabilities();
if (!clientCapabilities?.elicitation) {
return {
isError: true,
content: [
{
type: 'text',
text:
'Preview token required but client does not support elicitation. ' +
'Please provide an accessToken parameter directly, or use a client that supports MCP elicitation (e.g., MCP Inspector, Cursor, VS Code).'
}
]
};
}

// A server authenticated with a temporary tk.* token (e.g. the hosted MCP
// DevKit Server) can never call the Tokens API to create a new token, so
// the "create"/"auto" options are dropped from the dialog before asking.
const canCreateTokens = !isTemporaryServerToken(serverAccessToken!);

// Get existing public tokens to show user
const existingTokens = canCreateTokens
? await listPublicPreviewTokens(
this.httpRequest,
MapboxApiBasedTool.mapboxApiEndpoint,
serverAccessToken!,
userName
)
: [];

// Elicit token choice from user
const elicited = await elicitPreviewToken(
this.server.server,
existingTokens,
canCreateTokens
);

// Handle user's choice
if (elicited.choice === 'provide') {
if (!elicited.token) {
return {
isError: true,
content: [
{
type: 'text',
text: 'No token provided. Please provide a valid public token.'
}
]
};
}
publicToken = elicited.token;
} else if (elicited.choice === 'create') {
// Create new token with user's specifications
const created = await createPreviewToken(
this.httpRequest,
MapboxApiBasedTool.mapboxApiEndpoint,
serverAccessToken!,
userName,
elicited.tokenNote,
elicited.urlRestrictions
);
if (!created.success) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to create token: ${created.error}`
}
]
};
}
publicToken = created.token!;
} else {
// auto - create basic preview token
const created = await createPreviewToken(
this.httpRequest,
MapboxApiBasedTool.mapboxApiEndpoint,
serverAccessToken!,
userName
);
if (!created.success) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to auto-create token: ${created.error}`
}
]
};
}
publicToken = created.token!;
}

// Store token for future use
previewTokenStorage.set(userName, publicToken);
}
}

// Step 2: Get username from the preview token
try {
userName = getUserNameFromToken(input.accessToken);
userName = getUserNameFromToken(publicToken);
} catch (error) {
return {
isError: true,
Expand All @@ -52,9 +211,6 @@ export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
};
}

// 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);
Expand Down
Loading
Loading