Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
## Unreleased

### New Features

- **`preview_style_tool` and `style_comparison_tool` no longer require an existing `accessToken`.** Both previously made `accessToken` a required `pk.*` input — meaning a caller had to already have (or separately go create) a public token before either tool would do anything, for even a quick one-off preview. `accessToken` is now optional on both: when omitted, the tool auto-generates a preview token from the server's own access token (the same pattern `geojson_preview_tool`'s resource already used) via a new shared `mintScopedPreviewToken` utility, so a first call needs no setup at all.
- Pass `share: true` alongside your own `accessToken` on either tool to get a link built from a token you manage yourself, for when you actually want something durable to bookmark or hand to someone else — `share: true` without an `accessToken` is a clear validation error, not a silent fallback.
- The two tools' auto-mint tokens differ in shape, confirmed live against the real API: `preview_style_tool` mints a short-lived (~1h) `tk.*` token, since the Styles API's embeddable HTML preview page and GL JS both accept it fine. `style_comparison_tool` mints a non-expiring `pk.*` token instead — `agent.mapbox.com/tools/style-compare`, which its comparison link embeds, validates the token prefix itself and hard-rejects anything but `pk.*` (a `tk.*` token 400s there with "Configuration Error: Invalid token type"). The `pk.*` token is still narrowly scoped (`styles:tiles`/`styles:read`/`fonts:read`) and auto-generated, just not self-expiring; it persists on the account until manually revoked.
- Both tools now take `httpRequest` as a constructor dependency (matching every other network-calling tool in this repo) to make the mint call.
- **Hosted-endpoint-aware error messages.** Per `README.md`, the hosted MCP endpoint authenticates with its own access token rather than a personal Mapbox account token, and that token isn't granted `tokens:write` — so auto-mint can't work there. Without this, a hosted caller who omits `accessToken` would have hit a raw, misleading `jwtUtils` error (`"MAPBOX_ACCESS_TOKEN is not in valid JWT format"`, referencing an env var the hosted deployment doesn't even use) or a bare `Token API 403`. A new `describeAutoMintFailure` helper rewrites both into an actionable message pointing at `accessToken`/`list_tokens_tool`/`create_token_tool` instead.

### Breaking Changes

- **Consolidated `GeojsonPreviewUIResource` and `PreviewStyleUIResource` into a single `MapPreviewUIResource`.** Both were near-identical hand-written MCP Apps templates — the same postMessage handshake, fullscreen/open-link controls, and resize handling copy-pasted across ~650 lines, differing only in what they drew on the map once a tool result arrived (a GeoJSON overlay on the default Standard style vs. swapping to an arbitrary preview style). `geojson_preview_tool` and `preview_style_tool` now both declare `_meta.ui.resourceUri: 'ui://mapbox/map-preview/index.html'`, served by the merged resource, which dispatches on the shape of the tool-result URL it receives (a `geojson.io` URL vs. a Styles API `.html?access_token=...` preview URL) rather than assuming a fixed mode. Neither tool's own input/output contract changed.
Expand Down
48 changes: 5 additions & 43 deletions src/resources/ui-apps/MapPreviewUIResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,46 +9,10 @@ import type {
} from '@modelcontextprotocol/sdk/types.js';
import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
import { BaseResource } from '../BaseResource.js';
import {
getUserNameFromToken,
mapboxApiEndpoint
} from '../../utils/jwtUtils.js';
import { mintScopedPreviewToken } from '../../utils/mintScopedPreviewToken.js';

const MAPBOX_GL_VERSION = '3.12.0';

// GL JS needs a public token; mint a short-lived one per request from the
// caller's sk.*. Do NOT cache it in module scope — on a multi-tenant server a
// process-global cache can return one caller's token to a different caller.
//
// Only used to bootstrap the map before any tool result has arrived (the
// GeoJSON-preview path draws on top of the default Standard style using this
// token). The style-preview path never needs it: its tool result URL already
// carries a token scoped to the style being previewed, which takes over via
// mapboxgl.accessToken once that result arrives — see handleToolResult below.
async function createPreviewToken(skToken: string): Promise<string> {
const username = getUserNameFromToken(skToken);
const expires = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
const url = `${mapboxApiEndpoint()}tokens/v2/${username}?access_token=${skToken}`;

const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
note: 'Map Preview (auto-generated, expires in 1h)',
scopes: ['styles:tiles', 'styles:read', 'fonts:read'],
expires
})
});

