diff --git a/CHANGELOG.md b/CHANGELOG.md index d017a7a..974c150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`union_tool`, `intersect_tool`, `difference_tool` now render as a live Mapbox GL JS map** with input polygons in muted blue and the result in an operation-keyed color (green for union, purple for intersect, orange for difference). All three share a single `PolygonOpsAppUIResource` and one `renderPolygonOpsAppHtml` template. Same dual-spec dispatch (MCP Apps + inline MCP-UI). - **`ground_location_tool` now renders as a live Mapbox GL JS map** showing the reverse-geocoded origin marker + nearby POIs (numbered orange pins) + the isochrone polygons when present. Same dual-spec dispatch (MCP Apps + inline MCP-UI) and shared `renderGroundLocationAppHtml` template. - **`map_matching_tool` now renders as a live Mapbox GL JS map** showing the raw GPS trace as a dashed orange line and the snapped matched route as a solid blue line on top. Same dual-spec dispatch (MCP Apps + inline MCP-UI) and shared `renderMapMatchingAppHtml` template. - **`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. diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 6da6c31..34f5909 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -11,6 +11,7 @@ import { OptimizationAppUIResource } from './ui-apps/OptimizationAppUIResource.j import { SearchAppUIResource } from './ui-apps/SearchAppUIResource.js'; import { MapMatchingAppUIResource } from './ui-apps/MapMatchingAppUIResource.js'; import { GroundLocationAppUIResource } from './ui-apps/GroundLocationAppUIResource.js'; +import { PolygonOpsAppUIResource } from './ui-apps/PolygonOpsAppUIResource.js'; import { VersionResource } from './version/VersionResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -26,6 +27,7 @@ export const ALL_RESOURCES = [ new SearchAppUIResource({ httpRequest }), new MapMatchingAppUIResource({ httpRequest }), new GroundLocationAppUIResource({ httpRequest }), + new PolygonOpsAppUIResource({ httpRequest }), new VersionResource() ] as const; diff --git a/src/resources/ui-apps/PolygonOpsAppUIResource.ts b/src/resources/ui-apps/PolygonOpsAppUIResource.ts new file mode 100644 index 0000000..90fbf53 --- /dev/null +++ b/src/resources/ui-apps/PolygonOpsAppUIResource.ts @@ -0,0 +1,86 @@ +// 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 { renderPolygonOpsAppHtml } from './polygonOpsAppHtml.js'; + +/** + * Serves HTML for union/intersect/difference polygon-op MCP Apps. + * + * The input polygons are rendered in muted blue (semi-transparent fill + + * outline) and the result polygon is overlaid in a brighter color keyed to + * the operation (green=union, purple=intersect, orange=difference). + */ +export class PolygonOpsAppUIResource extends BaseResource { + readonly name = 'Polygon Ops App UI'; + readonly uri = 'ui://mapbox/polygon-ops-app/index.html'; + readonly description = + 'Interactive UI for visualizing polygon union/intersect/difference results (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 = renderPolygonOpsAppHtml({ + 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/polygonOpsAppHtml.ts b/src/resources/ui-apps/polygonOpsAppHtml.ts new file mode 100644 index 0000000..5dbb643 --- /dev/null +++ b/src/resources/ui-apps/polygonOpsAppHtml.ts @@ -0,0 +1,342 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Render the polygon-operation MCP App HTML — used by both the MCP Apps + * resource and the inline MCP-UI rawHtml block emitted by `union_tool`, + * `intersect_tool`, and `difference_tool`. + * + * MCP Apps path: only the result geometry is in the tool's structuredContent + * (inputs come from the request, not the response). The iframe shows the + * result polygon in the operation color. + * + * MCP-UI path: the tool bakes both inputs + result into initialData, so the + * iframe shows inputs in muted blue and the result in the operation color. + */ + +export const MAPBOX_GL_VERSION = '3.12.0'; + +export type PolygonOperation = 'union' | 'intersect' | 'difference'; + +export interface PolygonOpsAppInitialData { + operation: PolygonOperation; + inputs: Array<{ type: 'Feature'; geometry: unknown }>; + result: { type: 'Feature'; geometry: unknown } | null; + summary?: string; +} + +export function renderPolygonOpsAppHtml(params: { + publicToken: string; + glVersion?: string; + initialData?: PolygonOpsAppInitialData; +}): string { + const { publicToken, initialData } = params; + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + + const initialDataScript = initialData + ? `` + : ''; + + return ` + + + + +Polygon Operation + + + + + +
+ +
+ + +
+
Loading…
+ +${initialDataScript} + + + +`; +} + +function escapeForScript(s: string): string { + return s.replace(/<\/script>/gi, '<\\/script>'); +} + +/** + * Bake polygon op inputs + result into the shared iframe template for + * MCP-UI clients. Polygon ops are fully offline tools (no API calls and + * no per-request access token), so the public token must come from the + * MAPBOX_PUBLIC_TOKEN env var. If it isn't set, no inline UI is emitted. + */ +export function tryRenderPolygonOpsInlineHtml(params: { + operation: PolygonOperation; + inputs: Array<{ type: 'Feature'; geometry: unknown }>; + result: { type: 'Feature'; geometry: unknown } | null; + summary: string; +}): string | undefined { + const { operation, inputs, result, summary } = params; + + if (inputs.length === 0) return undefined; + + const publicToken = process.env.MAPBOX_PUBLIC_TOKEN; + if (!publicToken || !publicToken.startsWith('pk.')) return undefined; + + return renderPolygonOpsAppHtml({ + publicToken, + initialData: { operation, inputs, result, summary } + }); +} diff --git a/src/tools/difference-tool/DifferenceTool.ts b/src/tools/difference-tool/DifferenceTool.ts index 0f12068..a889a95 100644 --- a/src/tools/difference-tool/DifferenceTool.ts +++ b/src/tools/difference-tool/DifferenceTool.ts @@ -1,8 +1,10 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. +import { randomUUID } from 'node:crypto'; import { difference, polygon, featureCollection } from '@turf/turf'; import { context, SpanStatusCode, trace } from '@opentelemetry/api'; +import { createUIResource } from '@mcp-ui/server'; import { createLocalToolExecutionContext } from '../../utils/tracing.js'; import { BaseTool } from '../BaseTool.js'; import { DifferenceInputSchema } from './DifferenceTool.input.schema.js'; @@ -11,6 +13,8 @@ import { type DifferenceOutput } from './DifferenceTool.output.schema.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { tryRenderPolygonOpsInlineHtml } from '../../resources/ui-apps/polygonOpsAppHtml.js'; export class DifferenceTool extends BaseTool< typeof DifferenceInputSchema, @@ -31,6 +35,16 @@ export class DifferenceTool extends BaseTool< openWorldHint: false }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/polygon-ops-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; + constructor() { super({ inputSchema: DifferenceInputSchema, @@ -63,11 +77,44 @@ export class DifferenceTool extends BaseTool< ? `Difference computed (area in polygon1 not covered by polygon2).\nGeometry:\n${JSON.stringify(validated.geometry, null, 2)}` : 'No difference: polygon2 fully covers polygon1.'; + const content: CallToolResult['content'] = [ + { type: 'text' as const, text } + ]; + + if (isMcpUiEnabled()) { + const inlineHtml = tryRenderPolygonOpsInlineHtml({ + operation: 'difference', + inputs: [poly1, poly2] as Array<{ + type: 'Feature'; + geometry: unknown; + }>, + result: (result ?? null) as { + type: 'Feature'; + geometry: unknown; + } | null, + summary: validated.has_difference + ? 'Difference of two polygons (polygon1 minus polygon2)' + : 'polygon2 fully covers polygon1 (no difference)' + }); + if (inlineHtml) { + content.push( + createUIResource({ + uri: `ui://mapbox/polygon-ops/${randomUUID()}`, + content: { type: 'rawHtml', htmlString: inlineHtml }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '500px'] + } + }) + ); + } + } + toolContext.span.setStatus({ code: SpanStatusCode.OK }); toolContext.span.end(); return { - content: [{ type: 'text' as const, text }], + content, structuredContent: validated, isError: false }; @@ -81,7 +128,10 @@ export class DifferenceTool extends BaseTool< toolContext.span.end(); return { content: [ - { type: 'text' as const, text: `DifferenceTool: ${errorMessage}` } + { + type: 'text' as const, + text: `DifferenceTool: ${errorMessage}` + } ], isError: true }; diff --git a/src/tools/intersect-tool/IntersectTool.ts b/src/tools/intersect-tool/IntersectTool.ts index 690c23b..715c511 100644 --- a/src/tools/intersect-tool/IntersectTool.ts +++ b/src/tools/intersect-tool/IntersectTool.ts @@ -1,8 +1,10 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. +import { randomUUID } from 'node:crypto'; import { intersect, polygon, featureCollection } from '@turf/turf'; import { context, SpanStatusCode, trace } from '@opentelemetry/api'; +import { createUIResource } from '@mcp-ui/server'; import { createLocalToolExecutionContext } from '../../utils/tracing.js'; import { BaseTool } from '../BaseTool.js'; import { IntersectInputSchema } from './IntersectTool.input.schema.js'; @@ -11,6 +13,8 @@ import { type IntersectOutput } from './IntersectTool.output.schema.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { tryRenderPolygonOpsInlineHtml } from '../../resources/ui-apps/polygonOpsAppHtml.js'; export class IntersectTool extends BaseTool< typeof IntersectInputSchema, @@ -31,6 +35,16 @@ export class IntersectTool extends BaseTool< openWorldHint: false }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/polygon-ops-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; + constructor() { super({ inputSchema: IntersectInputSchema, @@ -63,11 +77,44 @@ export class IntersectTool extends BaseTool< ? `The polygons intersect.\nIntersection geometry:\n${JSON.stringify(validated.geometry, null, 2)}` : 'The polygons do not intersect.'; + const content: CallToolResult['content'] = [ + { type: 'text' as const, text } + ]; + + if (isMcpUiEnabled()) { + const inlineHtml = tryRenderPolygonOpsInlineHtml({ + operation: 'intersect', + inputs: [poly1, poly2] as Array<{ + type: 'Feature'; + geometry: unknown; + }>, + result: (result ?? null) as { + type: 'Feature'; + geometry: unknown; + } | null, + summary: validated.intersects + ? 'Intersection of two polygons' + : 'Polygons do not intersect' + }); + if (inlineHtml) { + content.push( + createUIResource({ + uri: `ui://mapbox/polygon-ops/${randomUUID()}`, + content: { type: 'rawHtml', htmlString: inlineHtml }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '500px'] + } + }) + ); + } + } + toolContext.span.setStatus({ code: SpanStatusCode.OK }); toolContext.span.end(); return { - content: [{ type: 'text' as const, text }], + content, structuredContent: validated, isError: false }; diff --git a/src/tools/union-tool/UnionTool.ts b/src/tools/union-tool/UnionTool.ts index 7c73580..fa57c49 100644 --- a/src/tools/union-tool/UnionTool.ts +++ b/src/tools/union-tool/UnionTool.ts @@ -1,8 +1,10 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. +import { randomUUID } from 'node:crypto'; import { union, polygon, featureCollection } from '@turf/turf'; import { context, SpanStatusCode, trace } from '@opentelemetry/api'; +import { createUIResource } from '@mcp-ui/server'; import { createLocalToolExecutionContext } from '../../utils/tracing.js'; import { BaseTool } from '../BaseTool.js'; import { UnionInputSchema } from './UnionTool.input.schema.js'; @@ -11,6 +13,8 @@ import { type UnionOutput } from './UnionTool.output.schema.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { tryRenderPolygonOpsInlineHtml } from '../../resources/ui-apps/polygonOpsAppHtml.js'; export class UnionTool extends BaseTool< typeof UnionInputSchema, @@ -31,6 +35,16 @@ export class UnionTool extends BaseTool< openWorldHint: false }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/polygon-ops-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; + constructor() { super({ inputSchema: UnionInputSchema, outputSchema: UnionOutputSchema }); } @@ -60,11 +74,36 @@ export class UnionTool extends BaseTool< `Result type: ${validated.type}\n` + `GeoJSON geometry:\n${JSON.stringify(validated.geometry, null, 2)}`; + const content: CallToolResult['content'] = [ + { type: 'text' as const, text } + ]; + + if (isMcpUiEnabled()) { + const inlineHtml = tryRenderPolygonOpsInlineHtml({ + operation: 'union', + inputs: polys as Array<{ type: 'Feature'; geometry: unknown }>, + result: merged as { type: 'Feature'; geometry: unknown }, + summary: `Union of ${input.polygons.length} polygons` + }); + if (inlineHtml) { + content.push( + createUIResource({ + uri: `ui://mapbox/polygon-ops/${randomUUID()}`, + content: { type: 'rawHtml', htmlString: inlineHtml }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '500px'] + } + }) + ); + } + } + toolContext.span.setStatus({ code: SpanStatusCode.OK }); toolContext.span.end(); return { - content: [{ type: 'text' as const, text }], + content, structuredContent: validated, isError: false }; diff --git a/test/tools/annotations.test.ts b/test/tools/annotations.test.ts index 0a5448f..4bb900e 100644 --- a/test/tools/annotations.test.ts +++ b/test/tools/annotations.test.ts @@ -80,7 +80,10 @@ describe('Tool Annotations', () => { 'destination_tool', 'length_tool', 'nearest_point_on_line_tool', - 'convex_tool' + 'convex_tool', + 'union_app_tool', + 'intersect_app_tool', + 'difference_app_tool' ]; const apiTools = tools.filter((tool) => !offlineTools.includes(tool.name));