From d4f85c6718e45062746f86db201898f161942f70 Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Thu, 12 Nov 2026 15:10:27 +0200 Subject: [PATCH 1/3] feat: open a local .mvt or .pbf file in a Mapbox vector tile viewer Adds mvt_preview_tool, registered as a file entrypoint for .mvt and .pbf (OpenAI MCP Extensions), and its page. The tool is app-only and never sees the file: the page reads it through the host with resources/read on the opaque URI it is given, unzips it if gzipped, and decodes it in the browser. Each layer is drawn in its own color, placed by the tile address in the file name or one the user enters, with feature counts, fields and a click to inspect a feature. The open file and selected feature are shared as model context. A file picker does the same outside ChatGPT. The decoder matches @mapbox/vector-tile on five real tiles (2759 features), including negative int64 values. --- CHANGELOG.md | 4 + README.md | 4 + src/resources/resourceRegistry.ts | 3 + src/resources/ui-apps/MvtPreviewUIResource.ts | 73 +++ src/resources/ui-apps/mvtDecoder.ts | 202 ++++++ src/resources/ui-apps/mvtPreviewHtml.ts | 613 ++++++++++++++++++ src/tools/BaseTool.ts | 7 +- .../MvtPreviewTool.input.schema.ts | 15 + .../MvtPreviewTool.output.schema.ts | 19 + src/tools/mvt-preview-tool/MvtPreviewTool.ts | 103 +++ src/tools/toolRegistry.ts | 2 + .../ui-apps/MvtPreviewUIResource.test.ts | 73 +++ test/resources/ui-apps/mvtDecoder.test.ts | 238 +++++++ test/resources/ui-apps/mvtPreviewHtml.test.ts | 25 + test/tools/annotations.test.ts | 3 +- .../mvt-preview-tool/MvtPreviewTool.test.ts | 66 ++ 16 files changed, 1447 insertions(+), 3 deletions(-) create mode 100644 src/resources/ui-apps/MvtPreviewUIResource.ts create mode 100644 src/resources/ui-apps/mvtDecoder.ts create mode 100644 src/resources/ui-apps/mvtPreviewHtml.ts create mode 100644 src/tools/mvt-preview-tool/MvtPreviewTool.input.schema.ts create mode 100644 src/tools/mvt-preview-tool/MvtPreviewTool.output.schema.ts create mode 100644 src/tools/mvt-preview-tool/MvtPreviewTool.ts create mode 100644 test/resources/ui-apps/MvtPreviewUIResource.test.ts create mode 100644 test/resources/ui-apps/mvtDecoder.test.ts create mode 100644 test/resources/ui-apps/mvtPreviewHtml.test.ts create mode 100644 test/tools/mvt-preview-tool/MvtPreviewTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0e6b20..00cc86fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ - **The map:** every source layer is drawn in its own color (polygons, lines and points) over a blank background, framed to the tileset's bounds and never below its minzoom. The panel switches layers, lists their fields, shows tile boundaries and adds a base map. A click shows a feature's layer, geometry, ID and properties. A raster tileset is one raster layer. - **In ChatGPT:** opens as a tab next to the chat, and is a thread entrypoint (**Mapbox Tileset Explorer**) that starts on `mapbox.mapbox-streets-v8`. An open explorer shares the tileset, its hidden layers and the view as background context, and the selected feature as a titled item the user can remove. It registers two WebMCP tools: `load_tileset` (calls `tileset_explorer_tool` and shows the result in the open explorer) and `read_tileset_view` (features in view per source layer, with up to three samples each). - **Tokens:** the TileJSON `tiles` URLs carry the caller's token, so only metadata is returned. The page loads tiles through `mapbox://` with the account's public token; without one it still lists layers and fields. +- **Vector tile viewer: `mvt_preview_tool`.** Opens a local `.mvt` or `.pbf` file in ChatGPT Desktop through the [OpenAI MCP Extensions](https://developers.openai.com/plugins/build/extensions) file entrypoint (`{ type: "file", extensions: [".mvt", ".pbf"] }`), with its own page (`ui://mapbox/mvt-preview/index.html`). + - **Reading the file:** the tool is app-only and never sees the file. The page reads it through the host with `resources/read` on the opaque URI it is given (`representation: "blob"`), unzips it if gzipped, and decodes it in the browser. Outside ChatGPT, a file picker on the page does the same. + - **The map:** every layer is drawn in its own color, placed by the tile address in the file name (`14-9326-4739.mvt`, `streets_14_9326_4739.pbf`) or one the user enters. Without one the tile is drawn as 0/0/0. The panel lists each layer's feature count, geometry types and fields, switches layers, and shows the tile outline and a base map. A click shows a feature's properties. + - **In ChatGPT:** the open file (name, tile address, layers and fields) and the selected feature are shared with the model as background context. ## 0.18.0 - 2026-11-11 diff --git a/README.md b/README.md index d528079c..3ced1353 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,10 @@ In ChatGPT the explorer opens as a tab next to the chat, and also from "+" → " The server reads the TileJSON with the caller's token and passes on only its metadata, never the tile URLs, which carry that token. The map loads tiles with the account's public token, so a private tileset shows only for its own account. +### Vector tile viewer (`mvt_preview_tool`) + +Opens a local Mapbox Vector Tile file (`.mvt` or `.pbf`, gzipped or not) in ChatGPT Desktop, as the viewer for that file type. The page reads the file through ChatGPT and decodes it in the browser; the server never receives it. Each layer is drawn in its own color, placed by the tile address in the file name (for example `14-9326-4739.mvt`) or one entered in the panel. The panel lists layers, feature counts and fields, and a click shows a feature's properties. The tool is app-only, so the model does not call it. + ### Utility Tools #### Resource Reader Tool diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 62c8241e..ec14ac3b 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -10,6 +10,7 @@ import { ToolResponseResource } from './tool-response/ToolResponseResource.js'; import { MapAppUIResource } from './ui-apps/MapAppUIResource.js'; import { StaticMapViewerUIResource } from './ui-apps/StaticMapViewerUIResource.js'; import { TilesetExplorerUIResource } from './ui-apps/TilesetExplorerUIResource.js'; +import { MvtPreviewUIResource } from './ui-apps/MvtPreviewUIResource.js'; import { VersionResource } from './version/VersionResource.js'; import { PlacePhotoResource } from './place-photo/PlacePhotoResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -28,6 +29,8 @@ export const ALL_RESOURCES = [ new StaticMapViewerUIResource(), // Tileset preview, targeted by tileset_explorer_tool. new TilesetExplorerUIResource({ httpRequest }), + // Viewer for .mvt and .pbf files, targeted by mvt_preview_tool. + new MvtPreviewUIResource({ httpRequest }), // Photos for the map app's results carousel, which its CSP can't load directly. new PlacePhotoResource({ httpRequest }), new VersionResource() diff --git a/src/resources/ui-apps/MvtPreviewUIResource.ts b/src/resources/ui-apps/MvtPreviewUIResource.ts new file mode 100644 index 00000000..aff02982 --- /dev/null +++ b/src/resources/ui-apps/MvtPreviewUIResource.ts @@ -0,0 +1,73 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { + ReadResourceResult, + ServerContext +} from '@modelcontextprotocol/server'; +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 { renderMvtPreviewHtml } from './mvtPreviewHtml.js'; +import { mapAppCsp } from './mapAppCsp.js'; + +export const MVT_PREVIEW_URI = 'ui://mapbox/mvt-preview/index.html'; + +/** UI for `mvt_preview_tool`, the viewer for `.mvt` and `.pbf` files. */ +export class MvtPreviewUIResource extends BaseResource { + readonly name = 'Mapbox Vector Tile Viewer UI'; + readonly uri = MVT_PREVIEW_URI; + readonly description = + 'Viewer for a Mapbox Vector Tile file: its layers, fields and features on a map (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?: ServerContext): Promise { + const accessToken = + (extra?.http?.authInfo?.token as string | undefined) || + process.env.MAPBOX_ACCESS_TOKEN || + ''; + + const publicToken = accessToken + ? await resolveMapboxPublicToken({ + accessToken, + apiEndpoint: this.apiEndpoint(), + httpRequest: this.httpRequest + }) + : undefined; + + return { + contents: [ + { + uri: this.uri, + mimeType: RESOURCE_MIME_TYPE, + text: renderMvtPreviewHtml({ publicToken: publicToken ?? '' }), + _meta: { + ui: { + csp: mapAppCsp(this.apiEndpoint()), + preferredSize: { width: 1000, height: 600 } + }, + 'openai/ui': { + preferredDisplayMode: 'fullscreen', + availableDisplayModes: ['inline', 'fullscreen'] + } + } + } + ] + }; + } +} diff --git a/src/resources/ui-apps/mvtDecoder.ts b/src/resources/ui-apps/mvtDecoder.ts new file mode 100644 index 00000000..bb880742 --- /dev/null +++ b/src/resources/ui-apps/mvtDecoder.ts @@ -0,0 +1,202 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Browser JavaScript for the MVT viewer page: a Mapbox Vector Tile decoder + * (https://github.com/mapbox/vector-tile-spec/tree/master/2.1) and a converter + * to GeoJSON. Kept as source text, like the rest of the page script, so the + * page embeds exactly what the tests run. + * + * Defines: + * - `decodeMvt(bytes)` → `{ layers: [{ name, version, extent, features: + * [{ id, type, properties, parts }] }] }`, `type` 1 point, 2 line, 3 polygon, + * `parts` in tile coordinates. + * - `mvtLayerToGeoJson(layer, tile)` → GeoJSON features. With a tile + * `{ z, x, y }` coordinates are longitude and latitude; with `null` the tile + * is placed as 0/0/0, which keeps its shapes but not its location. + */ +export const MVT_DECODER_JS = String.raw` +function decodeMvt(bytes) { + var pos = 0; + var view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + var utf8 = new TextDecoder(); + + function varint() { + var result = 0, shift = 1, b; + do { + if (pos >= bytes.length) throw new Error('Truncated varint'); + if (shift > 18446744073709551616) throw new Error('Varint too long'); + b = bytes[pos++]; + result += (b & 0x7f) * shift; + shift *= 128; + } while (b >= 0x80); + return result; + } + // A negative int64 is a 10-byte two's complement varint, beyond what a + // double holds exactly, so read it as low and high 32 bits. + function int64() { + var lo = 0, hi = 0, b, i; + for (i = 0; i < 10; i++) { + if (pos >= bytes.length) throw new Error('Truncated varint'); + b = bytes[pos++]; + if (i < 4) lo |= (b & 0x7f) << (7 * i); + else if (i === 4) { lo |= (b & 0x0f) << 28; hi |= (b & 0x7f) >> 4; } + else hi |= (b & 0x7f) << (7 * i - 32); + if (b < 0x80) break; + } + if (b >= 0x80) throw new Error('Varint too long'); + if (hi & 0x80000000) return -((~hi >>> 0) * 4294967296 + (~lo >>> 0) + 1); + return (hi >>> 0) * 4294967296 + (lo >>> 0); + } + function zigzag(n) { return n % 2 === 1 ? -(n + 1) / 2 : n / 2; } + function skip(wireType) { + if (wireType === 0) varint(); + else if (wireType === 1) pos += 8; + else if (wireType === 2) pos += varint(); + else if (wireType === 5) pos += 4; + else throw new Error('Unsupported wire type ' + wireType); + } + function fields(end, onField) { + if (end > bytes.length) throw new Error('Truncated message'); + while (pos < end) { + var key = varint(); + onField(Math.floor(key / 8), key % 8); + } + if (pos !== end) throw new Error('Malformed message'); + } + function text() { + var length = varint(); + if (pos + length > bytes.length) throw new Error('Truncated string'); + var value = utf8.decode(bytes.subarray(pos, pos + length)); + pos += length; + return value; + } + function packed() { + var end = varint() + pos; + var out = []; + while (pos < end) out.push(varint()); + if (pos !== end) throw new Error('Malformed packed field'); + return out; + } + function value(end) { + var result = ''; + fields(end, function(field, wireType) { + if (field === 1 && wireType === 2) result = text(); + else if (field === 2 && wireType === 5) { result = view.getFloat32(pos, true); pos += 4; } + else if (field === 3 && wireType === 1) { result = view.getFloat64(pos, true); pos += 8; } + else if (field === 4 && wireType === 0) result = int64(); + else if (field === 5 && wireType === 0) result = varint(); + else if (field === 6 && wireType === 0) result = zigzag(varint()); + else if (field === 7 && wireType === 0) result = varint() !== 0; + else skip(wireType); + }); + return result; + } + function geometry(commands) { + var parts = [], part = null, x = 0, y = 0, i = 0; + while (i < commands.length) { + var command = commands[i] & 7; + var count = Math.floor(commands[i] / 8); + i++; + if (command === 7) { + if (part && part.length) part.push([part[0][0], part[0][1]]); + continue; + } + if (command !== 1 && command !== 2) throw new Error('Unknown geometry command ' + command); + for (var k = 0; k < count; k++) { + if (i + 1 >= commands.length) throw new Error('Truncated geometry'); + x += zigzag(commands[i++]); + y += zigzag(commands[i++]); + if (command === 1) { part = []; parts.push(part); } + if (part) part.push([x, y]); + } + } + return parts; + } + function layer(end) { + var out = { name: '', version: 1, extent: 4096, features: [] }; + var keys = [], values = [], raw = []; + fields(end, function(field, wireType) { + if (field === 15 && wireType === 0) out.version = varint(); + else if (field === 1 && wireType === 2) out.name = text(); + else if (field === 5 && wireType === 0) out.extent = varint() || 4096; + else if (field === 3 && wireType === 2) keys.push(text()); + else if (field === 4 && wireType === 2) values.push(value(varint() + pos)); + else if (field === 2 && wireType === 2) { + var feature = { id: undefined, type: 0, tags: [], geometry: [] }; + fields(varint() + pos, function(f, w) { + if (f === 1 && w === 0) feature.id = varint(); + else if (f === 2 && w === 2) feature.tags = packed(); + else if (f === 3 && w === 0) feature.type = varint(); + else if (f === 4 && w === 2) feature.geometry = packed(); + else skip(w); + }); + raw.push(feature); + } + else skip(wireType); + }); + out.features = raw.map(function(f) { + var properties = {}; + for (var i = 0; i + 1 < f.tags.length; i += 2) { + var key = keys[f.tags[i]]; + if (key !== undefined && f.tags[i + 1] < values.length) properties[key] = values[f.tags[i + 1]]; + } + return { id: f.id, type: f.type, properties: properties, parts: geometry(f.geometry) }; + }); + return out; + } + + var layers = []; + fields(bytes.length, function(field, wireType) { + if (field === 3 && wireType === 2) layers.push(layer(varint() + pos)); + else skip(wireType); + }); + return { layers: layers }; +} + +function mvtLayerToGeoJson(layer, tile) { + var z = tile ? tile.z : 0, x0 = tile ? tile.x : 0, y0 = tile ? tile.y : 0; + var size = layer.extent * Math.pow(2, z); + function project(p) { + var lon = ((x0 * layer.extent + p[0]) / size) * 360 - 180; + var n = Math.PI * (1 - (2 * (y0 * layer.extent + p[1])) / size); + var lat = Math.atan((Math.exp(n) - Math.exp(-n)) / 2) * 180 / Math.PI; + return [Math.round(lon * 1e7) / 1e7, Math.round(lat * 1e7) / 1e7]; + } + function ringArea(ring) { + var sum = 0; + for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) { + sum += (ring[i][0] - ring[j][0]) * (ring[i][1] + ring[j][1]); + } + return sum; + } + function one(type, list) { + return list.length === 1 ? { type: type, coordinates: list[0] } : { type: 'Multi' + type, coordinates: list }; + } + var out = []; + layer.features.forEach(function(f) { + var geometry = null; + if (f.type === 1) { + var points = []; + f.parts.forEach(function(part) { part.forEach(function(p) { points.push(project(p)); }); }); + if (points.length) geometry = one('Point', points); + } else if (f.type === 2) { + var lines = f.parts.filter(function(p) { return p.length > 1; }).map(function(p) { return p.map(project); }); + if (lines.length) geometry = one('LineString', lines); + } else if (f.type === 3) { + // A ring wound like the first one starts a new polygon; the others are its holes. + var polygons = [], outerSign = 0; + f.parts.forEach(function(ring) { + var area = ringArea(ring); + if (!area || ring.length < 4) return; + if (!outerSign) outerSign = Math.sign(area); + if (Math.sign(area) === outerSign) polygons.push([ring.map(project)]); + else polygons[polygons.length - 1].push(ring.map(project)); + }); + if (polygons.length) geometry = one('Polygon', polygons); + } + if (geometry) out.push({ type: 'Feature', id: f.id, properties: f.properties, geometry: geometry }); + }); + return out; +} +`; diff --git a/src/resources/ui-apps/mvtPreviewHtml.ts b/src/resources/ui-apps/mvtPreviewHtml.ts new file mode 100644 index 00000000..42df33cc --- /dev/null +++ b/src/resources/ui-apps/mvtPreviewHtml.ts @@ -0,0 +1,613 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { MAPBOX_GL_VERSION } from './mapAppHtml.js'; +import { MVT_DECODER_JS } from './mvtDecoder.js'; + +/** + * HTML for the vector tile viewer (`mvt_preview_tool`), opened by ChatGPT for + * a `.mvt` or `.pbf` file. The page reads the file through the host + * (`resources/read` on the opaque URI it is given), decodes it and draws each + * layer in its own color, placed by the tile address in the file name or one + * the user enters. Outside a file entrypoint, a file picker does the same. + * File contents only go in through textContent. + */ +export function renderMvtPreviewHtml(params: { + publicToken: string; + glVersion?: string; +}): string { + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + // Safe inside " can survive the escaping. + const json = (value: unknown) => + JSON.stringify(value).replace(/ + + + + +Mapbox Vector Tile Viewer + + + +
+
+

Vector tile

+
Waiting for a .mvt or .pbf file…
+ +

Tile address

+
+ + +
+
+
+ + +
+ +

Feature

+
Click a feature to inspect it.
+
+
+ + + +`; +} diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index a4491e6f..01a5852d 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -25,11 +25,14 @@ export type ToolMeta = { }; /** * OpenAI MCP Extensions: lets users open the app in a conversation's side - * panel (`thread`). + * panel (`thread`), or as the viewer for files with the given extensions + * (`file`). * https://github.com/openai/mcp-extensions/blob/main/docs/spec.md#mcp-app-entrypoints */ 'openai/ui'?: { - entrypoints: Array<{ type: 'thread' }>; + entrypoints: Array< + { type: 'thread' } | { type: 'file'; extensions: string[] } + >; /** How a call from the model opens the app. In the SDK, not the spec. */ preferredModelDisplayMode?: 'inline' | 'fullscreen'; }; diff --git a/src/tools/mvt-preview-tool/MvtPreviewTool.input.schema.ts b/src/tools/mvt-preview-tool/MvtPreviewTool.input.schema.ts new file mode 100644 index 00000000..35186e19 --- /dev/null +++ b/src/tools/mvt-preview-tool/MvtPreviewTool.input.schema.ts @@ -0,0 +1,15 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +/** `FileInput` from the OpenAI MCP Extensions file entrypoint. */ +export const MvtPreviewInputSchema = z.object({ + file: z.object({ + name: z.string().min(1).describe('File name with its extension.'), + resourceUri: z + .string() + .min(1) + .describe('Opaque host URI the viewer reads the file through.') + }) +}); diff --git a/src/tools/mvt-preview-tool/MvtPreviewTool.output.schema.ts b/src/tools/mvt-preview-tool/MvtPreviewTool.output.schema.ts new file mode 100644 index 00000000..d7389512 --- /dev/null +++ b/src/tools/mvt-preview-tool/MvtPreviewTool.output.schema.ts @@ -0,0 +1,19 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const MvtPreviewOutputSchema = z.object({ + mvtPreview: z.literal(true), + file: z.object({ name: z.string(), resourceUri: z.string() }), + /** Tile address read from the file name, when it has one. */ + tile: z + .object({ + z: z.number().int(), + x: z.number().int(), + y: z.number().int() + }) + .optional() +}); + +export type MvtPreviewOutput = z.infer; diff --git a/src/tools/mvt-preview-tool/MvtPreviewTool.ts b/src/tools/mvt-preview-tool/MvtPreviewTool.ts new file mode 100644 index 00000000..f6718397 --- /dev/null +++ b/src/tools/mvt-preview-tool/MvtPreviewTool.ts @@ -0,0 +1,103 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { BaseTool, type ToolMeta } from '../BaseTool.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import { MVT_PREVIEW_URI } from '../../resources/ui-apps/MvtPreviewUIResource.js'; +import { mapAppCsp } from '../../resources/ui-apps/mapAppCsp.js'; +import { MvtPreviewInputSchema } from './MvtPreviewTool.input.schema.js'; +import { + MvtPreviewOutputSchema, + type MvtPreviewOutput +} from './MvtPreviewTool.output.schema.js'; + +export const MVT_EXTENSIONS = ['.mvt', '.pbf']; + +/** + * Reads a tile address from names such as `14-8800-5373.mvt` or + * `streets_14_8800_5373.pbf`: the last three numbers, as z, x, y. + */ +export function tileFromFileName( + name: string +): MvtPreviewOutput['tile'] | undefined { + const match = /(\d{1,2})[-_.](\d+)[-_.](\d+)\D*$/.exec(name); + if (!match) return undefined; + const [z, x, y] = match.slice(1).map(Number); + const size = 2 ** z; + if (z > 30 || x >= size || y >= size) return undefined; + return { z, x, y }; +} + +/** + * `mvt_preview_tool` — the viewer ChatGPT opens for a `.mvt` or `.pbf` file + * (OpenAI MCP Extensions file entrypoint). + * + * App-only: the host calls it with the file's name and an opaque resource URI. + * The tool never sees the file; the page reads it through the host with + * `resources/read` and decodes it. + */ +export class MvtPreviewTool extends BaseTool< + typeof MvtPreviewInputSchema, + typeof MvtPreviewOutputSchema +> { + readonly name = 'mvt_preview_tool'; + readonly description = + 'Open a Mapbox Vector Tile file (.mvt or .pbf) in the Mapbox tile viewer: its layers, fields and features on a map.'; + readonly annotations = { + title: 'Mapbox Vector Tile Viewer', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }; + readonly meta: ToolMeta; + + constructor() { + super({ + inputSchema: MvtPreviewInputSchema, + outputSchema: MvtPreviewOutputSchema + }); + this.meta = { + ui: { + resourceUri: MVT_PREVIEW_URI, + visibility: ['app'], + csp: mapAppCsp(MapboxApiBasedTool.mapboxApiEndpoint) + }, + 'openai/ui': { + entrypoints: [{ type: 'file', extensions: MVT_EXTENSIONS }] + } + }; + } + + async run(rawInput: unknown): Promise { + const parsed = MvtPreviewInputSchema.safeParse(rawInput); + if (!parsed.success) { + return { + content: [ + { + type: 'text', + text: 'mvt_preview_tool needs the file the host opened: { file: { name, resourceUri } }.' + } + ], + isError: true + }; + } + const { file } = parsed.data; + const tile = tileFromFileName(file.name); + const output: MvtPreviewOutput = { mvtPreview: true, file, tile }; + const where = tile + ? ` as tile ${tile.z}/${tile.x}/${tile.y}` + : ', with no tile address in its name'; + return { + content: [ + { + type: 'text', + text: `Opened ${file.name} in the Mapbox vector tile viewer${where}.` + } + ], + structuredContent: output, + isError: false + }; + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 7619b96a..9544232f 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -33,6 +33,7 @@ import { MatrixTool } from './matrix-tool/MatrixTool.js'; import { OptimizationTool } from './optimization-tool/OptimizationTool.js'; import { RenderMapTool } from './render-map-tool/RenderMapTool.js'; import { TilesetExplorerTool } from './tileset-explorer-tool/TilesetExplorerTool.js'; +import { MvtPreviewTool } from './mvt-preview-tool/MvtPreviewTool.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'; @@ -75,6 +76,7 @@ export const CORE_TOOLS = [ new OptimizationTool({ httpRequest }), new RenderMapTool({ httpRequest }), new TilesetExplorerTool({ httpRequest }), + new MvtPreviewTool(), new ReverseGeocodeTool({ httpRequest }), new StaticMapImageTool({ httpRequest }), new SearchAndGeocodeTool({ httpRequest }) diff --git a/test/resources/ui-apps/MvtPreviewUIResource.test.ts b/test/resources/ui-apps/MvtPreviewUIResource.test.ts new file mode 100644 index 00000000..3a3dc1b5 --- /dev/null +++ b/test/resources/ui-apps/MvtPreviewUIResource.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +// JWT with payload {"sub":"test","u":"testuser"} (base64) +const SK_TOKEN = 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + MvtPreviewUIResource, + MVT_PREVIEW_URI +} from '../../../src/resources/ui-apps/MvtPreviewUIResource.js'; +import { __resetMapboxPublicTokenCache } from '../../../src/utils/mapboxPublicToken.js'; +import { resourceTextOf } from '../../utils/resultTestUtils.js'; +import type { HttpRequest } from '../../../src/utils/types.js'; + +const authed = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + http: { authInfo: { token: SK_TOKEN } as any } + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +describe('MvtPreviewUIResource', () => { + beforeEach(() => { + __resetMapboxPublicTokenCache(); + delete process.env.MAPBOX_PUBLIC_TOKEN; + }); + + it('serves the viewer with the public token and opens it as a tab', async () => { + const httpRequest = vi.fn( + async () => + ({ + ok: true, + status: 200, + json: async () => [ + { + usage: 'pk', + default: true, + token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.pub' + } + ] + }) as unknown as Response + ); + + const result = await new MvtPreviewUIResource({ httpRequest }).read( + MVT_PREVIEW_URI, + authed + ); + + const entry = result.contents[0]; + expect(entry.uri).toBe(MVT_PREVIEW_URI); + expect(entry.mimeType).toBe('text/html;profile=mcp-app'); + const html = resourceTextOf(result); + expect(html).toContain('Mapbox Vector Tile Viewer'); + expect(html).toContain('"pk.eyJ1IjoidGVzdHVzZXIifQ.pub"'); + expect(html).not.toContain(SK_TOKEN); + expect(entry._meta).toMatchObject({ + ui: { + csp: { + connectDomains: [ + 'https://api.mapbox.com', + 'https://events.mapbox.com' + ], + resourceDomains: ['https://api.mapbox.com'], + workerDomains: ['blob:'] + } + }, + 'openai/ui': { + preferredDisplayMode: 'fullscreen', + availableDisplayModes: ['inline', 'fullscreen'] + } + }); + }); +}); diff --git a/test/resources/ui-apps/mvtDecoder.test.ts b/test/resources/ui-apps/mvtDecoder.test.ts new file mode 100644 index 00000000..fa2d5197 --- /dev/null +++ b/test/resources/ui-apps/mvtDecoder.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import * as vm from 'node:vm'; +import { MVT_DECODER_JS } from '../../../src/resources/ui-apps/mvtDecoder.js'; + +type Decoder = { + decodeMvt: (bytes: Uint8Array) => { + layers: Array<{ + name: string; + version: number; + extent: number; + features: Array<{ + id?: number; + type: number; + properties: Record; + parts: number[][][]; + }>; + }>; + }; + mvtLayerToGeoJson: ( + layer: unknown, + tile: { z: number; x: number; y: number } | null + ) => Array<{ + id?: number; + properties: Record; + geometry: { type: string; coordinates: unknown }; + }>; +}; + +function loadDecoder(): Decoder { + const context = vm.createContext({ DataView, TextDecoder, Uint8Array }); + vm.runInContext(MVT_DECODER_JS, context); + return context as unknown as Decoder; +} + +// --- A minimal protobuf writer for building test tiles ---------------------- +function varint(value: number | bigint): number[] { + // Negative values are written as 64-bit two's complement, as int64 is. + let n = BigInt.asUintN(64, BigInt(value)); + const out: number[] = []; + do { + let byte = Number(n & 0x7fn); + n >>= 7n; + if (n > 0n) byte |= 0x80; + out.push(byte); + } while (n > 0n); + return out; +} +const key = (field: number, wireType: number) => varint(field * 8 + wireType); +const vint = (field: number, value: number | bigint) => [ + ...key(field, 0), + ...varint(value) +]; +const bytesField = (field: number, bytes: number[]) => [ + ...key(field, 2), + ...varint(bytes.length), + ...bytes +]; +const str = (field: number, text: string) => + bytesField(field, [...new TextEncoder().encode(text)]); +const packed = (field: number, values: number[]) => + bytesField( + field, + values.flatMap((v) => varint(v)) + ); +function fixed(field: number, wireType: 1 | 5, value: number): number[] { + const buffer = new DataView(new ArrayBuffer(wireType === 1 ? 8 : 4)); + if (wireType === 1) buffer.setFloat64(0, value, true); + else buffer.setFloat32(0, value, true); + return [...key(field, wireType), ...new Uint8Array(buffer.buffer)]; +} +const zz = (n: number) => (n << 1) ^ (n >> 31); +const cmd = (id: number, count: number) => (id & 7) | (count << 3); +/** Geometry commands for MoveTo-started rings or lines, in absolute coordinates. */ +function path(parts: number[][][], close: boolean): number[] { + const out: number[] = []; + let x = 0; + let y = 0; + for (const part of parts) { + part.forEach(([px, py], i) => { + if (i === 0) out.push(cmd(1, 1)); + else if (i === 1) out.push(cmd(2, part.length - 1)); + out.push(zz(px - x), zz(py - y)); + x = px; + y = py; + }); + if (close) out.push(cmd(7, 1)); + } + return out; +} +const feature = ( + id: number | undefined, + type: number, + tags: number[], + geometry: number[] +) => [ + ...(id === undefined ? [] : vint(1, id)), + ...packed(2, tags), + ...vint(3, type), + ...packed(4, geometry) +]; + +const E = 4096; +const outer = [ + [0, 0], + [E, 0], + [E, E], + [0, E] +]; +const hole = [ + [1024, 1024], + [1024, 3072], + [3072, 3072], + [3072, 1024] +]; +const island = [ + [1536, 1536], + [2560, 1536], + [2560, 2560], + [1536, 2560] +]; + +const layer = [ + ...vint(15, 2), + ...str(1, 'places'), + ...vint(5, E), + ...str(3, 'name'), + ...str(3, 'rank'), + ...str(3, 'neg'), + ...str(3, 'ratio'), + ...str(3, 'scale'), + ...str(3, 'open'), + ...str(3, 'delta'), + ...bytesField(4, str(1, 'Helsinki')), + ...bytesField(4, vint(5, 3)), + ...bytesField(4, vint(4, -1)), + ...bytesField(4, fixed(3, 1, 0.5)), + ...bytesField(4, fixed(2, 5, 1.5)), + ...bytesField(4, vint(7, 1)), + ...bytesField(4, vint(6, zz(-2))), + ...bytesField( + 2, + feature(7, 1, [0, 0, 1, 1, 2, 2], path([[[E / 2, E / 2]]], false)) + ), + ...bytesField( + 2, + feature( + undefined, + 2, + [3, 3, 4, 4, 5, 5, 6, 6], + path( + [ + [ + [0, 0], + [E, 0], + [E, E] + ] + ], + false + ) + ) + ), + ...bytesField(2, feature(9, 3, [], path([outer, hole, island], true))) +]; +const tile = Uint8Array.from([...vint(99, 1), ...bytesField(3, layer)]); + +describe('MVT decoder', () => { + it('decodes layers, values of every type and geometry', () => { + const { layers } = loadDecoder().decodeMvt(tile); + expect(layers).toHaveLength(1); + const [places] = layers; + expect(places).toMatchObject({ name: 'places', version: 2, extent: E }); + expect(places.features.map((f) => [f.id, f.type])).toEqual([ + [7, 1], + [undefined, 2], + [9, 3] + ]); + expect(places.features[0].properties).toEqual({ + name: 'Helsinki', + rank: 3, + neg: -1 + }); + expect(places.features[1].properties).toEqual({ + ratio: 0.5, + scale: 1.5, + open: true, + delta: -2 + }); + expect(places.features[0].parts).toEqual([[[E / 2, E / 2]]]); + // ClosePath repeats the first point. + expect(places.features[2].parts[0]).toEqual([...outer, [0, 0]]); + }); + + it('converts to GeoJSON at the tile address', () => { + const decoder = loadDecoder(); + const [places] = decoder.decodeMvt(tile).layers; + const [point, line, polygon] = decoder.mvtLayerToGeoJson(places, { + z: 1, + x: 1, + y: 0 + }); + // The center of tile 1/1/0 is at 90°E on the Mercator latitude of y = 0.25. + expect(point.geometry).toEqual({ + type: 'Point', + coordinates: [90, 66.5132604] + }); + expect(point.id).toBe(7); + expect(line.geometry).toEqual({ + type: 'LineString', + coordinates: [ + [0, 85.0511288], + [180, 85.0511288], + [180, 0] + ] + }); + // A ring wound against the first is a hole; one wound like it is a new polygon. + expect(polygon.geometry.type).toBe('MultiPolygon'); + const polygons = polygon.geometry.coordinates as number[][][][]; + expect(polygons.map((rings) => rings.length)).toEqual([2, 1]); + }); + + it('places a tile with no address as 0/0/0', () => { + const decoder = loadDecoder(); + const [places] = decoder.decodeMvt(tile).layers; + const [point] = decoder.mvtLayerToGeoJson(places, null); + expect(point.geometry.coordinates).toEqual([0, 0]); + }); + + it('rejects bytes that are not a vector tile', () => { + const { decodeMvt } = loadDecoder(); + expect(() => decodeMvt(tile.subarray(0, tile.length - 3))).toThrow(); + expect(() => + decodeMvt(new TextEncoder().encode('{"type":"FeatureCollection"}')) + ).toThrow(); + }); +}); diff --git a/test/resources/ui-apps/mvtPreviewHtml.test.ts b/test/resources/ui-apps/mvtPreviewHtml.test.ts new file mode 100644 index 00000000..7098b2cf --- /dev/null +++ b/test/resources/ui-apps/mvtPreviewHtml.test.ts @@ -0,0 +1,25 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import * as vm from 'node:vm'; +import { renderMvtPreviewHtml } from '../../../src/resources/ui-apps/mvtPreviewHtml.js'; + +describe('renderMvtPreviewHtml', () => { + const html = renderMvtPreviewHtml({ publicToken: 'pk.test' }); + const script = /