From e4fbe9ed8d08bf2243ade54dc75addc97ecf05a1 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 11:16:45 -0400 Subject: [PATCH 1/3] feat: search_and_geocode/category_search app tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth in the MCP Apps series. Two tools — free-text search and category-filtered search — share a single SearchAppUIResource that drops numbered pins with popups (name, address, category, coordinates) for each result. When proximity is provided, a small dark dot also shows on the map as "you are here" context. The two tools share the same MCP App resource because their result shape is identical; only the API endpoint and bias parameters differ. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + src/resources/resourceRegistry.ts | 2 + src/resources/ui-apps/SearchAppUIResource.ts | 360 ++++++++++++++++++ src/tools/index.ts | 14 + .../SearchAppTool.input.schema.ts | 53 +++ src/tools/search-app-tool/SearchAppTool.ts | 246 ++++++++++++ src/tools/toolRegistry.ts | 6 + .../ui-apps/SearchAppUIResource.test.ts | 68 ++++ .../search-app-tool/SearchAppTool.test.ts | 156 ++++++++ 9 files changed, 906 insertions(+) create mode 100644 src/resources/ui-apps/SearchAppUIResource.ts create mode 100644 src/tools/search-app-tool/SearchAppTool.input.schema.ts create mode 100644 src/tools/search-app-tool/SearchAppTool.ts create mode 100644 test/resources/ui-apps/SearchAppUIResource.test.ts create mode 100644 test/tools/search-app-tool/SearchAppTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7022ca6..f15139d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`search_and_geocode_app_tool` + `category_search_app_tool`**: Two new tools that render Mapbox Search Box results as numbered, popup-equipped pins on an interactive Mapbox GL JS map (MCP App). Both share a single MCP App resource (`ui://mapbox/search-app/index.html`). When a `proximity` point is given, it's also rendered as a small dark "you are here" dot. Useful for any "find me X near Y" prompt — the natural UX for multi-result POI lists. - **`optimization_app_tool`**: New tool that solves the traveling-salesman version of a multi-stop trip and renders the result on an interactive Mapbox GL JS map as an MCP App. Accepts 2–12 stops, returns the optimized visit order plus the route geometry, and the registered MCP App resource (`ui://mapbox/optimization-app/index.html`) draws the route line with numbered markers (1, 2, 3, …) at each stop in the visit order. Supports `source`, `destination`, `roundtrip`, and `profile` options. Useful for "what order should I do these errands in?" type questions. - **`isochrone_app_tool`**: New tool that renders reachable-area isochrones on an interactive Mapbox GL JS map as an MCP App. Returns the isochrone FeatureCollection plus a `_meta.ui.resourceUri` reference to a registered MCP App resource (`ui://mapbox/isochrone-app/index.html`) that hosts render as a live map with each contour drawn as a translucent fill + outline layer, the origin point marked, and the camera fit to the contours. Reuses the same `tokens:read`-via-Tokens-API public token resolution as `directions_app_tool`. - **`directions_app_tool`**: New tool that renders a route on an interactive Mapbox GL JS map as an MCP App. Returns the route GeoJSON plus a `_meta.ui.resourceUri` reference to a separately-registered MCP App resource (`ui://mapbox/directions-app/index.html`) that hosts (Claude Desktop, VS Code, Cursor) render as a live map with the route drawn, start/end markers, and camera fit to the route bounds. The required public (`pk.*`) token is resolved server-side by the resource: it first calls `GET /tokens/v2/{user}?default=true` to fetch the user's default public token (requires `tokens:read` scope on the `sk.*` access token), and falls back to the optional `MAPBOX_PUBLIC_TOKEN` env var. Includes `_meta.ui.csp` with `workerDomains: ['blob:']` so MCP App hosts grant Mapbox GL JS the iframe sandbox permissions it needs. 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..8f4cde3 --- /dev/null +++ b/src/resources/ui-apps/SearchAppUIResource.ts @@ -0,0 +1,360 @@ +// 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'; + +const MAPBOX_GL_VERSION = '3.12.0'; + +/** + * Serves the HTML for the Search Results App MCP App. + * + * Receives search results from `search_and_geocode_app_tool` or + * `category_search_app_tool` and drops numbered pins on the map with popups + * showing name, address, and category. + */ +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 ?? '', + glVersion: MAPBOX_GL_VERSION + }); + + 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 } + } + } + } + ] + }; + } +} + +function renderSearchAppHtml(params: { + publicToken: string; + glVersion: string; +}): string { + const { publicToken, glVersion } = params; + + return ` + + + + +Search Results + + + + + +
+ +
Loading results…
+ + + + +`; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index dd02357..5e97b03 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -40,6 +40,10 @@ export { DirectionsTool } from './directions-tool/DirectionsTool.js'; export { DirectionsAppTool } from './directions-app-tool/DirectionsAppTool.js'; export { IsochroneAppTool } from './isochrone-app-tool/IsochroneAppTool.js'; export { OptimizationAppTool } from './optimization-app-tool/OptimizationAppTool.js'; +export { + SearchAndGeocodeAppTool, + CategorySearchAppTool +} from './search-app-tool/SearchAppTool.js'; export { DistanceTool } from './distance-tool/DistanceTool.js'; export { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; export { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -65,6 +69,10 @@ import { DirectionsTool } from './directions-tool/DirectionsTool.js'; import { DirectionsAppTool } from './directions-app-tool/DirectionsAppTool.js'; import { IsochroneAppTool } from './isochrone-app-tool/IsochroneAppTool.js'; import { OptimizationAppTool } from './optimization-app-tool/OptimizationAppTool.js'; +import { + SearchAndGeocodeAppTool, + CategorySearchAppTool +} from './search-app-tool/SearchAppTool.js'; import { DistanceTool } from './distance-tool/DistanceTool.js'; import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -112,6 +120,12 @@ export const isochroneApp = new IsochroneAppTool({ httpRequest }); /** Render an optimized multi-stop trip on an interactive Mapbox GL JS map (MCP App) */ export const optimizationApp = new OptimizationAppTool({ httpRequest }); +/** Free-text search/geocode results on an interactive Mapbox GL JS map (MCP App) */ +export const searchAndGeocodeApp = new SearchAndGeocodeAppTool({ httpRequest }); + +/** Category-filtered POI search results on an interactive Mapbox GL JS map (MCP App) */ +export const categorySearchApp = new CategorySearchAppTool({ httpRequest }); + /** Calculate distance between points */ export const distance = new DistanceTool(); diff --git a/src/tools/search-app-tool/SearchAppTool.input.schema.ts b/src/tools/search-app-tool/SearchAppTool.input.schema.ts new file mode 100644 index 0000000..5c520e9 --- /dev/null +++ b/src/tools/search-app-tool/SearchAppTool.input.schema.ts @@ -0,0 +1,53 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { coordinateSchema } from '../../schemas/shared.js'; + +export const SearchAndGeocodeAppInputSchema = z.object({ + q: z + .string() + .min(1) + .max(256) + .describe('Free-text search query (place name, address, or POI).'), + proximity: coordinateSchema + .optional() + .describe( + 'Bias results toward this location. Strongly recommended for relevant results.' + ), + limit: z + .number() + .int() + .min(1) + .max(10) + .default(10) + .describe('Maximum number of results to return.') +}); + +export const CategorySearchAppInputSchema = z.object({ + category: z + .string() + .min(1) + .describe( + 'Canonical category name (e.g. "restaurant", "cafe", "hotel"). Use category_list_tool for the full list.' + ), + proximity: coordinateSchema + .optional() + .describe( + 'Bias results toward this location. Strongly recommended for relevant results.' + ), + limit: z + .number() + .int() + .min(1) + .max(25) + .default(10) + .describe('Maximum number of results to return.') +}); + +export type SearchAndGeocodeAppInput = z.infer< + typeof SearchAndGeocodeAppInputSchema +>; +export type CategorySearchAppInput = z.infer< + typeof CategorySearchAppInputSchema +>; diff --git a/src/tools/search-app-tool/SearchAppTool.ts b/src/tools/search-app-tool/SearchAppTool.ts new file mode 100644 index 0000000..fcf4129 --- /dev/null +++ b/src/tools/search-app-tool/SearchAppTool.ts @@ -0,0 +1,246 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { randomUUID } from 'node:crypto'; +import type { z } from 'zod'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { + SearchAndGeocodeAppInputSchema, + CategorySearchAppInputSchema +} from './SearchAppTool.input.schema.js'; + +// Docs: +// https://docs.mapbox.com/api/search/search-box/#search-request +// https://docs.mapbox.com/api/search/search-box/#category-search + +interface SearchFeature { + type: 'Feature'; + geometry?: { type: string; coordinates: [number, number] }; + properties?: { + name?: string; + full_address?: string; + place_formatted?: string; + poi_category?: string[]; + feature_type?: string; + }; +} + +interface SearchResponse { + type?: 'FeatureCollection'; + features?: SearchFeature[]; +} + +const SEARCH_APP_META = { + ui: { + resourceUri: 'ui://mapbox/search-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } +}; + +function shapeResults(features: SearchFeature[]) { + return features + .filter( + ( + f + ): f is SearchFeature & { + geometry: { type: string; coordinates: [number, number] }; + } => + !!f.geometry?.coordinates && + Array.isArray(f.geometry.coordinates) && + f.geometry.coordinates.length === 2 + ) + .map((f, i) => ({ + index: i + 1, + name: f.properties?.name ?? 'Result', + address: + f.properties?.full_address ?? + f.properties?.place_formatted ?? + undefined, + category: f.properties?.poi_category?.[0], + location: f.geometry.coordinates + })); +} + +// --------------------------------------------------------------------------- +// SearchAndGeocodeAppTool — free-text search/geocode +// --------------------------------------------------------------------------- +export class SearchAndGeocodeAppTool extends MapboxApiBasedTool< + typeof SearchAndGeocodeAppInputSchema +> { + name = 'search_and_geocode_app_tool'; + description = + 'Search for places by free-text query (place name, address, POI) and render the results as pins on an interactive Mapbox GL JS map (MCP App). ' + + 'Use this when the user asks to find or locate places — "find coffee shops near me", "where is the closest pharmacy", etc.'; + annotations = { + title: 'Search and Geocode App Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + readonly meta = SEARCH_APP_META; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: SearchAndGeocodeAppInputSchema, + httpRequest: params.httpRequest + }); + } + + protected async execute( + input: z.infer, + accessToken: string + ): Promise { + const url = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}search/searchbox/v1/forward` + ); + url.searchParams.set('access_token', accessToken); + url.searchParams.set('q', input.q); + url.searchParams.set('limit', String(input.limit)); + if (input.proximity) { + url.searchParams.set( + 'proximity', + `${input.proximity.longitude},${input.proximity.latitude}` + ); + } + + return runSearch({ + httpRequest: this.httpRequest, + getErrorMessage: (r) => this.getErrorMessage(r), + url, + context: { + kind: 'search', + query: input.q, + proximity: input.proximity + } + }); + } +} + +// --------------------------------------------------------------------------- +// CategorySearchAppTool — category-filtered search +// --------------------------------------------------------------------------- +export class CategorySearchAppTool extends MapboxApiBasedTool< + typeof CategorySearchAppInputSchema +> { + name = 'category_search_app_tool'; + description = + 'Search for places by canonical category (restaurant, cafe, hotel, …) and render the results as pins on an interactive Mapbox GL JS map (MCP App). ' + + 'Use this when the user wants a list of places of a specific type near a location.'; + annotations = { + title: 'Category Search App Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + readonly meta = SEARCH_APP_META; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: CategorySearchAppInputSchema, + httpRequest: params.httpRequest + }); + } + + protected async execute( + input: z.infer, + accessToken: string + ): Promise { + const url = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}search/searchbox/v1/category/${encodeURIComponent(input.category)}` + ); + url.searchParams.set('access_token', accessToken); + url.searchParams.set('limit', String(input.limit)); + if (input.proximity) { + url.searchParams.set( + 'proximity', + `${input.proximity.longitude},${input.proximity.latitude}` + ); + } + + return runSearch({ + httpRequest: this.httpRequest, + getErrorMessage: (r) => this.getErrorMessage(r), + url, + context: { + kind: 'category', + category: input.category, + proximity: input.proximity + } + }); + } +} + +// --------------------------------------------------------------------------- +// Shared search runner used by both tools +// --------------------------------------------------------------------------- +interface RunSearchParams { + httpRequest: HttpRequest; + getErrorMessage: (response: Response) => Promise; + url: URL; + context: + | { + kind: 'search'; + query: string; + proximity?: { longitude: number; latitude: number }; + } + | { + kind: 'category'; + category: string; + proximity?: { longitude: number; latitude: number }; + }; +} + +async function runSearch(params: RunSearchParams): Promise { + const { httpRequest, getErrorMessage, url, context } = params; + + const response = await httpRequest(url.toString()); + if (!response.ok) { + const errorText = await getErrorMessage(response); + return { + content: [{ type: 'text', text: `Search API error: ${errorText}` }], + isError: true + }; + } + + const data = (await response.json()) as SearchResponse; + const results = shapeResults(data.features ?? []); + + if (results.length === 0) { + return { + content: [{ type: 'text', text: 'No matching places found.' }], + isError: true + }; + } + + const summary = + context.kind === 'search' + ? `Found ${results.length} match${results.length === 1 ? '' : 'es'} for "${context.query}"` + : `Found ${results.length} "${context.category}" place${results.length === 1 ? '' : 's'}`; + + const payload = { + summary, + kind: context.kind, + query: context.kind === 'search' ? context.query : context.category, + proximity: context.proximity, + results + }; + + return { + content: [ + { type: 'text', text: summary }, + { type: 'text', text: JSON.stringify(payload) } + ], + structuredContent: { search: payload }, + isError: false, + _meta: { + viewUUID: randomUUID() + } + }; +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 46ead50..d407e8a 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -31,6 +31,10 @@ import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; import { MatrixTool } from './matrix-tool/MatrixTool.js'; import { OptimizationTool } from './optimization-tool/OptimizationTool.js'; import { OptimizationAppTool } from './optimization-app-tool/OptimizationAppTool.js'; +import { + SearchAndGeocodeAppTool, + CategorySearchAppTool +} from './search-app-tool/SearchAppTool.js'; import { ResourceReaderTool } from './resource-reader-tool/ResourceReaderTool.js'; import { ReverseGeocodeTool } from './reverse-geocode-tool/ReverseGeocodeTool.js'; import { StaticMapImageTool } from './static-map-image-tool/StaticMapImageTool.js'; @@ -71,6 +75,8 @@ export const CORE_TOOLS = [ new MatrixTool({ httpRequest }), new OptimizationTool({ httpRequest }), new OptimizationAppTool({ httpRequest }), + new SearchAndGeocodeAppTool({ httpRequest }), + new CategorySearchAppTool({ httpRequest }), new ReverseGeocodeTool({ httpRequest }), new StaticMapImageTool({ httpRequest }), new SearchAndGeocodeTool({ httpRequest }) 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:'); + }); +}); diff --git a/test/tools/search-app-tool/SearchAppTool.test.ts b/test/tools/search-app-tool/SearchAppTool.test.ts new file mode 100644 index 0000000..77a3e07 --- /dev/null +++ b/test/tools/search-app-tool/SearchAppTool.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; +import { + SearchAndGeocodeAppTool, + CategorySearchAppTool +} from '../../../src/tools/search-app-tool/SearchAppTool.js'; + +const fakeSearchResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-122.4194, 37.7749] }, + properties: { + name: 'Blue Bottle Coffee', + full_address: '66 Mint St, San Francisco, CA', + poi_category: ['cafe', 'coffee shop'] + } + }, + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-122.42, 37.78] }, + properties: { + name: 'Sightglass Coffee', + full_address: '270 7th St, San Francisco, CA', + poi_category: ['cafe'] + } + } + ] +}; + +function makeOkResponse(body: unknown): Partial { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body) + }; +} + +describe('SearchAndGeocodeAppTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns shaped results and the search resource reference', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest( + makeOkResponse(fakeSearchResponse) + ); + + const result = await new SearchAndGeocodeAppTool({ httpRequest }).run({ + q: 'coffee', + proximity: { longitude: -122.42, latitude: 37.78 } + }); + + expect(result.isError).toBe(false); + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('search/searchbox/v1/forward'); + expect(calledUrl).toContain('q=coffee'); + expect(calledUrl).toContain('proximity=-122.42%2C37.78'); + + const payload = JSON.parse( + (result.content[1] as { type: 'text'; text: string }).text + ); + expect(payload.results).toHaveLength(2); + expect(payload.results[0].name).toBe('Blue Bottle Coffee'); + expect(payload.results[0].index).toBe(1); + expect(payload.results[0].category).toBe('cafe'); + }); + + it('declares the shared search resourceUri', () => { + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeAppTool({ httpRequest }); + expect(tool.meta?.ui?.resourceUri).toBe( + 'ui://mapbox/search-app/index.html' + ); + }); + + it('errors when no results are returned', async () => { + const { httpRequest } = setupHttpRequest( + makeOkResponse({ type: 'FeatureCollection', features: [] }) + ); + + const result = await new SearchAndGeocodeAppTool({ httpRequest }).run({ + q: 'asdfqwerty' + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('No matching places'); + }); + + it('errors on non-2xx API response', async () => { + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ message: 'Bad query' }), + text: async () => '{"message":"Bad query"}' + }); + + const result = await new SearchAndGeocodeAppTool({ httpRequest }).run({ + q: 'coffee' + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('Search API error'); + }); +}); + +describe('CategorySearchAppTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('hits the category endpoint and shapes results identically', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest( + makeOkResponse(fakeSearchResponse) + ); + + const result = await new CategorySearchAppTool({ httpRequest }).run({ + category: 'cafe', + proximity: { longitude: -122.42, latitude: 37.78 }, + limit: 5 + }); + + expect(result.isError).toBe(false); + + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('search/searchbox/v1/category/cafe'); + expect(calledUrl).toContain('limit=5'); + + const payload = JSON.parse( + (result.content[1] as { type: 'text'; text: string }).text + ); + expect(payload.kind).toBe('category'); + expect(payload.results).toHaveLength(2); + }); + + it('shares the same MCP App resource as search_and_geocode_app_tool', () => { + const { httpRequest } = setupHttpRequest(); + const search = new SearchAndGeocodeAppTool({ httpRequest }); + const category = new CategorySearchAppTool({ httpRequest }); + expect(category.meta?.ui?.resourceUri).toBe(search.meta?.ui?.resourceUri); + }); +}); From 974b55beca99a5f53bc7064986d42574d445e895 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 11:44:09 -0400 Subject: [PATCH 2/3] fix(search_app): defer fitBounds until after iframe resize Same pattern. Handles both the single-result flyTo path and the multi-result fitBounds path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resources/ui-apps/SearchAppUIResource.ts | 28 +++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/resources/ui-apps/SearchAppUIResource.ts b/src/resources/ui-apps/SearchAppUIResource.ts index 8f4cde3..5234209 100644 --- a/src/resources/ui-apps/SearchAppUIResource.ts +++ b/src/resources/ui-apps/SearchAppUIResource.ts @@ -331,21 +331,23 @@ function renderSearchAppHtml(params: { .addTo(map); } - if (isFinite(minLng)) { - // If we only have one point, flyTo zoom 14; otherwise fit - if (minLng === maxLng && minLat === maxLat) { - map.flyTo({ center: [minLng, minLat], zoom: 14 }); - } else { - map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { - padding: { top: 70, bottom: 30, left: 30, right: 30 }, - duration: 600, - maxZoom: 15 - }); - } - } - loadingEl.style.display = 'none'; requestSizeToFit(); + + if (isFinite(minLng)) { + setTimeout(function() { + map.resize(); + if (minLng === maxLng && minLat === maxLat) { + map.flyTo({ center: [minLng, minLat], zoom: 14 }); + } else { + map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { + padding: { top: 70, bottom: 30, left: 30, right: 30 }, + duration: 600, + maxZoom: 15 + }); + } + }, 60); + } } function escapeHtml(s) { From a8288cf1d8985abd91defb430d76c0739b20baeb Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 16:26:17 -0400 Subject: [PATCH 3/3] refactor: fold MCP App support into search/category search tools Drops search_and_geocode_app_tool and category_search_app_tool. Both existing tools now emit meta.ui (MCP Apps) + inline rawHtml (MCP-UI), sharing renderSearchAppHtml and a tryRenderSearchInlineHtml helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resources/ui-apps/SearchAppUIResource.ts | 289 +------------ src/resources/ui-apps/searchAppHtml.ts | 397 ++++++++++++++++++ .../CategorySearchTool.ts | 61 ++- .../SearchAndGeocodeTool.ts | 52 ++- 4 files changed, 494 insertions(+), 305 deletions(-) create mode 100644 src/resources/ui-apps/searchAppHtml.ts diff --git a/src/resources/ui-apps/SearchAppUIResource.ts b/src/resources/ui-apps/SearchAppUIResource.ts index 5234209..a745d5f 100644 --- a/src/resources/ui-apps/SearchAppUIResource.ts +++ b/src/resources/ui-apps/SearchAppUIResource.ts @@ -11,16 +11,8 @@ 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'; -const MAPBOX_GL_VERSION = '3.12.0'; - -/** - * Serves the HTML for the Search Results App MCP App. - * - * Receives search results from `search_and_geocode_app_tool` or - * `category_search_app_tool` and drops numbered pins on the map with popups - * showing name, address, and category. - */ export class SearchAppUIResource extends BaseResource { readonly name = 'Search Results App UI'; readonly uri = 'ui://mapbox/search-app/index.html'; @@ -57,10 +49,7 @@ export class SearchAppUIResource extends BaseResource { httpRequest: this.httpRequest }); - const html = renderSearchAppHtml({ - publicToken: publicToken ?? '', - glVersion: MAPBOX_GL_VERSION - }); + const html = renderSearchAppHtml({ publicToken: publicToken ?? '' }); return { contents: [ @@ -86,277 +75,3 @@ export class SearchAppUIResource extends BaseResource { }; } } - -function renderSearchAppHtml(params: { - publicToken: string; - glVersion: string; -}): string { - const { publicToken, glVersion } = params; - - return ` - - - - -Search Results - - - - - -
- -
Loading results…
- - - - -`; -} 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 };