diff --git a/CHANGELOG.md b/CHANGELOG.md index 044072b..8cdfb92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ ## 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. + - The public resource exports change: `previewStyleUI`/`geojsonPreviewUI` (from `@mapbox/mcp-devkit-server/resources`) are replaced by a single `mapPreviewUI`. `GeojsonPreviewUIResource`/`PreviewStyleUIResource` class exports are removed in favor of `MapPreviewUIResource`. + - `style_comparison_tool`'s `StyleComparisonUIResource` is untouched and stays separate — it renders two synced `mapboxgl.Map` instances under a swipe/compare slider, a genuinely different UI shape from "one map, different content," not just another mode to fold in. + - The GeoJSON-preview path keeps its existing eager map bootstrap (drawing the default Standard style immediately, before any tool result arrives, so an overlay has something to render onto right away); the style-preview path now reuses that same map instance via `setStyle()` when it exists, falling back to constructing its own (as before) when it doesn't. Verified live against the real Mapbox API in a real browser: the GeoJSON overlay path and the style-swap-in-place path (loading a real custom style by name) both work end-to-end on the merged resource. + ### Dependencies - Bumped `@modelcontextprotocol/sdk` to `1.30.0`. Not adopting the `2026-07-28` spec revision this release covers (stateless request/response model, elicitation replaced by Multi Round-Trip Requests, Sampling deprecated) — that's a separate migration, tracked in #130, given this repo's own elicitation-based features depend on the mechanism being replaced. Regenerated `patches/@modelcontextprotocol+sdk+1.30.0.patch` (previously pinned to `1.29.0`) — same patch content, applies cleanly to the new version, verified live against the built server. diff --git a/src/resources/index.ts b/src/resources/index.ts index bc8a1fc..162a413 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -23,18 +23,16 @@ export { MapboxStyleLayersResource } from './mapbox-style-layers-resource/Mapbox export { MapboxStreetsV8FieldsResource } from './mapbox-streets-v8-fields-resource/MapboxStreetsV8FieldsResource.js'; export { MapboxTokenScopesResource } from './mapbox-token-scopes-resource/MapboxTokenScopesResource.js'; export { MapboxLayerTypeMappingResource } from './mapbox-layer-type-mapping-resource/MapboxLayerTypeMappingResource.js'; -export { PreviewStyleUIResource } from './ui-apps/PreviewStyleUIResource.js'; +export { MapPreviewUIResource } from './ui-apps/MapPreviewUIResource.js'; export { StyleComparisonUIResource } from './ui-apps/StyleComparisonUIResource.js'; -export { GeojsonPreviewUIResource } from './ui-apps/GeojsonPreviewUIResource.js'; // Import resource classes for instantiation import { MapboxStyleLayersResource } from './mapbox-style-layers-resource/MapboxStyleLayersResource.js'; import { MapboxStreetsV8FieldsResource } from './mapbox-streets-v8-fields-resource/MapboxStreetsV8FieldsResource.js'; import { MapboxTokenScopesResource } from './mapbox-token-scopes-resource/MapboxTokenScopesResource.js'; import { MapboxLayerTypeMappingResource } from './mapbox-layer-type-mapping-resource/MapboxLayerTypeMappingResource.js'; -import { PreviewStyleUIResource } from './ui-apps/PreviewStyleUIResource.js'; +import { MapPreviewUIResource } from './ui-apps/MapPreviewUIResource.js'; import { StyleComparisonUIResource } from './ui-apps/StyleComparisonUIResource.js'; -import { GeojsonPreviewUIResource } from './ui-apps/GeojsonPreviewUIResource.js'; // Export pre-configured resource instances with short, clean names @@ -50,15 +48,15 @@ export const mapboxTokenScopes = new MapboxTokenScopesResource(); /** Mapbox layer type mapping reference */ export const mapboxLayerTypeMapping = new MapboxLayerTypeMappingResource(); -/** Preview style UI resource */ -export const previewStyleUI = new PreviewStyleUIResource(); +/** + * Shared map preview UI resource — serves both geojson_preview_tool and + * preview_style_tool's inline MCP Apps preview. + */ +export const mapPreviewUI = new MapPreviewUIResource(); /** Style comparison UI resource */ export const styleComparisonUI = new StyleComparisonUIResource(); -/** GeoJSON preview UI resource */ -export const geojsonPreviewUI = new GeojsonPreviewUIResource(); - // Export registry functions for batch access export { getAllResources, diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index a9758bc..a3c871a 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -5,9 +5,8 @@ import { MapboxStyleLayersResource } from './mapbox-style-layers-resource/Mapbox import { MapboxStreetsV8FieldsResource } from './mapbox-streets-v8-fields-resource/MapboxStreetsV8FieldsResource.js'; import { MapboxTokenScopesResource } from './mapbox-token-scopes-resource/MapboxTokenScopesResource.js'; import { MapboxLayerTypeMappingResource } from './mapbox-layer-type-mapping-resource/MapboxLayerTypeMappingResource.js'; -import { PreviewStyleUIResource } from './ui-apps/PreviewStyleUIResource.js'; +import { MapPreviewUIResource } from './ui-apps/MapPreviewUIResource.js'; import { StyleComparisonUIResource } from './ui-apps/StyleComparisonUIResource.js'; -import { GeojsonPreviewUIResource } from './ui-apps/GeojsonPreviewUIResource.js'; // Central registry of all resources export const ALL_RESOURCES = [ @@ -16,9 +15,8 @@ export const ALL_RESOURCES = [ new MapboxTokenScopesResource(), new MapboxLayerTypeMappingResource(), // MCP Apps UI resources (ui:// scheme) - new PreviewStyleUIResource(), - new StyleComparisonUIResource(), - new GeojsonPreviewUIResource() + new MapPreviewUIResource(), + new StyleComparisonUIResource() ] as const; export type ResourceInstance = (typeof ALL_RESOURCES)[number]; diff --git a/src/resources/ui-apps/GeojsonPreviewUIResource.ts b/src/resources/ui-apps/MapPreviewUIResource.ts similarity index 69% rename from src/resources/ui-apps/GeojsonPreviewUIResource.ts rename to src/resources/ui-apps/MapPreviewUIResource.ts index 48052fd..220ce81 100644 --- a/src/resources/ui-apps/GeojsonPreviewUIResource.ts +++ b/src/resources/ui-apps/MapPreviewUIResource.ts @@ -9,50 +9,32 @@ 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. -async function createPreviewToken(skToken: string): Promise { - 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: 'GeoJSON 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 UI App HTML for GeoJSON Preview using Mapbox GL JS directly. - * Renders GeoJSON inline — no inner iframe needed, so frame-src CSP is not an issue. - * Implements MCP Apps pattern with ui:// scheme. + * Serves the UI App HTML shared by `geojson_preview_tool` and + * `preview_style_tool` — one Mapbox GL JS map that either draws a GeoJSON + * overlay on the default Standard style, or swaps to an arbitrary preview + * style, depending on which tool's result arrives. Renders inline — no + * inner iframe needed, so frame-src CSP is not an issue. + * + * Previously two nearly-identical resources (GeojsonPreviewUIResource, + * PreviewStyleUIResource) each hand-wrote the same MCP-Apps postMessage + * handshake, fullscreen/open-link controls, and resize handling. Merged + * here since the only real difference between them was what they drew on + * the map once data arrived, not how the map/iframe worked. + * + * `style_comparison_tool`'s dual-map swipe UI (StyleComparisonUIResource) + * is a genuinely different UI shape — two synced map instances under a + * compare slider, not "one map, different content" — and stays separate. */ -export class GeojsonPreviewUIResource extends BaseResource { - readonly name = 'GeoJSON Preview UI'; - readonly uri = 'ui://mapbox/geojson-preview/index.html'; +export class MapPreviewUIResource extends BaseResource { + readonly name = 'Mapbox Map Preview UI'; + readonly uri = 'ui://mapbox/map-preview/index.html'; readonly description = - 'Interactive UI for previewing GeoJSON data rendered inline with Mapbox GL JS (MCP Apps)'; + 'Interactive UI for previewing GeoJSON data or Mapbox styles rendered inline with Mapbox GL JS (MCP Apps)'; readonly mimeType = RESOURCE_MIME_TYPE; public async readCallback( @@ -69,14 +51,13 @@ export class GeojsonPreviewUIResource 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 but the link button still works + // Non-fatal — map won't render until a style-preview result + // supplies its own token, but the link button still works. } } else if (skToken.startsWith('pk.')) { accessToken = skToken; // Already a public token @@ -87,7 +68,7 @@ export class GeojsonPreviewUIResource extends BaseResource { - GeoJSON Preview + Map Preview - - -
-
Loading style preview...
- -
- - - - - -`; - - return { - contents: [ - { - uri: this.uri, - mimeType: RESOURCE_MIME_TYPE, - text: html, - _meta: { - ui: { - csp: { - connectDomains: ['https://*.mapbox.com'], - resourceDomains: ['https://api.mapbox.com'], - workerDomains: ['blob:'] - }, - preferredSize: { - width: 1000, - height: 600 - } - } - } - } - ] - }; - } -} diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts index ce60f0c..4a76641 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts @@ -25,7 +25,7 @@ export class GeojsonPreviewTool extends BaseTool { readonly meta = { ui: { - resourceUri: 'ui://mapbox/geojson-preview/index.html', + resourceUri: 'ui://mapbox/map-preview/index.html', csp: { frameDomains: ['https://geojson.io'] } diff --git a/src/tools/index.ts b/src/tools/index.ts index ca134f3..91a537e 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -124,7 +124,7 @@ export const listTokens = new ListTokensTool({ httpRequest }); export const optimizeStyle = new OptimizeStyleTool(); /** Preview a Mapbox style */ -export const previewStyle = new PreviewStyleTool(); +export const previewStyle = new PreviewStyleTool({ httpRequest }); /** Retrieve a Mapbox style */ export const retrieveStyle = new RetrieveStyleTool({ httpRequest }); @@ -133,7 +133,7 @@ export const retrieveStyle = new RetrieveStyleTool({ httpRequest }); export const styleBuilder = new StyleBuilderTool(); /** Compare styles side-by-side */ -export const styleComparison = new StyleComparisonTool(); +export const styleComparison = new StyleComparisonTool({ httpRequest }); /** Query tiles at a location */ export const tilequery = new TilequeryTool({ httpRequest }); diff --git a/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts index fb9111b..78dbb6d 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts @@ -9,8 +9,16 @@ export const PreviewStyleSchema = z.object({ 'pk.', 'Invalid access token. Only public tokens (starting with pk.*) are allowed for preview URLs. Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs.' ) + .optional() + .describe( + '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() diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index f445161..de7544c 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -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 { 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, @@ -23,7 +28,7 @@ export class PreviewStyleTool extends BaseTool { readonly meta = { ui: { - resourceUri: 'ui://mapbox/preview-style/index.html', + resourceUri: 'ui://mapbox/map-preview/index.html', csp: { connectDomains: ['https://*.mapbox.com'], resourceDomains: ['https://*.mapbox.com'], @@ -32,29 +37,79 @@ export class PreviewStyleTool extends BaseTool { } }; - constructor() { + private readonly httpRequest: HttpRequest; + + constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: PreviewStyleSchema }); + this.httpRequest = params.httpRequest; } - protected async execute(input: PreviewStyleInput): Promise { + protected async execute( + input: PreviewStyleInput, + accessToken?: string + ): Promise { 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); diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts index 1265c11..f23d215 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts @@ -28,8 +28,16 @@ export const StyleComparisonSchema = z.object({ 'pk.', 'Invalid token type. Style comparison requires a public token (pk.*) that can be used in browser URLs. Secret tokens (sk.*) cannot be exposed in client-side applications. Please provide a public token with styles:read permission.' ) + .optional() + .describe( + '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() diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index e916b5a..f925957 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -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, @@ -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; } /** @@ -86,14 +94,70 @@ export class StyleComparisonTool extends BaseTool< } protected async execute( - input: StyleComparisonInput + input: StyleComparisonInput, + accessToken?: string ): Promise { + 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: [ @@ -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); diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 5e9237d..64f181f 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -36,13 +36,13 @@ export const CORE_TOOLS = [ new RetrieveStyleTool({ httpRequest }), new UpdateStyleTool({ httpRequest }), new DeleteStyleTool({ httpRequest }), - new PreviewStyleTool(), + new PreviewStyleTool({ httpRequest }), new StyleBuilderTool(), new GeojsonPreviewTool(), new CheckColorContrastTool(), new CompareStylesTool(), new OptimizeStyleTool(), - new StyleComparisonTool(), + new StyleComparisonTool({ httpRequest }), new CreateTokenTool({ httpRequest }), new ListTokensTool({ httpRequest }), new BoundingBoxTool(), diff --git a/src/utils/mintScopedPreviewToken.ts b/src/utils/mintScopedPreviewToken.ts new file mode 100644 index 0000000..fafeccd --- /dev/null +++ b/src/utils/mintScopedPreviewToken.ts @@ -0,0 +1,123 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { HttpRequest } from './types.js'; +import { getUserNameFromToken, mapboxApiEndpoint } from './jwtUtils.js'; + +/** + * Mints a public token scoped to `scopes`, on the same account as + * `serverAccessToken`. Used to auto-generate a preview token when a caller + * hasn't supplied — and doesn't need to manage — a token of their own, e.g. + * for a one-off inline style preview or comparison. + * + * By default (`expiresInMs` omitted or a number), the Tokens API mints a + * short-lived **`tk.*`** temporary token — confirmed live against the real + * API — which is what GL JS and the Styles API's embeddable HTML preview + * page both accept. Pass `expiresInMs: null` to mint a genuine, non-expiring + * **`pk.*`** public token instead — required for any consumer that validates + * the token prefix itself and rejects non-`pk.*` tokens outright (confirmed + * live: `agent.mapbox.com/tools/style-compare`, which `style_comparison_tool` + * embeds, does exactly this and 400s on a `tk.*` token). A `pk.*` token + * minted this way does not self-expire; it persists on the account (scoped + * narrowly to `scopes`) until manually revoked. + * + * Verifies the minted token belongs to the caller's own account before + * returning it — defense in depth against a misbehaving/misconfigured + * backend returning a different account's token (see the AGI-905 regression + * suite this same check protects against in `MapPreviewUIResource`). + * + * Do NOT cache the result across calls: on a multi-tenant server, caching + * by anything less than the caller's own identity risks handing one + * caller's token to another. + */ +export async function mintScopedPreviewToken( + httpRequest: HttpRequest, + serverAccessToken: string, + params: { note: string; scopes: string[]; expiresInMs?: number | null } +): Promise { + const expectedUsername = getUserNameFromToken(serverAccessToken); + const expires = + params.expiresInMs === null + ? undefined + : new Date( + Date.now() + (params.expiresInMs ?? 60 * 60 * 1000) + ).toISOString(); + + const url = `${mapboxApiEndpoint()}tokens/v2/${expectedUsername}?access_token=${serverAccessToken}`; + const response = await httpRequest(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + note: params.note, + scopes: params.scopes, + ...(expires ? { 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 }; + if (getUserNameFromToken(data.token) !== expectedUsername) { + throw new Error('Minted token does not match caller account'); + } + return data.token; +} + +/** + * Turns a failure from the auto-mint path (either `getUserNameFromToken` + * on the server's own token, or `mintScopedPreviewToken` itself) into a + * message that actually tells the caller what to do next, instead of + * surfacing jwtUtils' raw, `MAPBOX_ACCESS_TOKEN`-flavored error text or a + * bare `Token API 403`. + * + * Two known failure shapes, both confirmed against this repo's own code + * and docs rather than assumed: + * + * 1. The server's own access token isn't a personal Mapbox account token + * at all, so a username can't even be resolved from it before a mint + * attempt — this is the hosted MCP endpoint's normal case. Per + * README.md: "the hosted deployment authenticates each request with + * its own access token rather than your personal Mapbox account + * token" and doesn't expose `create_token_tool` at all. There's no + * reliable way to detect this shape ahead of time (it isn't a Mapbox + * `pk./sk./tk.` token, so it never reaches the mint call), so this is + * keyed off `getUserNameFromToken`'s own error text. + * 2. The server's own token IS a Mapbox token but lacks `tokens:write` — + * the mint call itself 401s/403s. + * + * Anything else (e.g. a transient network/5xx failure) is passed through + * unchanged rather than guessed at. + */ +export function describeAutoMintFailure(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const actionableSuffix = + 'Pass accessToken directly instead: get an existing public token via ' + + 'list_tokens_tool, or create one with create_token_tool if it is ' + + 'available in this deployment.'; + + const cannotResolveAccount = + message.includes('is not in valid JWT format') || + message.includes('does not contain username in payload') || + message.includes('No access token provided'); + if (cannotResolveAccount) { + return ( + "Could not auto-generate a preview token: this server's own access " + + "token isn't a personal Mapbox account token (expected on " + + 'deployments — like the hosted MCP endpoint — that authenticate a ' + + `different way). ${actionableSuffix}` + ); + } + + if (/^Token API 40[13]$/.test(message)) { + return ( + "Could not auto-generate a preview token: this server's access " + + 'token does not have permission to create new tokens (tokens:write). ' + + actionableSuffix + ); + } + + return message; +} diff --git a/test/resources/ui-apps/GeojsonPreviewUIResource.test.ts b/test/resources/ui-apps/GeojsonPreviewUIResource.test.ts deleted file mode 100644 index 95ed635..0000000 --- a/test/resources/ui-apps/GeojsonPreviewUIResource.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { GeojsonPreviewUIResource } from '../../../src/resources/ui-apps/GeojsonPreviewUIResource.js'; - -const uri = new URL('ui://mapbox/geojson-preview/index.html'); - -// Build a Mapbox-style 3-part JWT whose payload carries the username (`u`). -function makeToken(prefix: 'sk' | 'pk' | 'tk', username: string): string { - const payload = Buffer.from(JSON.stringify({ u: username })).toString( - 'base64' - ); - return `${prefix}.${payload}.sig`; -} - -function embeddedToken(html: string): string | null { - const m = html.match(/var TOKEN = '([^']*)'/); - return m ? m[1] : null; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function extra(token?: string): any { - return token ? { authInfo: { token } } : {}; -} - -async function readHtml( - resource: GeojsonPreviewUIResource, - token?: string -): Promise { - const result = await resource['readCallback'](uri, extra(token)); - return result.contents[0].text as string; -} - -// Stub global fetch to mirror real Mapbox behaviour: POST tokens/v2/{username} -// mints a `tk` token for THAT account. Returns the mock for call assertions. -function stubMintingFetch() { - const fn = vi.fn(async (input: string | URL | Request) => { - const url = String(input); - const username = decodeURIComponent( - url.match(/tokens\/v2\/([^?]+)/)?.[1] ?? '' - ); - return new Response(JSON.stringify({ token: makeToken('tk', username) }), { - status: 200 - }); - }); - vi.stubGlobal('fetch', fn); - return fn; -} - -describe('GeojsonPreviewUIResource — AGI-905 cross-account token leak', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('embeds only the caller’s own minted token, never another account’s (regression)', async () => { - const fetchMock = stubMintingFetch(); - const resource = new GeojsonPreviewUIResource(); - - const htmlA = await readHtml(resource, makeToken('sk', 'accountA')); - const htmlB = await readHtml(resource, makeToken('sk', 'accountB')); - - const tokA = embeddedToken(htmlA); - const tokB = embeddedToken(htmlB); - - // Each caller receives a token minted for their own account. - expect(tokA).toBe(makeToken('tk', 'accountA')); - expect(tokB).toBe(makeToken('tk', 'accountB')); - - // B must never receive A's token. - expect(tokB).not.toBe(tokA); - expect(htmlB).not.toContain(tokA as string); - - // No process-global cache: each read mints fresh. - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('mints a fresh token on every read (no shared cache, even for the same caller)', async () => { - const fetchMock = stubMintingFetch(); - const resource = new GeojsonPreviewUIResource(); - const sk = makeToken('sk', 'acct'); - - await readHtml(resource, sk); - await readHtml(resource, sk); - - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('does not embed a token minted for a different account (identity assertion)', async () => { - // Simulate a (hypothetical) backend returning a token for someone else. - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response(JSON.stringify({ token: makeToken('tk', 'attacker') }), { - status: 200 - }) - ) - ); - const resource = new GeojsonPreviewUIResource(); - - const html = await readHtml(resource, makeToken('sk', 'victim')); - - expect(embeddedToken(html)).toBe(''); - }); - - it('renders without a token when minting fails (graceful degradation)', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response('forbidden', { status: 403 })) - ); - const resource = new GeojsonPreviewUIResource(); - - const result = await resource['readCallback']( - uri, - extra(makeToken('sk', 'acct')) - ); - - expect(result.contents).toHaveLength(1); - expect(embeddedToken(result.contents[0].text as string)).toBe(''); - }); - - it('passes a pk token through unchanged without minting', async () => { - const fetchMock = stubMintingFetch(); - const resource = new GeojsonPreviewUIResource(); - const pk = makeToken('pk', 'acct'); - - const html = await readHtml(resource, pk); - - expect(embeddedToken(html)).toBe(pk); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('renders without a token when no token is provided', async () => { - const saved = process.env.MAPBOX_ACCESS_TOKEN; - delete process.env.MAPBOX_ACCESS_TOKEN; - try { - const fetchMock = stubMintingFetch(); - const resource = new GeojsonPreviewUIResource(); - - const html = await readHtml(resource); - - expect(embeddedToken(html)).toBe(''); - expect(fetchMock).not.toHaveBeenCalled(); - } finally { - if (saved !== undefined) process.env.MAPBOX_ACCESS_TOKEN = saved; - } - }); -}); diff --git a/test/resources/ui-apps/MapPreviewUIResource.test.ts b/test/resources/ui-apps/MapPreviewUIResource.test.ts new file mode 100644 index 0000000..7322f66 --- /dev/null +++ b/test/resources/ui-apps/MapPreviewUIResource.test.ts @@ -0,0 +1,372 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as vm from 'node:vm'; +import { MapPreviewUIResource } from '../../../src/resources/ui-apps/MapPreviewUIResource.js'; + +const uri = new URL('ui://mapbox/map-preview/index.html'); + +// Build a Mapbox-style 3-part JWT whose payload carries the username (`u`). +function makeToken(prefix: 'sk' | 'pk' | 'tk', username: string): string { + const payload = Buffer.from(JSON.stringify({ u: username })).toString( + 'base64' + ); + return `${prefix}.${payload}.sig`; +} + +function embeddedToken(html: string): string | null { + const m = html.match(/var TOKEN = '([^']*)'/); + return m ? m[1] : null; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extra(token?: string): any { + return token ? { authInfo: { token } } : {}; +} + +async function readHtml( + resource: MapPreviewUIResource, + token?: string +): Promise { + const result = await resource['readCallback'](uri, extra(token)); + return result.contents[0].text as string; +} + +// Stub global fetch to mirror real Mapbox behaviour: POST tokens/v2/{username} +// mints a `tk` token for THAT account. Returns the mock for call assertions. +function stubMintingFetch() { + const fn = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + const username = decodeURIComponent( + url.match(/tokens\/v2\/([^?]+)/)?.[1] ?? '' + ); + return new Response(JSON.stringify({ token: makeToken('tk', username) }), { + status: 200 + }); + }); + vi.stubGlobal('fetch', fn); + return fn; +} + +describe('MapPreviewUIResource — AGI-905 cross-account token leak', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('embeds only the caller’s own minted token, never another account’s (regression)', async () => { + const fetchMock = stubMintingFetch(); + const resource = new MapPreviewUIResource(); + + const htmlA = await readHtml(resource, makeToken('sk', 'accountA')); + const htmlB = await readHtml(resource, makeToken('sk', 'accountB')); + + const tokA = embeddedToken(htmlA); + const tokB = embeddedToken(htmlB); + + // Each caller receives a token minted for their own account. + expect(tokA).toBe(makeToken('tk', 'accountA')); + expect(tokB).toBe(makeToken('tk', 'accountB')); + + // B must never receive A's token. + expect(tokB).not.toBe(tokA); + expect(htmlB).not.toContain(tokA as string); + + // No process-global cache: each read mints fresh. + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('mints a fresh token on every read (no shared cache, even for the same caller)', async () => { + const fetchMock = stubMintingFetch(); + const resource = new MapPreviewUIResource(); + const sk = makeToken('sk', 'acct'); + + await readHtml(resource, sk); + await readHtml(resource, sk); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not embed a token minted for a different account (identity assertion)', async () => { + // Simulate a (hypothetical) backend returning a token for someone else. + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ token: makeToken('tk', 'attacker') }), { + status: 200 + }) + ) + ); + const resource = new MapPreviewUIResource(); + + const html = await readHtml(resource, makeToken('sk', 'victim')); + + expect(embeddedToken(html)).toBe(''); + }); + + it('renders without a token when minting fails (graceful degradation)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('forbidden', { status: 403 })) + ); + const resource = new MapPreviewUIResource(); + + const result = await resource['readCallback']( + uri, + extra(makeToken('sk', 'acct')) + ); + + expect(result.contents).toHaveLength(1); + expect(embeddedToken(result.contents[0].text as string)).toBe(''); + }); + + it('passes a pk token through unchanged without minting', async () => { + const fetchMock = stubMintingFetch(); + const resource = new MapPreviewUIResource(); + const pk = makeToken('pk', 'acct'); + + const html = await readHtml(resource, pk); + + expect(embeddedToken(html)).toBe(pk); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('renders without a token when no token is provided', async () => { + const saved = process.env.MAPBOX_ACCESS_TOKEN; + delete process.env.MAPBOX_ACCESS_TOKEN; + try { + const fetchMock = stubMintingFetch(); + const resource = new MapPreviewUIResource(); + + const html = await readHtml(resource); + + expect(embeddedToken(html)).toBe(''); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + if (saved !== undefined) process.env.MAPBOX_ACCESS_TOKEN = saved; + } + }); +}); + +/** + * Extracts and runs the resource's inline