diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0e6b2..dbd9d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ - **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` (#336).** 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 never sees the file. Opened from a file in the thread, 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. + - **Opened by the model:** asked to preview a tile, the model calls the tool with the file's absolute `path`. The page then asks ChatGPT to open that file (`openai/files/open`), which lands in the same viewer through the file entrypoint. The server instructions name `mvt_preview_tool` for `.mvt` and `.pbf` files; without that, Codex decoded the tile with shell commands. + - **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 d528079..232fc03 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. It opens when the user opens such a file from the thread, or when the model calls the tool with the file's absolute `path`; the page then asks ChatGPT to open that file in the viewer. + ### Utility Tools #### Resource Reader Tool diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 62c8241..ec14ac3 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 0000000..aff0298 --- /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 0000000..bb88074 --- /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 0000000..9235c9a --- /dev/null +++ b/src/resources/ui-apps/mvtPreviewHtml.ts @@ -0,0 +1,645 @@ +// 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 a4491e6..01a5852 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 0000000..b7a576f --- /dev/null +++ b/src/tools/mvt-preview-tool/MvtPreviewTool.input.schema.ts @@ -0,0 +1,31 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +/** + * Either `file`, the `FileInput` the host passes when it opens a file through + * the OpenAI MCP Extensions file entrypoint, or `path`, when the model asks to + * open a local file. + */ +export const MvtPreviewInputSchema = z.object({ + file: z + .object({ + name: z.string().min(1), + resourceUri: z.string().min(1) + }) + .optional() + .describe('Set by the host when it opens a file. Leave unset.'), + path: z + .string() + .trim() + .max(4096) + .regex( + /^\/.*\.(mvt|pbf)$/i, + 'Expected an absolute path ending in .mvt or .pbf' + ) + .optional() + .describe( + 'Absolute path of a local .mvt or .pbf file, for example /Users/me/Downloads/14-9326-4739.mvt.' + ) +}); 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 0000000..fce083e --- /dev/null +++ b/src/tools/mvt-preview-tool/MvtPreviewTool.output.schema.ts @@ -0,0 +1,22 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const MvtPreviewOutputSchema = z.object({ + mvtPreview: z.literal(true), + /** The file the host opened; the page reads it through the host. */ + file: z.object({ name: z.string(), resourceUri: z.string() }).optional(), + /** A local file the model asked for; the page asks the host to open it. */ + path: z.string().optional(), + /** 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 0000000..c166800 --- /dev/null +++ b/src/tools/mvt-preview-tool/MvtPreviewTool.ts @@ -0,0 +1,110 @@ +// 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). + * + * The host calls it with the file's name and an opaque resource URI; the page + * reads the file through the host with `resources/read` and decodes it. The + * model calls it with a local path instead, and the page asks the host to open + * that file (`openai/files/open`), which lands in the same viewer. The tool + * never reads the file. + */ +export class MvtPreviewTool extends BaseTool< + typeof MvtPreviewInputSchema, + typeof MvtPreviewOutputSchema +> { + readonly name = 'mvt_preview_tool'; + readonly description = + "Open a local Mapbox Vector Tile file (.mvt or .pbf) in the Mapbox vector tile viewer next to the chat: each layer drawn in its own color on a map, with feature counts, fields and properties. Pass the file's absolute path. Use it whenever the user wants to preview, view or inspect a vector tile file, instead of decoding the tile yourself. ChatGPT desktop only."; + 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, + csp: mapAppCsp(MapboxApiBasedTool.mapboxApiEndpoint) + }, + 'openai/ui': { + entrypoints: [{ type: 'file', extensions: MVT_EXTENSIONS }] + } + }; + } + + async run(rawInput: unknown): Promise { + const parsed = MvtPreviewInputSchema.safeParse(rawInput); + const input = parsed.success ? parsed.data : undefined; + if (!input || (!input.file && !input.path)) { + const reason = parsed.success + ? '' + : ` ${parsed.error.issues[0]?.message}.`; + return { + content: [ + { + type: 'text', + text: `mvt_preview_tool needs the absolute path of a local .mvt or .pbf file.${reason}` + } + ], + isError: true + }; + } + const name = input.file + ? input.file.name + : (input.path as string).split('/').pop() || ''; + const tile = tileFromFileName(name); + const output: MvtPreviewOutput = input.file + ? { mvtPreview: true, file: input.file, tile } + : { mvtPreview: true, path: input.path, tile }; + const where = tile + ? ` as tile ${tile.z}/${tile.x}/${tile.y}` + : ', with no tile address in its name'; + const text = input.file + ? `Opened ${name} in the Mapbox vector tile viewer${where}.` + : `Asked ChatGPT to open ${name} in the Mapbox vector tile viewer${where}. It opens as a tab next to the chat; the layers and features are shown there, not returned here.`; + return { + content: [{ type: 'text', text }], + structuredContent: output, + isError: false + }; + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 7619b96..9544232 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/src/utils/serverInstructions.ts b/src/utils/serverInstructions.ts index 610a41c..4d6c606 100644 --- a/src/utils/serverInstructions.ts +++ b/src/utils/serverInstructions.ts @@ -20,6 +20,11 @@ export function buildServerInstructions( 'Otherwise use render_map_tool: it renders an interactive Mapbox GL JS map.', 'When a tool result includes mapboxRender.ref, pass that ref unchanged in payload_refs (several refs can share one map). Never write or edit a ref by hand.' ]; + if (enabledToolNames.includes('mvt_preview_tool')) { + lines.push( + 'To preview, view or inspect a Mapbox Vector Tile file (.mvt or .pbf), call mvt_preview_tool with its absolute path. It opens the file in the Mapbox vector tile viewer. Do not decode the tile yourself or redraw it with render_map_tool.' + ); + } if (enabledToolNames.includes('static_map_image_tool')) { lines.push( 'Use static_map_image_tool only when the user explicitly asks for a static image file (PNG/JPEG), never as the default way to show a map.' diff --git a/test/resources/ui-apps/MvtPreviewUIResource.test.ts b/test/resources/ui-apps/MvtPreviewUIResource.test.ts new file mode 100644 index 0000000..3a3dc1b --- /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 0000000..fa2d519 --- /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 0000000..5abc9b6 --- /dev/null +++ b/test/resources/ui-apps/mvtPreviewHtml.test.ts @@ -0,0 +1,29 @@ +// 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 = /