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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
## Unreleased

### Breaking Changes

- **`style_comparison_tool` no longer depends on agent.mapbox.com (#152).** The comparison now runs only in this server's own MCP App panel (`ui://mapbox/style-comparison/index.html`), as part of retiring the old Mapbox Location Agent.
- **Removed:** the `share` input, the `agent.mapbox.com/tools/style-compare` link in the result text, the legacy MCP-UI `externalUrl` resource that framed that page, and the panel's "Open in browser" button. Clients without MCP Apps support get the text summary and preview links, with no comparison slider.
- **New output:** the tool returns `structuredContent` (`before`, `after`, `accessToken`, `beforePreviewUrl`, `afterPreviewUrl`, and optional `center`/`zoom`) with a matching output schema. The panel reads that payload instead of parsing a URL.
- **Sharing:** in place of the hosted comparison link, the result includes a Styles API preview link (`api.mapbox.com/styles/v1/{user}/{id}.html`) for each style, with the map position if one was given. You can open or share them on their own, and clients without MCP Apps support get these links. The links work with the token you pass as `accessToken`. With the auto-generated token they stop after an hour, and the result text says so.
- **Auto-generated tokens expire:** when no `accessToken` is passed, the tool now creates a 1-hour `tk.*` token. Before, it created a permanent `pk.*` token on every call, because the hosted page rejected `tk.*`, and each one stayed on the account until revoked. You can still pass your own `pk.*` token as `accessToken`.
- **Panel improvements from the hosted page:** styles load with `?fresh=true` so recent edits show up, and a style that fails to load now shows which side failed and why (not found, authentication or access denied). Errors after a style has loaded, such as a missing tile, no longer replace the map. The slider moves only on click and drag.
- **`preview_style_tool` no longer takes `share` (#152).** `share: true` only checked that an `accessToken` was passed, so passing your own `pk.*` token as `accessToken` does the same thing: the preview link uses that token and keeps working after an hour. Without `accessToken`, the tool still creates a 1-hour `tk.*` token. A call that still sends `share` won't fail; the option is ignored.

### Maintenance

- **OpenTelemetry JS 3.x (#149, same move as mapbox/mcp-server#325).** `@opentelemetry/sdk-trace-base` is deprecated in every 2.x release and removed in 3.0, and the ox-security scan flags it (mapbox/mcp-server#319). The OpenTelemetry set moves to the 3.0 line: `sdk-trace` 3.0.0 replaces `sdk-trace-base`, along with `resources` 3.0.0, `sdk-node`, `instrumentation` and `exporter-trace-otlp-http` 0.300.0, `auto-instrumentations-node` 0.82.0 and `api` 1.10.0.
Expand Down
138 changes: 65 additions & 73 deletions src/resources/ui-apps/StyleComparisonUIResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const COMPARE_VERSION = '0.4.0';
* Serves UI App HTML for Mapbox Style Comparison using mapbox-gl-compare.
* Renders two synced GL maps with a draggable reveal slider inline —
* no inner iframe needed, so frame-src CSP is not an issue.
* Reads the styles, token and initial position from the tool result's
* structuredContent; it does not depend on any hosted comparison page.
* Implements MCP Apps pattern with ui:// scheme.
*/
export class StyleComparisonUIResource extends BaseResource {
Expand Down Expand Up @@ -63,15 +65,6 @@ export class StyleComparisonUIResource extends BaseResource {
}
#before-label { left: 12px; }
#after-label { right: 12px; }
#open-btn {
position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
z-index: 10; display: none;
color: #fff; background: rgba(0,0,0,0.6);
font-size: 13px; padding: 5px 12px;
border: 1px solid rgba(255,255,255,0.4); border-radius: 4px;
cursor: pointer; font-family: inherit;
}
#open-btn:hover { background: rgba(0,0,0,0.8); }
#fullscreen-btn {
position: absolute; top: 10px; right: 10px; z-index: 10;
display: none;
Expand All @@ -94,21 +87,19 @@ export class StyleComparisonUIResource extends BaseResource {
<div id="error" style="display:none"></div>
<div id="before-label" class="style-label"></div>
<div id="after-label" class="style-label"></div>
<button id="open-btn">↗ Open in browser</button>
<button id="fullscreen-btn" title="Toggle fullscreen">⛶</button>

<script>
var beforeMap = null;
var afterMap = null;
var compare = null;
var currentPreviewUrl = '';
var currentDisplayMode = 'inline';
var STYLE_ID_PATTERN = /^[a-zA-Z0-9_-]+[/][a-zA-Z0-9_-]+$/;

var loadingEl = document.getElementById('loading');
var errorEl = document.getElementById('error');
var beforeLabel = document.getElementById('before-label');
var afterLabel = document.getElementById('after-label');
var openBtn = document.getElementById('open-btn');
var fullscreenBtn = document.getElementById('fullscreen-btn');

var messageId = 0;
Expand All @@ -131,6 +122,12 @@ export class StyleComparisonUIResource extends BaseResource {
sendNotification('ui/notifications/size-changed', { height: 600 });
}

function showError(message) {
loadingEl.style.display = 'none';
errorEl.textContent = message;
errorEl.style.display = 'block';
}

fullscreenBtn.addEventListener('click', function() {
var newMode = currentDisplayMode === 'fullscreen' ? 'inline' : 'fullscreen';
sendRequest('ui/request-display-mode', { mode: newMode }).then(function(result) {
Expand All @@ -141,23 +138,6 @@ export class StyleComparisonUIResource extends BaseResource {
}).catch(function() {});
});

openBtn.addEventListener('click', function() {
if (!currentPreviewUrl) return;
sendRequest('ui/open-link', { url: currentPreviewUrl }).catch(function() {
try {
var ta = document.createElement('textarea');
ta.value = currentPreviewUrl;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
openBtn.textContent = '✓ URL copied';
setTimeout(function() { openBtn.textContent = '↗ Open in browser'; }, 2000);
} catch (err) {}
});
});

window.addEventListener('message', function(event) {
if (event.source !== window.parent) return;
var message = event.data;
Expand Down Expand Up @@ -199,81 +179,93 @@ export class StyleComparisonUIResource extends BaseResource {
sendNotification('ui/notifications/initialized', {});
});

// Turns a GL JS error event into a message naming the side that failed.
function describeStyleError(side, styleId, e) {
var status = e && e.error && e.error.status;
var message = 'Failed to load the "' + side + '" style (' + styleId + ').';
if (status === 404) return message + ' The style was not found. Make sure it exists and is accessible with this token.';
if (status === 401) return message + ' Authentication failed. Check that the access token is valid.';
if (status === 403) return message + ' Access denied. The token may not have permission to read this style.';
var detail = e && e.error && e.error.message;
if (detail) return message + ' ' + String(detail).slice(0, 120);
return message;
}

function handleToolResult(result) {
const textContent = result.content?.find(c => c.type === 'text');
if (!textContent?.text) {
loadingEl.style.display = 'none';
errorEl.textContent = 'No URL found in tool result';
errorEl.style.display = 'block';
if (beforeMap) return;

var data = result && result.structuredContent;
if (!data || typeof data.accessToken !== 'string' ||
!STYLE_ID_PATTERN.test(data.before) || !STYLE_ID_PATTERN.test(data.after)) {
showError('No style comparison found in tool result');
return;
}

const url = textContent.text;
currentPreviewUrl = url;
openBtn.style.display = 'block';

if (typeof mapboxgl === 'undefined') {
loadingEl.style.display = 'none';
if (typeof mapboxgl === 'undefined' || typeof mapboxgl.Compare === 'undefined') {
showError('Could not load Mapbox GL JS');
return;
}

try {
const parsed = new URL(url);
const token = parsed.searchParams.get('access_token');
const before = parsed.searchParams.get('before');
const after = parsed.searchParams.get('after');
if (!token || !before || !after) throw new Error('Missing required params');

// Parse optional initial position from hash (#zoom/lat/lng)
var center = [0, 20];
var zoom = 1.5;
if (parsed.hash) {
var parts = parsed.hash.replace('#', '').split('/');
if (parts.length >= 3) {
zoom = parseFloat(parts[0]) || 1.5;
center = [parseFloat(parts[2]) || 0, parseFloat(parts[1]) || 20];
}
}
var center = [0, 20];
var zoom = 1.5;
if (data.center && data.center.length === 2 && typeof data.zoom === 'number') {
center = [data.center[0], data.center[1]];
zoom = data.zoom;
}

mapboxgl.accessToken = token;
try {
mapboxgl.accessToken = data.accessToken;

// fresh=true bypasses the style CDN cache so recent edits show up.
beforeMap = new mapboxgl.Map({
container: 'before',
style: 'mapbox://styles/' + before,
style: 'mapbox://styles/' + data.before + '?fresh=true',
center: center,
zoom: zoom
});

afterMap = new mapboxgl.Map({
container: 'after',
style: 'mapbox://styles/' + after,
style: 'mapbox://styles/' + data.after + '?fresh=true',
center: center,
zoom: zoom
});

// Show style names once both styles have loaded
var loaded = { before: false, after: false };
var loadedCount = 0;
function onStyleLoad(map, labelEl) {
var failed = false;

function onStyleLoad(map, side, labelEl, styleId) {
loaded[side] = true;
var style = map.getStyle();
if (style && style.name) {
labelEl.textContent = style.name;
labelEl.style.display = 'block';
}
labelEl.textContent = style && style.name ? style.name + ' (' + styleId + ')' : styleId;
labelEl.title = labelEl.textContent;
labelEl.style.display = 'block';
loadedCount++;
if (loadedCount === 2) {
if (loadedCount === 2 && !failed) {
loadingEl.style.display = 'none';
compare = new mapboxgl.Compare(beforeMap, afterMap, '#comparison-container', {});
compare = new mapboxgl.Compare(beforeMap, afterMap, '#comparison-container', {
mousemove: false,
orientation: 'vertical'
});
requestSizeToFit();
}
}

beforeMap.on('style.load', function() { onStyleLoad(beforeMap, beforeLabel); });
afterMap.on('style.load', function() { onStyleLoad(afterMap, afterLabel); });
// Only errors before a style has loaded are fatal; later ones (a
// missing tile, say) leave the comparison usable.
function onError(side, styleId, e) {
if (failed || loaded[side]) return;
failed = true;
showError(describeStyleError(side, styleId, e));
}

beforeMap.on('style.load', function() { onStyleLoad(beforeMap, 'before', beforeLabel, data.before); });
afterMap.on('style.load', function() { onStyleLoad(afterMap, 'after', afterLabel, data.after); });
beforeMap.on('error', function(e) { onError('before', data.before, e); });
afterMap.on('error', function(e) { onError('after', data.after, e); });
} catch (e) {
loadingEl.style.display = 'none';
errorEl.textContent = 'Could not load style comparison';
errorEl.style.display = 'block';
showError('Could not load style comparison');
}
}
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,7 @@ export const PreviewStyleSchema = z.object({
)
.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(
'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.'
'Existing Mapbox public token (must start with pk.* and have styles:read permission). Optional — if omitted, a short-lived (~1 hour) preview token is generated automatically, so no existing token is needed. Pass your own token when you want a preview URL that keeps working after that hour.'
),
title: z
.boolean()
Expand Down
17 changes: 2 additions & 15 deletions src/tools/preview-style-tool/PreviewStyleTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { HttpRequest } from '../../utils/types.js';
export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
readonly name = 'preview_style_tool';
readonly description =
'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.';
'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 an existing public `accessToken` instead to get a preview link that keeps working after the hour.';
readonly annotations = {
readOnlyHint: true,
destructiveHint: false,
Expand Down Expand Up @@ -52,7 +52,7 @@ export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
let publicToken: string;

if (input.accessToken) {
// Caller-supplied token — used as-is, for either mode.
// Caller-supplied token — used as-is.
try {
userName = getUserNameFromToken(input.accessToken);
} catch (error) {
Expand All @@ -67,19 +67,6 @@ export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
};
}
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 preview (no token needed).'
}
]
};
} else {
if (!accessToken) {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,11 @@ export const StyleComparisonSchema = z.object({
.string()
.startsWith(
'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.'
'Invalid token type. Style comparison requires a public token (pk.*) that can be used in the browser. 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(
'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.'
'Existing Mapbox public token (must start with pk.* and have styles:read permission). Optional — if omitted, a short-lived scoped preview token is generated automatically, so no existing token is needed.'
),
zoom: z
.number()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { z } from 'zod';

export const StyleComparisonOutputSchema = z.object({
before: z
.string()
.describe('Resolved "before" style in username/styleId format'),
after: z
.string()
.describe('Resolved "after" style in username/styleId format'),
accessToken: z
.string()
.describe(
'Public token the comparison view uses to load both styles (the one supplied, or a short-lived auto-generated tk.* token)'
),
beforePreviewUrl: z
.string()
.describe(
'Styles API preview page for the "before" style, to open or share on its own'
),
afterPreviewUrl: z
.string()
.describe(
'Styles API preview page for the "after" style, to open or share on its own'
),
center: z
.tuple([z.number().describe('Longitude'), z.number().describe('Latitude')])
.optional()
.describe('Initial map center as [longitude, latitude], if provided'),
zoom: z.number().optional().describe('Initial zoom level, if provided')
});

export type StyleComparisonOutput = z.infer<typeof StyleComparisonOutputSchema>;
Loading
Loading