Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ 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
- **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)

### 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 @@ -185,11 +194,25 @@ 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 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
- **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)
- **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

Expand All @@ -211,7 +234,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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
263 changes: 258 additions & 5 deletions src/tools/preview-style-tool/PreviewStyleTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof PreviewStyleSchema> {
readonly name = 'preview_style_tool';
Expand All @@ -25,10 +30,137 @@ export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
super({ inputSchema: PreviewStyleSchema });
}

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., Claude Desktop, Claude Code).'
}
]
};
}

// 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,
Expand All @@ -41,9 +173,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 Expand Up @@ -94,4 +223,128 @@ export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
isError: false
};
}

/**
* List existing public tokens from the user's Mapbox account
*/
private async listPublicTokens(
accessToken?: string
): Promise<ExistingTokenInfo[]> {
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,
// 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) {
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 };

// 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
};
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error creating token'
};
}
}
}
Loading
Loading