diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b8839a..40db6ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/src/resources/ui-apps/StyleComparisonUIResource.ts b/src/resources/ui-apps/StyleComparisonUIResource.ts
index d2e5c8b..83d9cda 100644
--- a/src/resources/ui-apps/StyleComparisonUIResource.ts
+++ b/src/resources/ui-apps/StyleComparisonUIResource.ts
@@ -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 {
@@ -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;
@@ -94,21 +87,19 @@ export class StyleComparisonUIResource extends BaseResource {
-
diff --git a/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts
index 78dbb6d..65b2571 100644
--- a/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts
+++ b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts
@@ -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()
diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts
index c69a923..d679a94 100644
--- a/src/tools/preview-style-tool/PreviewStyleTool.ts
+++ b/src/tools/preview-style-tool/PreviewStyleTool.ts
@@ -17,7 +17,7 @@ import type { HttpRequest } from '../../utils/types.js';
export class PreviewStyleTool extends BaseTool {
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,
@@ -52,7 +52,7 @@ export class PreviewStyleTool extends BaseTool {
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) {
@@ -67,19 +67,6 @@ export class PreviewStyleTool extends BaseTool {
};
}
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 {
diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.input.schema.ts
similarity index 68%
rename from src/tools/style-comparison-tool/StyleComparisonTool.schema.ts
rename to src/tools/style-comparison-tool/StyleComparisonTool.input.schema.ts
index f23d215..17a6998 100644
--- a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts
+++ b/src/tools/style-comparison-tool/StyleComparisonTool.input.schema.ts
@@ -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()
diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.output.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.output.schema.ts
new file mode 100644
index 0000000..ea194af
--- /dev/null
+++ b/src/tools/style-comparison-tool/StyleComparisonTool.output.schema.ts
@@ -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;
diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts
index 5fc064d..eae9134 100644
--- a/src/tools/style-comparison-tool/StyleComparisonTool.ts
+++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts
@@ -3,13 +3,19 @@
import { randomUUID } from 'node:crypto';
import type { CallToolResult } from '@modelcontextprotocol/server';
-import { createUIResource } from '@mcp-ui/server';
import { BaseTool } from '../BaseTool.js';
import {
StyleComparisonSchema,
StyleComparisonInput
-} from './StyleComparisonTool.schema.js';
-import { getUserNameFromToken } from '../../utils/jwtUtils.js';
+} from './StyleComparisonTool.input.schema.js';
+import {
+ StyleComparisonOutputSchema,
+ StyleComparisonOutput
+} from './StyleComparisonTool.output.schema.js';
+import {
+ getUserNameFromToken,
+ mapboxApiEndpoint
+} from '../../utils/jwtUtils.js';
import {
mintScopedPreviewToken,
describeAutoMintFailure
@@ -17,11 +23,12 @@ import {
import type { HttpRequest } from '../../utils/types.js';
export class StyleComparisonTool extends BaseTool<
- typeof StyleComparisonSchema
+ typeof StyleComparisonSchema,
+ typeof StyleComparisonOutputSchema
> {
readonly name = 'style_comparison_tool';
readonly description =
- '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.';
+ 'Show a live side-by-side comparison of two Mapbox styles with a draggable slider, rendered inline in clients that support MCP Apps. By default, auto-generates a short-lived scoped preview token so you can compare them right away — no existing token needed. Also returns a Styles API preview link for each style, to open or share on its own. Pass an existing public `accessToken` to use a token you manage yourself, so those links keep working after the hour.';
readonly annotations = {
readOnlyHint: true,
destructiveHint: false,
@@ -35,8 +42,7 @@ export class StyleComparisonTool extends BaseTool<
resourceUri: 'ui://mapbox/style-comparison/index.html',
csp: {
connectDomains: ['https://*.mapbox.com'],
- resourceDomains: ['https://*.mapbox.com'],
- frameDomains: ['https://*.mapbox.com']
+ resourceDomains: ['https://*.mapbox.com']
}
}
};
@@ -44,7 +50,10 @@ export class StyleComparisonTool extends BaseTool<
private readonly httpRequest: HttpRequest;
constructor(params: { httpRequest: HttpRequest }) {
- super({ inputSchema: StyleComparisonSchema });
+ super({
+ inputSchema: StyleComparisonSchema,
+ outputSchema: StyleComparisonOutputSchema
+ });
this.httpRequest = params.httpRequest;
}
@@ -100,21 +109,7 @@ export class StyleComparisonTool extends BaseTool<
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 {
@@ -128,20 +123,14 @@ export class StyleComparisonTool extends BaseTool<
};
}
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.
+ // The comparison renders in our own ui:// panel with GL JS, which
+ // accepts a short-lived tk.* token, so nothing persists on the account.
publicToken = await mintScopedPreviewToken(
this.httpRequest,
accessToken,
{
note: 'Style Comparison (auto-generated)',
- scopes: ['styles:tiles', 'styles:read', 'fonts:read'],
- expiresInMs: null
+ scopes: ['styles:tiles', 'styles:read', 'fonts:read']
}
);
} catch (error) {
@@ -173,49 +162,52 @@ export class StyleComparisonTool extends BaseTool<
};
}
- // Build the comparison URL
- const params = new URLSearchParams();
- params.append('access_token', publicToken);
- params.append('before', beforeStyleId);
- params.append('after', afterStyleId);
-
- // Build base URL
- let url = `https://agent.mapbox.com/tools/style-compare?${params.toString()}`;
-
- // Add hash fragment for map position if all coordinates are provided
+ let position = '';
+ let hash = '';
+ let view: Pick = {};
if (
input.zoom !== undefined &&
input.latitude !== undefined &&
input.longitude !== undefined
) {
- // Format: #zoom/latitude/longitude
- url += `#${input.zoom}/${input.latitude}/${input.longitude}`;
+ position = ` at zoom ${input.zoom}, ${input.latitude}, ${input.longitude}`;
+ hash = `#${input.zoom}/${input.latitude}/${input.longitude}`;
+ view = { center: [input.longitude, input.latitude], zoom: input.zoom };
}
- // Build content array with URL
- const content: CallToolResult['content'] = [
- {
- type: 'text',
- text: url
- }
- ];
-
- // Add MCP-UI resource (for legacy MCP-UI clients)
- const uiResource = createUIResource({
- uri: `ui://mapbox/style-comparison/${beforeStyleId}/${afterStyleId}`,
- content: {
- type: 'externalUrl',
- iframeUrl: url
- },
- encoding: 'text',
- uiMetadata: {
- 'preferred-frame-size': ['1000px', '700px']
- }
- });
- content.push(uiResource);
+ // Standalone Styles API preview pages for each side, so the styles can
+ // be opened or shared outside the inline comparison panel.
+ const previewUrl = (styleId: string) =>
+ `${mapboxApiEndpoint()}styles/v1/${styleId}.html?${new URLSearchParams({
+ access_token: publicToken,
+ fresh: 'true'
+ }).toString()}${hash}`;
+
+ const structuredContent: StyleComparisonOutput = {
+ before: beforeStyleId,
+ after: afterStyleId,
+ accessToken: publicToken,
+ beforePreviewUrl: previewUrl(beforeStyleId),
+ afterPreviewUrl: previewUrl(afterStyleId),
+ ...view
+ };
+
+ const expiry = input.accessToken
+ ? 'They use the access token you supplied.'
+ : 'They use an auto-generated token and stop working in about an hour; pass your own public accessToken for links that last.';
return {
- content,
+ content: [
+ {
+ type: 'text',
+ text:
+ `Comparing ${beforeStyleId} (before, left) with ${afterStyleId} (after, right)${position}.\n\n` +
+ `Preview links to open or share each style on its own. ${expiry}\n` +
+ `- Before: ${structuredContent.beforePreviewUrl}\n` +
+ `- After: ${structuredContent.afterPreviewUrl}`
+ }
+ ],
+ structuredContent,
isError: false,
_meta: {
viewUUID: randomUUID()
diff --git a/src/utils/mintScopedPreviewToken.ts b/src/utils/mintScopedPreviewToken.ts
index fafeccd..a2cd238 100644
--- a/src/utils/mintScopedPreviewToken.ts
+++ b/src/utils/mintScopedPreviewToken.ts
@@ -14,10 +14,8 @@ import { getUserNameFromToken, mapboxApiEndpoint } from './jwtUtils.js';
* 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
+ * **`pk.*`** public token instead — only for a consumer that validates the
+ * token prefix itself and rejects non-`pk.*` tokens outright. A `pk.*` token
* minted this way does not self-expire; it persists on the account (scoped
* narrowly to `scopes`) until manually revoked.
*
diff --git a/test/resources/ui-apps/StyleComparisonUIResource.test.ts b/test/resources/ui-apps/StyleComparisonUIResource.test.ts
index 14c92ab..b03eb9f 100644
--- a/test/resources/ui-apps/StyleComparisonUIResource.test.ts
+++ b/test/resources/ui-apps/StyleComparisonUIResource.test.ts
@@ -8,28 +8,42 @@ import { resourceTextOf } from '../../utils/resultTestUtils.js';
const uri = new URL('ui://mapbox/style-comparison/index.html');
-async function inlineScript(): Promise {
+async function resourceHtml(): Promise {
const resource = new StyleComparisonUIResource();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const html = resourceTextOf(await resource.readCallback(uri, {} as any));
- const match = html.match(/