if (!response.ok) {
// Do not include the response body — it may echo the token back.
throw new Error(`Token API ${response.status}`);
}

const data = (await response.json()) as { token: string };
return data.token;
}

/**
* Serves the UI App HTML shared by `geojson_preview_tool` and
* `preview_style_tool` — one Mapbox GL JS map that either draws a GeoJSON
Expand Down Expand Up @@ -87,12 +51,10 @@ export class MapPreviewUIResource extends BaseResource {
let accessToken = '';
if (skToken.startsWith('sk.')) {
try {
const minted = await createPreviewToken(skToken);
// Defense in depth: only embed a token minted for the caller's own
// account, so a token can never be served to a different caller.
if (getUserNameFromToken(minted) === getUserNameFromToken(skToken)) {
accessToken = minted;
}
accessToken = await mintScopedPreviewToken(fetch, skToken, {
note: 'Map Preview (auto-generated, expires in 1h)',
scopes: ['styles:tiles', 'styles:read', 'fonts:read']
});
} catch {
// Non-fatal — map won't render until a style-preview result
// supplies its own token, but the link button still works.
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
10 changes: 9 additions & 1 deletion src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts
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(
'Existing Mapbox public token (must start with pk.* and have styles:read permission). Required when share is true, to build a durable link with a token you control. Optional otherwise — if omitted, a short-lived (~1 hour) preview token is generated automatically, so no existing token is needed for a quick inline look.'
),
share: 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.'
'Set to true to build a durable, shareable preview link using accessToken (required together with share). Defaults to false: generates a short-lived preview token automatically for viewing the style right now — the returned URL expires in about an hour and is not meant to be bookmarked or shared with others.'
),
title: z
.boolean()
Expand Down
75 changes: 65 additions & 10 deletions src/tools/preview-style-tool/PreviewStyleTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ import {
PreviewStyleInput
} from './PreviewStyleTool.input.schema.js';
import { getUserNameFromToken } from '../../utils/jwtUtils.js';
import {
mintScopedPreviewToken,
describeAutoMintFailure
} from '../../utils/mintScopedPreviewToken.js';
import type { HttpRequest } from '../../utils/types.js';

export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
readonly name = 'preview_style_tool';
readonly description =
'Generate preview URL for a Mapbox style using an existing public token';
'Generate a live preview of a Mapbox style. By default, auto-generates a short-lived preview token so you can view it right away — no existing token needed. Pass `share: true` with an existing public `accessToken` instead to generate a durable, shareable preview link.';
readonly annotations = {
readOnlyHint: true,
destructiveHint: false,
Expand All @@ -32,29 +37,79 @@ 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,
accessToken?: string
): Promise<CallToolResult> {
let userName: string;
try {
userName = getUserNameFromToken(input.accessToken);
} catch (error) {
let publicToken: string;

if (input.accessToken) {
// Caller-supplied token — used as-is, for either mode.
try {
userName = getUserNameFromToken(input.accessToken);
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: error instanceof Error ? error.message : String(error)
}
]
};
}
publicToken = input.accessToken;
} else if (input.share) {
return {
isError: true,
content: [
{
type: 'text',
text: error instanceof Error ? error.message : String(error)
text:
'`share: true` requires an existing public token via `accessToken` — a persistent ' +
'pk.* token is needed for a durable, shareable link. Get one via list_tokens_tool ' +
'or create_token_tool, or omit `share` for a quick inline preview (no token needed).'
}
]
};
} else {
if (!accessToken) {
return {
isError: true,
content: [
{
type: 'text',
text: 'No Mapbox access token available to generate a preview.'
}
]
};
}
try {
userName = getUserNameFromToken(accessToken);
publicToken = await mintScopedPreviewToken(
this.httpRequest,
accessToken,
{
note: 'Style Preview (auto-generated, expires in 1h)',
scopes: ['styles:tiles', 'styles:read', 'fonts:read']
}
);
} catch (error) {
return {
isError: true,
content: [{ type: 'text', text: describeAutoMintFailure(error) }]
};
}
}

// 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
10 changes: 9 additions & 1 deletion src/tools/style-comparison-tool/StyleComparisonTool.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,16 @@ export const StyleComparisonSchema = z.object({
'pk.',
'Invalid token type. Style comparison requires a public token (pk.*) that can be used in browser URLs. Secret tokens (sk.*) cannot be exposed in client-side applications. Please provide a public token with styles:read permission.'
)
.optional()
.describe(
'Existing Mapbox public token (must start with pk.* and have styles:read permission). Required when share is true, to build the comparison link with a token you control. Optional otherwise — if omitted, a scoped preview token is generated automatically, so no existing token is needed for a quick inline comparison.'
),
share: 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.'
'Set to true to build the comparison link with an existing public token you provide via accessToken (required together with share) — use this for a link you intend to keep or share with someone else. Defaults to false: generates a scoped preview token automatically, tied to this comparison only and not tracked or named by you, so no existing token is needed for a quick look right now.'
),
zoom: z
.number()
Expand Down
76 changes: 70 additions & 6 deletions src/tools/style-comparison-tool/StyleComparisonTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ import {
StyleComparisonInput
} from './StyleComparisonTool.schema.js';
import { getUserNameFromToken } from '../../utils/jwtUtils.js';
import {
mintScopedPreviewToken,
describeAutoMintFailure
} from '../../utils/mintScopedPreviewToken.js';
import type { HttpRequest } from '../../utils/types.js';

export class StyleComparisonTool extends BaseTool<
typeof StyleComparisonSchema
> {
readonly name = 'style_comparison_tool';
readonly description =
'Generate a comparison URL for comparing two Mapbox styles side-by-side';
'Generate a live side-by-side comparison of two Mapbox styles. By default, auto-generates a scoped preview token so you can compare them right away — no existing token needed. Pass `share: true` with an existing public `accessToken` instead to generate a comparison link using a token you manage yourself.';
readonly annotations = {
readOnlyHint: true,
destructiveHint: false,
Expand All @@ -36,8 +41,11 @@ export class StyleComparisonTool extends BaseTool<
}
};

constructor() {
private readonly httpRequest: HttpRequest;

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

/**
Expand Down Expand Up @@ -86,14 +94,70 @@ export class StyleComparisonTool extends BaseTool<
}

protected async execute(
input: StyleComparisonInput
input: StyleComparisonInput,
accessToken?: string
): Promise<CallToolResult> {
let publicToken: string;

if (input.accessToken) {
// Caller-supplied token — used as-is, for either mode.
publicToken = input.accessToken;
} else if (input.share) {
return {
isError: true,
content: [
{
type: 'text',
text:
'`share: true` requires an existing public token via `accessToken` — a persistent ' +
'pk.* token is needed for a durable, shareable link. Get one via list_tokens_tool ' +
'or create_token_tool, or omit `share` for a quick inline comparison (no token needed).'
}
]
};
} else {
if (!accessToken) {
return {
isError: true,
content: [
{
type: 'text',
text: 'No Mapbox access token available to generate a comparison.'
}
]
};
}
try {
// Unlike the inline style preview, the comparison page this URL
// points to (agent.mapbox.com/tools/style-compare) validates the
// token prefix itself and hard-rejects anything but pk.* — a
// short-lived tk.* token (this repo's usual auto-mint default)
// fails there with a "Configuration Error", confirmed live. So
// this mints a real, non-expiring pk.* token instead: narrowly
// scoped, but it persists on the account until manually revoked.
publicToken = await mintScopedPreviewToken(
this.httpRequest,
accessToken,
{
note: 'Style Comparison (auto-generated)',
scopes: ['styles:tiles', 'styles:read', 'fonts:read'],
expiresInMs: null
}
);
} catch (error) {
return {
isError: true,
content: [{ type: 'text', text: describeAutoMintFailure(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: [
Expand All @@ -111,7 +175,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);

Expand Down
4 changes: 2 additions & 2 deletions src/tools/toolRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ export const CORE_TOOLS = [
new RetrieveStyleTool({ httpRequest }),
new UpdateStyleTool({ httpRequest }),
new DeleteStyleTool({ httpRequest }),
new PreviewStyleTool(),
new PreviewStyleTool({ httpRequest }),
new StyleBuilderTool(),
new GeojsonPreviewTool(),
new CheckColorContrastTool(),
new CompareStylesTool(),
new OptimizeStyleTool(),
new StyleComparisonTool(),
new StyleComparisonTool({ httpRequest }),
new CreateTokenTool({ httpRequest }),
new ListTokensTool({ httpRequest }),
new BoundingBoxTool(),
Expand Down
Loading