diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b53fe6..b6f2eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`search_and_geocode_tool` and `category_search_tool` now render as a live Mapbox GL JS map** following the same dual-spec pattern as `directions_tool`/`isochrone_tool`/`optimization_tool`. Both tools share a single `SearchAppUIResource` (`ui://mapbox/search-app/index.html`) and one `renderSearchAppHtml` template that drops numbered pins on each result with name/category/address popups. - **`optimization_tool` now renders as a live Mapbox GL JS map** following the same dual-spec pattern as `directions_tool`/`isochrone_tool`: MCP Apps via `_meta.ui.resourceUri` → `OptimizationAppUIResource`, plus an inline MCP-UI rawHtml block (gated by `ENABLE_MCP_UI`). One shared `renderOptimizationAppHtml` template renders the trip line with numbered markers (1, 2, 3, …) at each stop in the visit order. - **`isochrone_tool` now renders as a live Mapbox GL JS map** following the same dual-spec pattern as `directions_tool`: MCP Apps via `_meta.ui.resourceUri` → `IsochroneAppUIResource`, plus an inline MCP-UI rawHtml block (gated by `ENABLE_MCP_UI`). One shared `renderIsochroneAppHtml` template renders the contours as translucent fill + outline layers with the origin marked. - **`directions_tool` now renders as a live Mapbox GL JS map** for both the MCP Apps spec and legacy MCP-UI clients: diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 8609201..fafbe17 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -8,6 +8,7 @@ import { StaticMapUIResource } from './ui-apps/StaticMapUIResource.js'; import { DirectionsAppUIResource } from './ui-apps/DirectionsAppUIResource.js'; import { IsochroneAppUIResource } from './ui-apps/IsochroneAppUIResource.js'; import { OptimizationAppUIResource } from './ui-apps/OptimizationAppUIResource.js'; +import { SearchAppUIResource } from './ui-apps/SearchAppUIResource.js'; import { VersionResource } from './version/VersionResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -20,6 +21,7 @@ export const ALL_RESOURCES = [ new DirectionsAppUIResource({ httpRequest }), new IsochroneAppUIResource({ httpRequest }), new OptimizationAppUIResource({ httpRequest }), + new SearchAppUIResource({ httpRequest }), new VersionResource() ] as const; diff --git a/src/resources/ui-apps/SearchAppUIResource.ts b/src/resources/ui-apps/SearchAppUIResource.ts new file mode 100644 index 0000000..a745d5f --- /dev/null +++ b/src/resources/ui-apps/SearchAppUIResource.ts @@ -0,0 +1,77 @@ +// 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 { renderSearchAppHtml } from './searchAppHtml.js'; + +export class SearchAppUIResource extends BaseResource { + readonly name = 'Search Results App UI'; + readonly uri = 'ui://mapbox/search-app/index.html'; + readonly description = + 'Interactive UI for visualizing Mapbox search results 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 = renderSearchAppHtml({ 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/searchAppHtml.ts b/src/resources/ui-apps/searchAppHtml.ts new file mode 100644 index 0000000..9976a65 --- /dev/null +++ b/src/resources/ui-apps/searchAppHtml.ts @@ -0,0 +1,397 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Render the search-results MCP App HTML — used by both the MCP Apps resource + * and the inline MCP-UI rawHtml block emitted by `search_and_geocode_tool` + * and `category_search_tool`. + */ + +export const MAPBOX_GL_VERSION = '3.12.0'; + +export interface SearchAppInitialData { + results: Array<{ + index: number; + name: string; + address?: string; + category?: string; + location: [number, number]; + }>; + proximity?: { longitude: number; latitude: number }; + summary?: string; +} + +export function renderSearchAppHtml(params: { + publicToken: string; + glVersion?: string; + initialData?: SearchAppInitialData; +}): string { + const { publicToken, initialData } = params; + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + + const initialDataScript = initialData + ? `` + : ''; + + return ` + + + + +Search Results + + + + + +
+ +
Loading results…
+ +${initialDataScript} + + + +`; +} + +function escapeForScript(s: string): string { + return s.replace(/<\/script>/gi, '<\\/script>'); +} + +import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; +import type { HttpRequest } from '../../utils/types.js'; + +/** + * Bake a Mapbox FeatureCollection of search results into the shared iframe + * template for MCP-UI clients. Returns undefined when there are no usable + * results or no public token can be resolved. + */ +export async function tryRenderSearchInlineHtml(params: { + featureCollection: { features?: unknown[] }; + proximity?: { longitude: number; latitude: number }; + summary?: string; + accessToken: string; + apiEndpoint: string; + httpRequest: HttpRequest; +}): Promise { + const { + featureCollection, + proximity, + summary, + accessToken, + apiEndpoint, + httpRequest + } = params; + + const results = shapeSearchFeatures(featureCollection.features ?? []); + if (results.length === 0) return undefined; + + const publicToken = await resolveMapboxPublicToken({ + accessToken, + apiEndpoint, + httpRequest + }); + if (!publicToken) return undefined; + + return renderSearchAppHtml({ + publicToken, + initialData: { results, proximity, summary } + }); +} + +interface FeatureLike { + geometry?: { coordinates?: [number, number] }; + properties?: { + name?: string; + full_address?: string; + place_formatted?: string; + poi_category?: string[]; + }; +} + +function shapeSearchFeatures( + features: unknown[] +): SearchAppInitialData['results'] { + const out: SearchAppInitialData['results'] = []; + for (const raw of features) { + const f = raw as FeatureLike; + const coords = f?.geometry?.coordinates; + if (!Array.isArray(coords) || coords.length !== 2) continue; + out.push({ + index: out.length + 1, + name: f.properties?.name ?? 'Result', + address: f.properties?.full_address ?? f.properties?.place_formatted, + category: f.properties?.poi_category?.[0], + location: coords + }); + } + return out; +} diff --git a/src/tools/category-search-tool/CategorySearchTool.ts b/src/tools/category-search-tool/CategorySearchTool.ts index c190cae..8c8e768 100644 --- a/src/tools/category-search-tool/CategorySearchTool.ts +++ b/src/tools/category-search-tool/CategorySearchTool.ts @@ -1,7 +1,9 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. +import { 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 type { HttpRequest } from '../../utils/types.js'; @@ -11,6 +13,8 @@ import type { MapboxFeatureCollection, MapboxFeature } from '../../schemas/geojson.js'; +import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { tryRenderSearchInlineHtml } from '../../resources/ui-apps/searchAppHtml.js'; // API Documentation: https://docs.mapbox.com/api/search/search-box/#category-search @@ -28,6 +32,15 @@ export class CategorySearchTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/search-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; constructor(params: { httpRequest: HttpRequest }) { super({ @@ -181,18 +194,42 @@ export class CategorySearchTool extends MapboxApiBasedTool< data = rawData as MapboxFeatureCollection; } - if (input.format === 'json_string') { - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: data as unknown as Record, - isError: false - }; - } else { - return { - content: [{ type: 'text', text: this.formatGeoJsonToText(data) }], - structuredContent: data as unknown as Record, - isError: false - }; + const text = + input.format === 'json_string' + ? JSON.stringify(data, null, 2) + : this.formatGeoJsonToText(data); + + const content: CallToolResult['content'] = [{ type: 'text', text }]; + + if (isMcpUiEnabled()) { + const inlineHtml = await tryRenderSearchInlineHtml({ + featureCollection: data as { features?: unknown[] }, + proximity: input.proximity, + summary: `Found ${(data as { features?: unknown[] }).features?.length ?? 0} "${input.category}" place${ + (data as { features?: unknown[] }).features?.length === 1 ? '' : 's' + }`, + accessToken, + apiEndpoint: MapboxApiBasedTool.mapboxApiEndpoint, + httpRequest: this.httpRequest + }); + if (inlineHtml) { + content.push( + createUIResource({ + uri: `ui://mapbox/search/${randomUUID()}`, + content: { type: 'rawHtml', htmlString: inlineHtml }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '500px'] + } + }) + ); + } } + + return { + content, + structuredContent: data as unknown as Record, + isError: false + }; } } diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts index 682b831..e9396c2 100644 --- a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts +++ b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts @@ -1,7 +1,9 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. +import { randomUUID } from 'node:crypto'; import type { z } from 'zod'; +import { createUIResource } from '@mcp-ui/server'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { HttpRequest } from '../../utils/types.js'; import { SearchAndGeocodeInputSchema } from './SearchAndGeocodeTool.input.schema.js'; @@ -14,6 +16,8 @@ import type { MapboxFeature } from '../../schemas/geojson.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { tryRenderSearchInlineHtml } from '../../resources/ui-apps/searchAppHtml.js'; // API Documentation: https://docs.mapbox.com/api/search/search-box/#search-request @@ -31,6 +35,15 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/search-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; constructor(params: { httpRequest: HttpRequest }) { super({ @@ -299,13 +312,40 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< } // Default behavior: return all results + const content: CallToolResult['content'] = [ + { + type: 'text', + text: this.formatGeoJsonToText(data as MapboxFeatureCollection) + } + ]; + + if (isMcpUiEnabled()) { + const inlineHtml = await tryRenderSearchInlineHtml({ + featureCollection: data as { features?: unknown[] }, + proximity: input.proximity, + summary: `Found ${(data as { features?: unknown[] }).features?.length ?? 0} result${ + (data as { features?: unknown[] }).features?.length === 1 ? '' : 's' + } for "${input.q}"`, + accessToken, + apiEndpoint: MapboxApiBasedTool.mapboxApiEndpoint, + httpRequest: this.httpRequest + }); + if (inlineHtml) { + content.push( + createUIResource({ + uri: `ui://mapbox/search/${randomUUID()}`, + content: { type: 'rawHtml', htmlString: inlineHtml }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '500px'] + } + }) + ); + } + } + return { - content: [ - { - type: 'text', - text: this.formatGeoJsonToText(data as MapboxFeatureCollection) - } - ], + content, structuredContent: data, isError: false }; diff --git a/test/resources/ui-apps/SearchAppUIResource.test.ts b/test/resources/ui-apps/SearchAppUIResource.test.ts new file mode 100644 index 0000000..b77646d --- /dev/null +++ b/test/resources/ui-apps/SearchAppUIResource.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +const SK_TOKEN = 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { SearchAppUIResource } from '../../../src/resources/ui-apps/SearchAppUIResource.js'; +import { __resetMapboxPublicTokenCache } from '../../../src/utils/mapboxPublicToken.js'; + +const fakeTokenList = [ + { + id: 'cktest123', + usage: 'pk', + default: true, + token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token' + } +]; + +function makeOkJson(body: unknown): Partial { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body) + }; +} + +describe('SearchAppUIResource', () => { + beforeEach(() => { + __resetMapboxPublicTokenCache(); + delete process.env.MAPBOX_PUBLIC_TOKEN; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('serves HTML with the mcp-app mime type, the public token, and the search-specific marker class', 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 SearchAppUIResource({ httpRequest }); + + const result = await resource.read('ui://mapbox/search-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); + + const entry = result.contents[0]; + expect(entry.mimeType).toBe('text/html;profile=mcp-app'); + expect(entry.uri).toBe('ui://mapbox/search-app/index.html'); + expect(entry.text as string).toContain( + 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token' + ); + expect(entry.text as string).toContain('result-marker'); + expect(entry.text as string).toContain('proximity-marker'); + + const meta = (entry as { _meta?: unknown })._meta as + | { ui?: { csp?: { workerDomains?: string[] } } } + | undefined; + expect(meta?.ui?.csp?.workerDomains).toContain('blob:'); + }); +});