From 4c40a6fee78865f354dcbdacd0806a3b5b6a356b Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 11:30:13 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20union/intersect/difference=20app=20?= =?UTF-8?q?tools=20=E2=80=94=20polygon=20ops=20on=20a=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh-through-ninth tools in the MCP Apps series. Three Turf.js-based offline polygon-operation tools share a single PolygonOpsAppUIResource that renders the input polygons in muted blue and overlays the result in an operation-keyed color (green=union, purple=intersect, orange=difference). When intersect/difference produces no overlapping geometry, the tool still succeeds (isError=false) and the UI shows just the inputs with a "no overlapping geometry" summary. Also adds the three new offline tools to the annotations test's offlineTools list so the open-world-hint test continues to pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + src/resources/resourceRegistry.ts | 2 + .../ui-apps/PolygonOpsAppUIResource.ts | 382 ++++++++++++++++++ src/tools/index.ts | 19 + .../PolygonOpsAppTool.input.schema.ts | 26 ++ .../polygon-ops-app-tool/PolygonOpsAppTool.ts | 198 +++++++++ src/tools/toolRegistry.ts | 8 + test/tools/annotations.test.ts | 5 +- .../PolygonOpsAppTool.test.ts | 117 ++++++ 9 files changed, 757 insertions(+), 1 deletion(-) create mode 100644 src/resources/ui-apps/PolygonOpsAppUIResource.ts create mode 100644 src/tools/polygon-ops-app-tool/PolygonOpsAppTool.input.schema.ts create mode 100644 src/tools/polygon-ops-app-tool/PolygonOpsAppTool.ts create mode 100644 test/tools/polygon-ops-app-tool/PolygonOpsAppTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dc3806c..4f487f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`union_app_tool` + `intersect_app_tool` + `difference_app_tool`**: Three new offline polygon-operation tools that share a single MCP App resource (`ui://mapbox/polygon-ops-app/index.html`). Each renders the input polygons in muted blue and overlays the result polygon in an operation-keyed color (green for union, purple for intersect, orange for difference). When `intersect` or `difference` produces no overlapping geometry, the tool still succeeds and the UI just shows the inputs with a "no overlapping geometry" summary. - **`ground_location_app_tool`**: New tool that reverse-geocodes a coordinate to a place name and optionally finds nearby places matching a query, rendering the origin + POIs on an interactive Mapbox GL JS map (MCP App). Origin uses a dark circular marker; POIs are numbered orange pins with popups. A simpler, app-focused sibling of `ground_location_tool` for "tell me about this location and what's nearby" queries that benefit from a visual. - **`map_matching_app_tool`**: New tool that snaps a raw GPS trace to the road network and renders both the raw trace (dashed orange line) and the matched route (solid blue line) on an interactive Mapbox GL JS map (MCP App). Includes a small legend; the camera fits to the combined bounds of both traces. Useful for verifying how a noisy GPS recording maps to the road graph. - **`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. 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..da906d6 --- /dev/null +++ b/src/resources/ui-apps/PolygonOpsAppUIResource.ts @@ -0,0 +1,382 @@ +// 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 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 = renderHtml({ + 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 renderHtml(params: { + publicToken: string; + glVersion: string; +}): string { + const { publicToken, glVersion } = params; + + return ` + + + + +Polygon Operation + + + + + +
+ +
+
Inputs
+ +
+
Loading…
+ + + + +`; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 51d6582..f9328e5 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -46,6 +46,11 @@ export { } from './search-app-tool/SearchAppTool.js'; export { MapMatchingAppTool } from './map-matching-app-tool/MapMatchingAppTool.js'; export { GroundLocationAppTool } from './ground-location-app-tool/GroundLocationAppTool.js'; +export { + UnionAppTool, + IntersectAppTool, + DifferenceAppTool +} from './polygon-ops-app-tool/PolygonOpsAppTool.js'; export { DistanceTool } from './distance-tool/DistanceTool.js'; export { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; export { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -77,6 +82,11 @@ import { } from './search-app-tool/SearchAppTool.js'; import { MapMatchingAppTool } from './map-matching-app-tool/MapMatchingAppTool.js'; import { GroundLocationAppTool } from './ground-location-app-tool/GroundLocationAppTool.js'; +import { + UnionAppTool, + IntersectAppTool, + DifferenceAppTool +} from './polygon-ops-app-tool/PolygonOpsAppTool.js'; import { DistanceTool } from './distance-tool/DistanceTool.js'; import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -136,6 +146,15 @@ export const mapMatchingApp = new MapMatchingAppTool({ httpRequest }); /** Reverse-geocode + nearby POIs rendered on an interactive Mapbox GL JS map (MCP App) */ export const groundLocationApp = new GroundLocationAppTool({ httpRequest }); +/** Union of two or more polygons rendered on an interactive map (MCP App) */ +export const unionApp = new UnionAppTool(); + +/** Intersection of two polygons rendered on an interactive map (MCP App) */ +export const intersectApp = new IntersectAppTool(); + +/** Difference (polygon1 minus polygon2) rendered on an interactive map (MCP App) */ +export const differenceApp = new DifferenceAppTool(); + /** Calculate distance between points */ export const distance = new DistanceTool(); diff --git a/src/tools/polygon-ops-app-tool/PolygonOpsAppTool.input.schema.ts b/src/tools/polygon-ops-app-tool/PolygonOpsAppTool.input.schema.ts new file mode 100644 index 0000000..eecf195 --- /dev/null +++ b/src/tools/polygon-ops-app-tool/PolygonOpsAppTool.input.schema.ts @@ -0,0 +1,26 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { PolygonSchema } from '../shared/polygonSchema.js'; + +export const UnionAppInputSchema = z.object({ + polygons: z + .array(PolygonSchema) + .min(2) + .describe('Two or more polygons to merge into a single union geometry.') +}); + +export const IntersectAppInputSchema = z.object({ + polygon1: PolygonSchema.describe('First polygon'), + polygon2: PolygonSchema.describe('Second polygon') +}); + +export const DifferenceAppInputSchema = z.object({ + polygon1: PolygonSchema.describe('Polygon to subtract from'), + polygon2: PolygonSchema.describe('Polygon to subtract') +}); + +export type UnionAppInput = z.infer; +export type IntersectAppInput = z.infer; +export type DifferenceAppInput = z.infer; diff --git a/src/tools/polygon-ops-app-tool/PolygonOpsAppTool.ts b/src/tools/polygon-ops-app-tool/PolygonOpsAppTool.ts new file mode 100644 index 0000000..5228e9c --- /dev/null +++ b/src/tools/polygon-ops-app-tool/PolygonOpsAppTool.ts @@ -0,0 +1,198 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { randomUUID } from 'node:crypto'; +import { + difference, + featureCollection, + intersect, + polygon as turfPolygon, + union +} from '@turf/turf'; +import type { Feature, MultiPolygon, Polygon } from 'geojson'; +import type { z } from 'zod'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { BaseTool } from '../BaseTool.js'; +import { + UnionAppInputSchema, + IntersectAppInputSchema, + DifferenceAppInputSchema +} from './PolygonOpsAppTool.input.schema.js'; + +// All three tools share the same MCP App UI resource — the renderer just +// shows the input polygons in muted blue and the result polygon highlighted. + +const POLYGON_OPS_META = { + ui: { + resourceUri: 'ui://mapbox/polygon-ops-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } +}; + +function ringsToPolygonFeature(rings: number[][][]): Feature { + return turfPolygon(rings as Polygon['coordinates']) as Feature; +} + +function buildPayload(params: { + operation: 'union' | 'intersect' | 'difference'; + inputs: Array<{ type: 'Feature'; geometry: Polygon }>; + result: Feature | null; +}) { + const { operation, inputs, result } = params; + const summary = result + ? `${operation} produced a ${result.geometry.type}` + : `${operation} produced no overlapping geometry`; + + return { + summary, + operation, + inputs, + result: result ?? null + }; +} + +function buildResponse( + payload: ReturnType +): CallToolResult { + return { + content: [ + { type: 'text', text: payload.summary }, + { type: 'text', text: JSON.stringify(payload) } + ], + structuredContent: { polygon_ops: payload }, + isError: false, + _meta: { viewUUID: randomUUID() } + }; +} + +// --------------------------------------------------------------------------- +// UnionAppTool +// --------------------------------------------------------------------------- +export class UnionAppTool extends BaseTool { + name = 'union_app_tool'; + description = + 'Merge two or more polygons into a single union geometry and render the inputs + result on an interactive Mapbox GL JS map (MCP App). ' + + 'Useful for visualizing service-area or delivery-zone consolidation.'; + annotations = { + title: 'Union App Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }; + readonly meta = POLYGON_OPS_META; + + constructor() { + super({ inputSchema: UnionAppInputSchema }); + } + + async run(rawInput: unknown): Promise { + const input = this.inputSchema.parse(rawInput) as z.infer< + typeof this.inputSchema + >; + return this.executeImpl(input); + } + + protected async executeImpl( + input: z.infer + ): Promise { + const features = input.polygons.map(ringsToPolygonFeature); + const result = union(featureCollection(features)) as Feature< + Polygon | MultiPolygon + > | null; + + return buildResponse( + buildPayload({ operation: 'union', inputs: features, result }) + ); + } +} + +// --------------------------------------------------------------------------- +// IntersectAppTool +// --------------------------------------------------------------------------- +export class IntersectAppTool extends BaseTool { + name = 'intersect_app_tool'; + description = + 'Find the intersection (shared area) of two polygons and render the inputs + result on an interactive Mapbox GL JS map (MCP App).'; + annotations = { + title: 'Intersect App Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }; + readonly meta = POLYGON_OPS_META; + + constructor() { + super({ inputSchema: IntersectAppInputSchema }); + } + + async run(rawInput: unknown): Promise { + const input = this.inputSchema.parse(rawInput) as z.infer< + typeof this.inputSchema + >; + return this.executeImpl(input); + } + + protected async executeImpl( + input: z.infer + ): Promise { + const p1 = ringsToPolygonFeature(input.polygon1); + const p2 = ringsToPolygonFeature(input.polygon2); + const result = intersect(featureCollection([p1, p2])) as Feature< + Polygon | MultiPolygon + > | null; + + return buildResponse( + buildPayload({ operation: 'intersect', inputs: [p1, p2], result }) + ); + } +} + +// --------------------------------------------------------------------------- +// DifferenceAppTool +// --------------------------------------------------------------------------- +export class DifferenceAppTool extends BaseTool< + typeof DifferenceAppInputSchema +> { + name = 'difference_app_tool'; + description = + 'Subtract one polygon from another (polygon1 minus polygon2) and render the inputs + result on an interactive Mapbox GL JS map (MCP App). ' + + 'Useful for "what is in zone A but NOT in zone B" type questions.'; + annotations = { + title: 'Difference App Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }; + readonly meta = POLYGON_OPS_META; + + constructor() { + super({ inputSchema: DifferenceAppInputSchema }); + } + + async run(rawInput: unknown): Promise { + const input = this.inputSchema.parse(rawInput) as z.infer< + typeof this.inputSchema + >; + return this.executeImpl(input); + } + + protected async executeImpl( + input: z.infer + ): Promise { + const p1 = ringsToPolygonFeature(input.polygon1); + const p2 = ringsToPolygonFeature(input.polygon2); + const result = difference(featureCollection([p1, p2])) as Feature< + Polygon | MultiPolygon + > | null; + + return buildResponse( + buildPayload({ operation: 'difference', inputs: [p1, p2], result }) + ); + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 574fc62..6f8a7b3 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -37,6 +37,11 @@ import { } from './search-app-tool/SearchAppTool.js'; import { MapMatchingAppTool } from './map-matching-app-tool/MapMatchingAppTool.js'; import { GroundLocationAppTool } from './ground-location-app-tool/GroundLocationAppTool.js'; +import { + UnionAppTool, + IntersectAppTool, + DifferenceAppTool +} from './polygon-ops-app-tool/PolygonOpsAppTool.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'; @@ -81,6 +86,9 @@ export const CORE_TOOLS = [ new CategorySearchAppTool({ httpRequest }), new MapMatchingAppTool({ httpRequest }), new GroundLocationAppTool({ httpRequest }), + new UnionAppTool(), + new IntersectAppTool(), + new DifferenceAppTool(), new ReverseGeocodeTool({ httpRequest }), new StaticMapImageTool({ httpRequest }), new SearchAndGeocodeTool({ httpRequest }) 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)); diff --git a/test/tools/polygon-ops-app-tool/PolygonOpsAppTool.test.ts b/test/tools/polygon-ops-app-tool/PolygonOpsAppTool.test.ts new file mode 100644 index 0000000..8fd40b5 --- /dev/null +++ b/test/tools/polygon-ops-app-tool/PolygonOpsAppTool.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { + UnionAppTool, + IntersectAppTool, + DifferenceAppTool +} from '../../../src/tools/polygon-ops-app-tool/PolygonOpsAppTool.js'; + +// Two overlapping 1°×1° squares. +const polygonA = [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0] + ] +]; +const polygonB = [ + [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + [1, 1] + ] +]; + +const SHARED_URI = 'ui://mapbox/polygon-ops-app/index.html'; + +describe('UnionAppTool', () => { + it('returns the union of two polygons + inputs', async () => { + const result = await new UnionAppTool().run({ + polygons: [polygonA, polygonB] + }); + + expect(result.isError).toBe(false); + const payload = JSON.parse( + (result.content[1] as { type: 'text'; text: string }).text + ); + expect(payload.operation).toBe('union'); + expect(payload.inputs).toHaveLength(2); + expect(payload.result).not.toBeNull(); + expect(['Polygon', 'MultiPolygon']).toContain(payload.result.geometry.type); + }); + + it('declares the shared polygon-ops resourceUri', () => { + expect(new UnionAppTool().meta?.ui?.resourceUri).toBe(SHARED_URI); + }); +}); + +describe('IntersectAppTool', () => { + it('returns the intersection of two polygons', async () => { + const result = await new IntersectAppTool().run({ + polygon1: polygonA, + polygon2: polygonB + }); + + expect(result.isError).toBe(false); + const payload = JSON.parse( + (result.content[1] as { type: 'text'; text: string }).text + ); + expect(payload.operation).toBe('intersect'); + expect(payload.result).not.toBeNull(); + }); + + it('returns null result for non-overlapping polygons (still no error)', async () => { + const far = [ + [ + [10, 10], + [11, 10], + [11, 11], + [10, 11], + [10, 10] + ] + ]; + const result = await new IntersectAppTool().run({ + polygon1: polygonA, + polygon2: far + }); + + expect(result.isError).toBe(false); + const payload = JSON.parse( + (result.content[1] as { type: 'text'; text: string }).text + ); + expect(payload.result).toBeNull(); + const summary = (result.content[0] as { type: 'text'; text: string }).text; + expect(summary).toContain('no overlapping'); + }); + + it('shares the same resourceUri as UnionAppTool', () => { + expect(new IntersectAppTool().meta?.ui?.resourceUri).toBe(SHARED_URI); + }); +}); + +describe('DifferenceAppTool', () => { + it('returns polygon1 minus polygon2', async () => { + const result = await new DifferenceAppTool().run({ + polygon1: polygonA, + polygon2: polygonB + }); + + expect(result.isError).toBe(false); + const payload = JSON.parse( + (result.content[1] as { type: 'text'; text: string }).text + ); + expect(payload.operation).toBe('difference'); + expect(payload.result).not.toBeNull(); + expect(['Polygon', 'MultiPolygon']).toContain(payload.result.geometry.type); + }); + + it('shares the same resourceUri', () => { + expect(new DifferenceAppTool().meta?.ui?.resourceUri).toBe(SHARED_URI); + }); +}); From 0e7790b74b5ac415cc84ff8675fd387efeb1301d Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 11:45:42 -0400 Subject: [PATCH 2/3] fix(polygon_ops_app): defer fitBounds until after iframe resize Final resource in the series. Same pattern: send size-changed first, delay 60ms, then map.resize() + fitBounds so polygons fill the viewport instead of appearing as tiny dots. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ui-apps/PolygonOpsAppUIResource.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/resources/ui-apps/PolygonOpsAppUIResource.ts b/src/resources/ui-apps/PolygonOpsAppUIResource.ts index da906d6..04e7616 100644 --- a/src/resources/ui-apps/PolygonOpsAppUIResource.ts +++ b/src/resources/ui-apps/PolygonOpsAppUIResource.ts @@ -365,15 +365,18 @@ function renderHtml(params: { legendEl.style.display = 'block'; - if (isFinite(minLng)) { - map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { - padding: { top: 70, bottom: 50, left: 30, right: 30 }, - duration: 600 - }); - } - loadingEl.style.display = 'none'; requestSizeToFit(); + + if (isFinite(minLng)) { + setTimeout(function() { + map.resize(); + map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { + padding: { top: 70, bottom: 50, left: 30, right: 30 }, + duration: 600 + }); + }, 60); + } } })(); From 6c17256ca9d869cdf91e7adca9c247405bb24909 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 16:42:22 -0400 Subject: [PATCH 3/3] refactor: fold MCP App support into union/intersect/difference tools Polygon ops now render as a live Mapbox GL JS map directly from the existing offline tools instead of via parallel `*_app_tool` siblings. - Extract the rendering pipeline into a shared `renderPolygonOpsAppHtml` module so the MCP Apps resource and the inline MCP-UI rawHtml block use one template and one source of truth. - Slim down `PolygonOpsAppUIResource` to a thin wrapper around the shared render function (only the empty/postMessage variant). - Add `_meta.ui.resourceUri` to `union_tool`, `intersect_tool`, and `difference_tool` pointing to the shared `ui://mapbox/polygon-ops-app/ index.html` resource (MCP Apps path). - Gate inline MCP-UI rawHtml on `isMcpUiEnabled()` and bake both inputs and the result polygon into the iframe with operation-keyed result coloring (green=union, purple=intersect, orange=difference). - Polygon ops are fully offline, so the inline path uses `MAPBOX_PUBLIC_TOKEN` from env directly rather than the tokens API. Co-Authored-By: Claude Opus 4.7 --- .../ui-apps/PolygonOpsAppUIResource.ts | 305 +--------------- src/resources/ui-apps/polygonOpsAppHtml.ts | 342 ++++++++++++++++++ src/tools/difference-tool/DifferenceTool.ts | 54 ++- src/tools/intersect-tool/IntersectTool.ts | 49 ++- src/tools/union-tool/UnionTool.ts | 41 ++- 5 files changed, 485 insertions(+), 306 deletions(-) create mode 100644 src/resources/ui-apps/polygonOpsAppHtml.ts diff --git a/src/resources/ui-apps/PolygonOpsAppUIResource.ts b/src/resources/ui-apps/PolygonOpsAppUIResource.ts index 04e7616..90fbf53 100644 --- a/src/resources/ui-apps/PolygonOpsAppUIResource.ts +++ b/src/resources/ui-apps/PolygonOpsAppUIResource.ts @@ -11,8 +11,7 @@ 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'; +import { renderPolygonOpsAppHtml } from './polygonOpsAppHtml.js'; /** * Serves HTML for union/intersect/difference polygon-op MCP Apps. @@ -57,9 +56,8 @@ export class PolygonOpsAppUIResource extends BaseResource { httpRequest: this.httpRequest }); - const html = renderHtml({ - publicToken: publicToken ?? '', - glVersion: MAPBOX_GL_VERSION + const html = renderPolygonOpsAppHtml({ + publicToken: publicToken ?? '' }); return { @@ -86,300 +84,3 @@ export class PolygonOpsAppUIResource extends BaseResource { }; } } - -function renderHtml(params: { - publicToken: string; - glVersion: string; -}): string { - const { publicToken, glVersion } = params; - - return ` - - - - -Polygon Operation - - - - - -
- -
-
Inputs
- -
-
Loading…
- - - - -`; -} 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 };