diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf1d647..c5235c22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ ### New Features +- **`directions_tool` now renders as a live Mapbox GL JS map** for both the MCP Apps spec and legacy MCP-UI clients: + - **MCP Apps**: the tool declares `_meta.ui.resourceUri` pointing to a new `DirectionsAppUIResource` (`ui://mapbox/directions-app/index.html`). MCP App–capable hosts (Claude Desktop, VS Code, Cursor) render the route via postMessage handoff. + - **MCP-UI**: when `geometries=geojson` is requested and the response carries a renderable LineString, an inline `rawHtml` UIResource is added to the tool's `content[]` (gated by the existing `ENABLE_MCP_UI`/`--disable-mcp-ui` flag, like `static_map_image_tool`). + - **One source of truth**: both pathways render the same HTML produced by `renderDirectionsAppHtml` — for MCP Apps the resource serves a generic version and the iframe receives the tool result via postMessage; for MCP-UI the tool bakes the route geometry into the HTML before returning. No more "GL JS map for one client, static image for the other." + - **Public token**: resolved server-side via `GET /tokens/v2/{user}?default=true` (requires `tokens:read` on the `sk.*` token) with `MAPBOX_PUBLIC_TOKEN` env var fallback. Non-MCP-App hosts that also have MCP-UI disabled ignore both UI hints and consume the existing text/structuredContent payload unchanged. + - **Graceful degradation**: responses without renderable geometry (>50KB temporary-resource path, `geometries=none`, `geometries=polyline*`) skip the inline rawHtml block and show a "no geometry to render" message in the MCP App iframe. + - **CSP**: `_meta.ui.csp.workerDomains: ['blob:']` so MCP App hosts grant Mapbox GL JS the iframe sandbox permissions it needs. - **MCP Completions capability**: Add auto-completion support for prompt arguments per MCP spec (2025-11-25). Clients can now suggest values when users fill in prompt parameters (#176) - `category` argument on `find-places-nearby` — 482 Mapbox Search API categories - `mode` argument on `get-directions`, `search-along-route`, `show-reachable-areas` — driving, driving-traffic, walking, cycling diff --git a/docs/importing-tools.md b/docs/importing-tools.md index b45c63cc..25c6637c 100644 --- a/docs/importing-tools.md +++ b/docs/importing-tools.md @@ -116,7 +116,7 @@ import { centroid, distance, midpoint, - pointInPolygon, + pointsWithinPolygon, simplify, // API tools (HTTP pre-configured) @@ -149,7 +149,7 @@ import { CentroidTool, DistanceTool, MidpointTool, - PointInPolygonTool, + PointsWithinPolygonTool, SimplifyTool, // API tools diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 8d0c0ed1..69a81f03 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -5,6 +5,7 @@ import { CategoryListResource } from './category-list/CategoryListResource.js'; import { TemporaryDataResource } from './temporary/TemporaryDataResource.js'; import { StaticMapUIResource } from './ui-apps/StaticMapUIResource.js'; +import { DirectionsAppUIResource } from './ui-apps/DirectionsAppUIResource.js'; import { VersionResource } from './version/VersionResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -14,6 +15,7 @@ export const ALL_RESOURCES = [ new CategoryListResource({ httpRequest }), new TemporaryDataResource(), new StaticMapUIResource(), + new DirectionsAppUIResource({ httpRequest }), new VersionResource() ] as const; diff --git a/src/resources/ui-apps/DirectionsAppUIResource.ts b/src/resources/ui-apps/DirectionsAppUIResource.ts new file mode 100644 index 00000000..f2fc361b --- /dev/null +++ b/src/resources/ui-apps/DirectionsAppUIResource.ts @@ -0,0 +1,89 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { + ReadResourceResult, + ServerNotification, + ServerRequest +} from '@modelcontextprotocol/sdk/types.js'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; +import { BaseResource } from '../BaseResource.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; +import { renderDirectionsAppHtml } from './directionsAppHtml.js'; + +/** + * MCP Apps resource for `directions_tool` — serves the HTML at + * `ui://mapbox/directions-app/index.html`. The iframe waits for the host to + * deliver the tool result via the `ui/notifications/tool-result` postMessage + * event and renders the route from `structuredContent.routes[0]`. + * + * The legacy MCP-UI pathway (inline `rawHtml` on the tool result) uses the + * same HTML template via `renderDirectionsAppHtml` with geometry baked in at + * tool-execute time. + */ +export class DirectionsAppUIResource extends BaseResource { + readonly name = 'Directions App UI'; + readonly uri = 'ui://mapbox/directions-app/index.html'; + readonly description = + 'Interactive UI for visualizing a Mapbox directions route with Mapbox GL JS (MCP Apps)'; + readonly mimeType = RESOURCE_MIME_TYPE; + + private readonly httpRequest: HttpRequest; + private readonly apiEndpoint: () => string; + + constructor(params: { + httpRequest: HttpRequest; + apiEndpoint?: () => string; + }) { + super(); + this.httpRequest = params.httpRequest; + this.apiEndpoint = + params.apiEndpoint ?? + (() => process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'); + } + + async read( + _uri: string, + extra?: RequestHandlerExtra + ): Promise { + const accessToken = + (extra?.authInfo?.token as string | undefined) || + process.env.MAPBOX_ACCESS_TOKEN || + ''; + + const publicToken = await resolveMapboxPublicToken({ + accessToken, + apiEndpoint: this.apiEndpoint(), + httpRequest: this.httpRequest + }); + + const html = renderDirectionsAppHtml({ + publicToken: publicToken ?? '' + }); + + return { + contents: [ + { + uri: this.uri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + connectDomains: [ + 'https://*.mapbox.com', + 'https://events.mapbox.com' + ], + resourceDomains: ['https://api.mapbox.com'], + workerDomains: ['blob:'] + }, + preferredSize: { width: 1000, height: 600 } + } + } + } + ] + }; + } +} diff --git a/src/resources/ui-apps/directionsAppHtml.ts b/src/resources/ui-apps/directionsAppHtml.ts new file mode 100644 index 00000000..6883ea7a --- /dev/null +++ b/src/resources/ui-apps/directionsAppHtml.ts @@ -0,0 +1,472 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Render the directions MCP App HTML. + * + * The same template is consumed by two ingress paths: + * + * 1. **MCP Apps spec** — `DirectionsAppUIResource` reads this resource at + * `ui://mapbox/directions-app/index.html`. The iframe loads, the agent's + * tool result is delivered via the `ui/notifications/tool-result` + * postMessage event, and `extractRoute()` pulls the route out of + * `structuredContent.routes[0]`. + * + * 2. **Legacy MCP-UI spec** — `directions_tool` inlines a `rawHtml` + * UIResource into its content array (gated by `isMcpUiEnabled()`). The + * HTML is generated at tool-execute time with the route geometry already + * baked in as an `initialData` script block, so the iframe renders + * immediately without needing the host to deliver the tool result. + * + * One source of truth for the rendering logic; two slim entry conditions. + */ + +export const MAPBOX_GL_VERSION = '3.12.0'; + +export interface DirectionsAppInitialData { + geometry: { type: string; coordinates: [number, number][] }; + summary?: string; +} + +export function renderDirectionsAppHtml(params: { + publicToken: string; + glVersion?: string; + initialData?: DirectionsAppInitialData; +}): string { + const { publicToken, initialData } = params; + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + + const initialDataScript = initialData + ? `` + : ''; + + return ` + + + + +Directions Preview + + + + + +
+ +
Loading directions…
+ +${initialDataScript} + + + +`; +} + +function escapeForScript(s: string): string { + // Prevent inside JSON from breaking out of the script tag. + return s.replace(/<\/script>/gi, '<\\/script>'); +} diff --git a/src/tools/directions-tool/DirectionsTool.ts b/src/tools/directions-tool/DirectionsTool.ts index 080d222b..e7490706 100644 --- a/src/tools/directions-tool/DirectionsTool.ts +++ b/src/tools/directions-tool/DirectionsTool.ts @@ -2,8 +2,9 @@ // Licensed under the MIT License. import { URLSearchParams } from 'node:url'; -import { randomBytes } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import type { z } from 'zod'; +import { createUIResource } from '@mcp-ui/server'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { cleanResponseData } from './cleanResponseData.js'; @@ -15,6 +16,9 @@ import { } from './DirectionsTool.output.schema.js'; import type { HttpRequest } from '../..//utils/types.js'; import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js'; +import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; +import { renderDirectionsAppHtml } from '../../resources/ui-apps/directionsAppHtml.js'; // Docs: https://docs.mapbox.com/api/navigation/directions/ @@ -35,6 +39,15 @@ export class DirectionsTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/directions-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; constructor(params: { httpRequest: HttpRequest }) { super({ @@ -333,10 +346,93 @@ ${responseSize > RESPONSE_SIZE_THRESHOLD ? `\n⚠️ Full response (${Math.round } // Small response - return normally + const content: CallToolResult['content'] = [ + { type: 'text', text: responseText } + ]; + + // Legacy MCP-UI: inline a rawHtml UIResource so non-MCP-Apps clients + // also get a live GL JS map. Only works when the response actually + // carries a renderable GeoJSON geometry — i.e. geometries=geojson. + if (isMcpUiEnabled()) { + const inlineHtml = await tryRenderInlineUiHtml( + validatedData, + accessToken, + this.httpRequest + ); + if (inlineHtml) { + content.push( + createUIResource({ + uri: `ui://mapbox/directions/${randomUUID()}`, + content: { type: 'rawHtml', htmlString: inlineHtml }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '500px'] + } + }) + ); + } + } + return { - content: [{ type: 'text', text: responseText }], + content, structuredContent: validatedData, isError: false }; } } + +/** + * Try to render the same DirectionsAppHtml as the MCP Apps resource, but + * with the route geometry baked in so MCP-UI clients (which don't fetch + * external resources) can render inline. Returns undefined when the + * response has no GeoJSON geometry to render or no public token can be + * resolved — the caller falls back to text-only output. + */ +async function tryRenderInlineUiHtml( + data: DirectionsResponse, + accessToken: string, + httpRequest: HttpRequest +): Promise { + const route = data.routes?.[0]; + const geometry = route?.geometry; + // Accept either a GeoJSON LineString object or a polyline string — the + // iframe normalizes both shapes before rendering. + const hasGeojson = + geometry && + typeof geometry === 'object' && + (geometry as { type?: string }).type === 'LineString' && + Array.isArray((geometry as { coordinates?: unknown }).coordinates); + const hasPolyline = typeof geometry === 'string' && geometry.length > 0; + if (!hasGeojson && !hasPolyline) { + return undefined; + } + + const publicToken = await resolveMapboxPublicToken({ + accessToken, + apiEndpoint: MapboxApiBasedTool.mapboxApiEndpoint, + httpRequest + }); + if (!publicToken) return undefined; + + const summaryParts: string[] = []; + if (typeof route?.distance === 'number') { + summaryParts.push(`${(route.distance / 1609.34).toFixed(1)} mi`); + } + if (typeof route?.duration === 'number') { + summaryParts.push(`${Math.round(route.duration / 60)} min`); + } + const summary = summaryParts.length + ? `Route: ${summaryParts.join(', ')}` + : 'Route'; + + return renderDirectionsAppHtml({ + publicToken, + initialData: { + geometry: geometry as unknown as { + type: string; + coordinates: [number, number][]; + }, + summary + } + }); +} diff --git a/src/utils/jwtUtils.ts b/src/utils/jwtUtils.ts new file mode 100644 index 00000000..ede70b97 --- /dev/null +++ b/src/utils/jwtUtils.ts @@ -0,0 +1,22 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Extract the Mapbox username from a JWT access token. + * + * Mapbox tokens are JWTs whose payload contains the username under the `u` key. + * Returns undefined if the token is malformed or missing the `u` field — callers + * can decide whether to surface an error or fall back to another auth path. + */ +export function getUserNameFromToken(token: string): string | undefined { + const parts = token.split('.'); + if (parts.length !== 3) return undefined; + try { + const payload = JSON.parse( + Buffer.from(parts[1], 'base64').toString('utf-8') + ) as { u?: unknown }; + return typeof payload.u === 'string' ? payload.u : undefined; + } catch { + return undefined; + } +} diff --git a/src/utils/mapboxPublicToken.ts b/src/utils/mapboxPublicToken.ts new file mode 100644 index 00000000..f01a366e --- /dev/null +++ b/src/utils/mapboxPublicToken.ts @@ -0,0 +1,96 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { getUserNameFromToken } from './jwtUtils.js'; +import type { HttpRequest } from './types.js'; + +interface TokenListEntry { + token?: string; + usage?: string; + default?: boolean; +} + +interface CachedToken { + token: string; + expiresAt: number; +} + +const PUBLIC_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h +let cachedPublicToken: CachedToken | null = null; + +/** + * Resolve a public (pk.*) Mapbox token suitable for embedding in client-side + * HTML (e.g. an MCP App iframe that initializes Mapbox GL JS). + * + * Resolution order: + * 1. If the access token is already a pk.* token, use it directly. + * 2. Reuse a cached pk.* token while it has >5 min TTL remaining. + * 3. If the access token is an sk.* token, call + * GET /tokens/v2/{user}?default=true to fetch the user's default public + * token (requires `tokens:read` scope on the bearer). + * 4. Fall back to the MAPBOX_PUBLIC_TOKEN env var. + * + * Returns undefined if none of the above produces a pk.* token. + */ +export async function resolveMapboxPublicToken(params: { + accessToken: string; + apiEndpoint: string; + httpRequest: HttpRequest; +}): Promise { + const { accessToken, apiEndpoint, httpRequest } = params; + + if (accessToken.startsWith('pk.')) { + return accessToken; + } + + const now = Date.now(); + if (cachedPublicToken && cachedPublicToken.expiresAt - now > 5 * 60 * 1000) { + return cachedPublicToken.token; + } + + if (accessToken.startsWith('sk.')) { + const username = getUserNameFromToken(accessToken); + if (username) { + try { + const tokensUrl = new URL(`${apiEndpoint}tokens/v2/${username}`); + tokensUrl.searchParams.set('default', 'true'); + tokensUrl.searchParams.set('access_token', accessToken); + + const response = await httpRequest(tokensUrl.toString()); + if (response.ok) { + const body = (await response.json()) as unknown; + const entries: TokenListEntry[] = Array.isArray(body) + ? (body as TokenListEntry[]) + : ((body as { tokens?: TokenListEntry[] })?.tokens ?? []); + const defaultPk = entries.find( + (entry) => entry?.usage === 'pk' && typeof entry.token === 'string' + ); + if (defaultPk?.token) { + cachedPublicToken = { + token: defaultPk.token, + expiresAt: now + PUBLIC_TOKEN_TTL_MS + }; + return defaultPk.token; + } + } + } catch (err) { + // Network failures and JSON parse errors land here. Surface a warning + // so the cause is diagnosable from logs rather than masked behind the + // generic env-var fallback path. + console.warn( + `resolveMapboxPublicToken: Tokens API call failed, falling back to MAPBOX_PUBLIC_TOKEN env var: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + } + + const envFallback = process.env.MAPBOX_PUBLIC_TOKEN; + return envFallback && envFallback.startsWith('pk.') ? envFallback : undefined; +} + +/** + * Reset the cached public token. For tests only. + */ +export function __resetMapboxPublicTokenCache(): void { + cachedPublicToken = null; +} diff --git a/test/resources/ui-apps/DirectionsAppUIResource.test.ts b/test/resources/ui-apps/DirectionsAppUIResource.test.ts new file mode 100644 index 00000000..135c4631 --- /dev/null +++ b/test/resources/ui-apps/DirectionsAppUIResource.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +// JWT with payload {"sub":"test","u":"testuser"} (base64) +const SK_TOKEN = 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { DirectionsAppUIResource } from '../../../src/resources/ui-apps/DirectionsAppUIResource.js'; +import { __resetMapboxPublicTokenCache } from '../../../src/utils/mapboxPublicToken.js'; + +const fakeTokenList = [ + { + id: 'cktest123', + usage: 'pk', + default: true, + token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token', + scopes: ['styles:read', 'styles:tiles', 'fonts:read'] + } +]; + +function makeOkJson(body: unknown): Partial { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body) + }; +} + +describe('DirectionsAppUIResource', () => { + beforeEach(() => { + __resetMapboxPublicTokenCache(); + delete process.env.MAPBOX_PUBLIC_TOKEN; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('serves HTML with the mcp-app mime type and the public token baked in', async () => { + const httpRequest = vi.fn(async (url: string) => { + if (url.includes('tokens/v2/')) + return makeOkJson(fakeTokenList) as Response; + throw new Error(`Unexpected URL: ${url}`); + }); + + const resource = new DirectionsAppUIResource({ httpRequest }); + + const result = await resource.read( + 'ui://mapbox/directions-app/index.html', + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authInfo: { token: SK_TOKEN } as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ); + + expect(result.contents).toHaveLength(1); + const entry = result.contents[0]; + expect(entry.mimeType).toBe('text/html;profile=mcp-app'); + expect(entry.uri).toBe('ui://mapbox/directions-app/index.html'); + expect(typeof entry.text).toBe('string'); + expect(entry.text as string).toContain( + 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token' + ); + expect(entry.text as string).toContain('mapbox-gl.js'); + + const meta = (entry as { _meta?: unknown })._meta as + | { ui?: { csp?: { workerDomains?: string[] } } } + | undefined; + expect(meta?.ui?.csp?.workerDomains).toContain('blob:'); + + // Confirm the tokens endpoint was hit with default=true + const tokensCall = httpRequest.mock.calls.find((c) => + (c[0] as string).includes('tokens/v2/') + ); + expect(tokensCall?.[0]).toContain('tokens/v2/testuser'); + expect(tokensCall?.[0]).toContain('default=true'); + }); + + it('falls back to MAPBOX_PUBLIC_TOKEN when the Tokens API call fails', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-token'; + + const httpRequest = vi.fn( + async () => ({ ok: false, status: 403 }) as Response + ); + + const resource = new DirectionsAppUIResource({ httpRequest }); + + const result = await resource.read( + 'ui://mapbox/directions-app/index.html', + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authInfo: { token: SK_TOKEN } as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ); + + expect(result.contents[0].text as string).toContain('pk.fallback-token'); + }); + + it('still returns HTML (with empty token) when no token can be resolved', async () => { + const httpRequest = vi.fn( + async () => ({ ok: false, status: 403 }) as Response + ); + + const resource = new DirectionsAppUIResource({ httpRequest }); + + const result = await resource.read( + 'ui://mapbox/directions-app/index.html', + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authInfo: { token: SK_TOKEN } as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ); + + // HTML is still served — the iframe will surface a friendly error to the user + expect(result.contents[0].text as string).toContain( + 'No Mapbox public token available' + ); + }); +}); diff --git a/test/tools/directions-tool/DirectionsTool.test.ts b/test/tools/directions-tool/DirectionsTool.test.ts index a1fcf25f..027de31b 100644 --- a/test/tools/directions-tool/DirectionsTool.test.ts +++ b/test/tools/directions-tool/DirectionsTool.test.ts @@ -1064,4 +1064,98 @@ describe('DirectionsTool', () => { isError: true }); }); + + describe('MCP App + MCP-UI integration', () => { + it('declares meta.ui.resourceUri pointing to the directions-app resource', () => { + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); + expect(tool.meta?.ui?.resourceUri).toBe( + 'ui://mapbox/directions-app/index.html' + ); + }); + + it('adds an inline MCP-UI rawHtml resource for small geojson responses', async () => { + const fakeResponse = { + routes: [ + { + geometry: { + type: 'LineString', + coordinates: [ + [-74.0, 40.7], + [-74.01, 40.71] + ] + }, + distance: 1500, + duration: 180, + legs: [] + } + ], + waypoints: [ + { location: [-74.0, 40.7], name: '' }, + { location: [-74.01, 40.71], name: '' } + ], + code: 'Ok' + }; + const tokensListResponse = [ + { + usage: 'pk', + default: true, + token: 'pk.fake-public-token' + } + ]; + + const httpRequestFn = vi.fn(async (url: string) => { + if (url.includes('tokens/v2/')) { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => tokensListResponse, + text: async () => JSON.stringify(tokensListResponse) + } as Response; + } + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => fakeResponse, + text: async () => JSON.stringify(fakeResponse) + } as Response; + }); + + // Mapbox sk.* tokens are 3 dot-segments: sk.. + const realToken = process.env.MAPBOX_ACCESS_TOKEN; + const payload = Buffer.from(JSON.stringify({ u: 'testuser' })).toString( + 'base64' + ); + process.env.MAPBOX_ACCESS_TOKEN = `sk.${payload}.signature`; + + try { + const result = await new DirectionsTool({ + httpRequest: httpRequestFn + }).run({ + coordinates: [ + { longitude: -74.0, latitude: 40.7 }, + { longitude: -74.01, latitude: 40.71 } + ], + geometries: 'geojson' + }); + + expect(result.isError).toBe(false); + // Expect at least the response text + the MCP-UI resource block + expect(result.content.length).toBeGreaterThanOrEqual(2); + const uiBlock = result.content.find( + (c) => (c as { type?: string }).type === 'resource' + ) as { resource?: { text?: string; mimeType?: string } } | undefined; + expect(uiBlock).toBeDefined(); + expect(uiBlock?.resource?.text).toContain('mapbox-gl.js'); + expect(uiBlock?.resource?.text).toContain('pk.fake-public-token'); + // Initial-data block should carry the baked-in geometry + expect(uiBlock?.resource?.text).toContain('initial-data'); + expect(uiBlock?.resource?.text).toContain('LineString'); + } finally { + process.env.MAPBOX_ACCESS_TOKEN = realToken; + } + }); + }); });