From b00aecffba6ad902c2aa02a361689395d19faaa4 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 1 Apr 2026 14:16:50 -0400 Subject: [PATCH 1/2] chore: add CVE-2026-4926 entry to CHANGELOG Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f74b9..8c815e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +### Security + +- **CVE-2026-4926**: Upgraded `@modelcontextprotocol/sdk` to `^1.29.0`, resolving `path-to-regexp` to `8.4.1` and fixing the ReDoS vulnerability [GHSA-j3q9-mxjg-w52f](https://github.com/advisories/GHSA-j3q9-mxjg-w52f); regenerated output-validation patch for the new version + ### Public API - **Add `getAllTools` and `getVersionInfo` to public exports** — `getAllTools` is now re-exported from `@mapbox/mcp-devkit-server/tools` and `getVersionInfo` (plus `VersionInfo` type) from `@mapbox/mcp-devkit-server/utils`. These are needed by `hosted-mcp-server` to import server functionality via npm packages instead of submodule filesystem paths. From cd96295a55b7f27b91e6555915b1eada7a27d8d8 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 10:33:26 -0400 Subject: [PATCH 2/2] feat: MTS read-only tools (phase 1 of #115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new tools for inspecting Mapbox Tiling Service tilesets without needing tilesets:write scope: - list_tilesets_tool — list a user's tilesets with filter/pagination - get_tileset_tool — metadata for a single tileset - get_tileset_status_tool — publish job status (with optional job_id) - get_tileset_recipe_tool — retrieve the MTS recipe JSON All require only tilesets:read (already in devkit OAuth). The write side of MTS (upload source, create tileset, publish) is split into a follow-on PR that depends on a tilesets:write CFN scope change in hosted-mcp-server and a decision on how to apply the MCP tasks extension to the long-running publish jobs. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 + .../GetTilesetRecipeTool.input.schema.ts | 10 ++ .../GetTilesetRecipeTool.ts | 76 +++++++++++ .../GetTilesetStatusTool.input.schema.ts | 17 +++ .../GetTilesetStatusTool.ts | 97 ++++++++++++++ .../GetTilesetTool.input.schema.ts | 15 +++ src/tools/get-tileset-tool/GetTilesetTool.ts | 76 +++++++++++ .../ListTilesetsTool.input.schema.ts | 34 +++++ .../list-tilesets-tool/ListTilesetsTool.ts | 117 +++++++++++++++++ src/tools/toolRegistry.ts | 8 ++ .../tool-naming-convention.test.ts.snap | 20 +++ .../GetTilesetRecipeTool.test.ts | 50 ++++++++ .../GetTilesetStatusTool.test.ts | 60 +++++++++ .../get-tileset-tool/GetTilesetTool.test.ts | 47 +++++++ .../ListTilesetsTool.test.ts | 121 ++++++++++++++++++ 15 files changed, 752 insertions(+) create mode 100644 src/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.input.schema.ts create mode 100644 src/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.ts create mode 100644 src/tools/get-tileset-status-tool/GetTilesetStatusTool.input.schema.ts create mode 100644 src/tools/get-tileset-status-tool/GetTilesetStatusTool.ts create mode 100644 src/tools/get-tileset-tool/GetTilesetTool.input.schema.ts create mode 100644 src/tools/get-tileset-tool/GetTilesetTool.ts create mode 100644 src/tools/list-tilesets-tool/ListTilesetsTool.input.schema.ts create mode 100644 src/tools/list-tilesets-tool/ListTilesetsTool.ts create mode 100644 test/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.test.ts create mode 100644 test/tools/get-tileset-status-tool/GetTilesetStatusTool.test.ts create mode 100644 test/tools/get-tileset-tool/GetTilesetTool.test.ts create mode 100644 test/tools/list-tilesets-tool/ListTilesetsTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 89fc893..826b493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +### New Features + +- **MTS read-only tools (Phase 1 of #115)**: Four new tools for inspecting Mapbox tilesets — `list_tilesets_tool`, `get_tileset_tool`, `get_tileset_status_tool`, `get_tileset_recipe_tool`. All require only the `tilesets:read` scope (which devkit OAuth already grants). The write/publish/upload tools are intentionally split into a follow-on PR pending a `tilesets:write` scope expansion in `hosted-mcp-server` and a decision on how to leverage the MCP tasks extension for the long-running publish jobs. + ## 0.8.0 - 2026-05-05 ## 0.7.5 - 2026-05-05 diff --git a/src/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.input.schema.ts b/src/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.input.schema.ts new file mode 100644 index 0000000..c8426c8 --- /dev/null +++ b/src/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.input.schema.ts @@ -0,0 +1,10 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const GetTilesetRecipeSchema = z.object({ + tileset_id: z.string().min(1).describe('Tileset id in `username.id` form.') +}); + +export type GetTilesetRecipeInput = z.infer; diff --git a/src/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.ts b/src/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.ts new file mode 100644 index 0000000..6b7e0fc --- /dev/null +++ b/src/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.ts @@ -0,0 +1,76 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import { + GetTilesetRecipeSchema, + GetTilesetRecipeInput +} from './GetTilesetRecipeTool.input.schema.js'; + +// Docs: https://docs.mapbox.com/api/maps/mapbox-tiling-service/#retrieve-a-tilesets-recipe + +export class GetTilesetRecipeTool extends MapboxApiBasedTool< + typeof GetTilesetRecipeSchema +> { + readonly name = 'get_tileset_recipe_tool'; + readonly description = + 'Retrieve the MTS recipe JSON for a tileset. The recipe defines layers, sources, and processing steps. Useful for inspecting how a tileset was built or as a template for creating a new tileset. Requires the `tilesets:read` scope.'; + readonly annotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + title: 'Get Mapbox Tileset Recipe' + }; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: GetTilesetRecipeSchema, + httpRequest: params.httpRequest + }); + } + + protected async execute( + input: GetTilesetRecipeInput, + accessToken: string + ): Promise { + if (!accessToken) { + return { + isError: true, + content: [{ type: 'text', text: 'MAPBOX_ACCESS_TOKEN is not set' }] + }; + } + + if (!/^[A-Za-z0-9-]+\.[A-Za-z0-9-]+$/.test(input.tileset_id)) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Invalid tileset_id "${input.tileset_id}". Expected "username.id" format.` + } + ] + }; + } + + const url = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}tilesets/v1/${input.tileset_id}/recipe` + ); + url.searchParams.set('access_token', accessToken); + + const response = await this.httpRequest(url.toString(), { method: 'GET' }); + if (!response.ok) { + return this.handleApiError(response, 'get tileset recipe'); + } + + const data = (await response.json()) as Record; + + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: data, + isError: false + }; + } +} diff --git a/src/tools/get-tileset-status-tool/GetTilesetStatusTool.input.schema.ts b/src/tools/get-tileset-status-tool/GetTilesetStatusTool.input.schema.ts new file mode 100644 index 0000000..799d5de --- /dev/null +++ b/src/tools/get-tileset-status-tool/GetTilesetStatusTool.input.schema.ts @@ -0,0 +1,17 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const GetTilesetStatusSchema = z.object({ + tileset_id: z.string().min(1).describe('Tileset id in `username.id` form.'), + job_id: z + .string() + .min(1) + .optional() + .describe( + "Specific publish job id to fetch. If omitted, returns the tileset's most recent job status summary." + ) +}); + +export type GetTilesetStatusInput = z.infer; diff --git a/src/tools/get-tileset-status-tool/GetTilesetStatusTool.ts b/src/tools/get-tileset-status-tool/GetTilesetStatusTool.ts new file mode 100644 index 0000000..2b65c04 --- /dev/null +++ b/src/tools/get-tileset-status-tool/GetTilesetStatusTool.ts @@ -0,0 +1,97 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import { + GetTilesetStatusSchema, + GetTilesetStatusInput +} from './GetTilesetStatusTool.input.schema.js'; + +// Docs: +// https://docs.mapbox.com/api/maps/mapbox-tiling-service/#retrieve-tileset-status +// https://docs.mapbox.com/api/maps/mapbox-tiling-service/#retrieve-a-publish-job + +export class GetTilesetStatusTool extends MapboxApiBasedTool< + typeof GetTilesetStatusSchema +> { + readonly name = 'get_tileset_status_tool'; + readonly description = + "Get the publish job status for a Mapbox tileset. With just `tileset_id`, returns the most recent job's status summary (`queued` / `processing` / `success` / `failed`) and any errors. With `job_id`, returns the full detail for that specific job. Useful for polling a publish in progress. Requires the `tilesets:read` scope."; + readonly annotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + title: 'Get Mapbox Tileset Job Status' + }; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: GetTilesetStatusSchema, + httpRequest: params.httpRequest + }); + } + + protected async execute( + input: GetTilesetStatusInput, + accessToken: string + ): Promise { + if (!accessToken) { + return { + isError: true, + content: [{ type: 'text', text: 'MAPBOX_ACCESS_TOKEN is not set' }] + }; + } + + if (!/^[A-Za-z0-9-]+\.[A-Za-z0-9-]+$/.test(input.tileset_id)) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Invalid tileset_id "${input.tileset_id}". Expected "username.id" format.` + } + ] + }; + } + + const path = input.job_id + ? `tilesets/v1/${input.tileset_id}/jobs/${input.job_id}` + : `tilesets/v1/${input.tileset_id}/status`; + + const url = new URL(`${MapboxApiBasedTool.mapboxApiEndpoint}${path}`); + url.searchParams.set('access_token', accessToken); + + const response = await this.httpRequest(url.toString(), { method: 'GET' }); + if (!response.ok) { + return this.handleApiError( + response, + input.job_id ? 'get tileset job' : 'get tileset status' + ); + } + + const data = (await response.json()) as Record; + + // Status endpoint returns { status, jobs }; job endpoint returns full job + const status = + typeof data.stage === 'string' + ? data.stage + : typeof data.status === 'string' + ? data.status + : undefined; + const summary = status + ? `Tileset status: ${status}` + : 'Tileset status retrieved.'; + + return { + content: [ + { type: 'text', text: summary }, + { type: 'text', text: JSON.stringify(data, null, 2) } + ], + structuredContent: data, + isError: false + }; + } +} diff --git a/src/tools/get-tileset-tool/GetTilesetTool.input.schema.ts b/src/tools/get-tileset-tool/GetTilesetTool.input.schema.ts new file mode 100644 index 0000000..5ed71a3 --- /dev/null +++ b/src/tools/get-tileset-tool/GetTilesetTool.input.schema.ts @@ -0,0 +1,15 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const GetTilesetSchema = z.object({ + tileset_id: z + .string() + .min(1) + .describe( + 'Tileset id in `username.id` form (e.g. "examples.ckk4xyzc1014t17pmweqib5kf").' + ) +}); + +export type GetTilesetInput = z.infer; diff --git a/src/tools/get-tileset-tool/GetTilesetTool.ts b/src/tools/get-tileset-tool/GetTilesetTool.ts new file mode 100644 index 0000000..af7910d --- /dev/null +++ b/src/tools/get-tileset-tool/GetTilesetTool.ts @@ -0,0 +1,76 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import { + GetTilesetSchema, + GetTilesetInput +} from './GetTilesetTool.input.schema.js'; + +// Docs: https://docs.mapbox.com/api/maps/mapbox-tiling-service/#retrieve-tileset-information + +export class GetTilesetTool extends MapboxApiBasedTool< + typeof GetTilesetSchema +> { + readonly name = 'get_tileset_tool'; + readonly description = + 'Retrieve metadata for a single Mapbox tileset by id (`username.id`). Returns information like name, visibility, created/modified timestamps, and bounds. Requires the `tilesets:read` scope.'; + readonly annotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + title: 'Get Mapbox Tileset' + }; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: GetTilesetSchema, + httpRequest: params.httpRequest + }); + } + + protected async execute( + input: GetTilesetInput, + accessToken: string + ): Promise { + if (!accessToken) { + return { + isError: true, + content: [{ type: 'text', text: 'MAPBOX_ACCESS_TOKEN is not set' }] + }; + } + + if (!/^[A-Za-z0-9-]+\.[A-Za-z0-9-]+$/.test(input.tileset_id)) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Invalid tileset_id "${input.tileset_id}". Expected "username.id" format.` + } + ] + }; + } + + const url = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}tilesets/v1/${input.tileset_id}` + ); + url.searchParams.set('access_token', accessToken); + + const response = await this.httpRequest(url.toString(), { method: 'GET' }); + if (!response.ok) { + return this.handleApiError(response, 'get tileset'); + } + + const data = (await response.json()) as Record; + + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: data, + isError: false + }; + } +} diff --git a/src/tools/list-tilesets-tool/ListTilesetsTool.input.schema.ts b/src/tools/list-tilesets-tool/ListTilesetsTool.input.schema.ts new file mode 100644 index 0000000..55c1542 --- /dev/null +++ b/src/tools/list-tilesets-tool/ListTilesetsTool.input.schema.ts @@ -0,0 +1,34 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const ListTilesetsSchema = z.object({ + type: z + .enum(['raster', 'vector']) + .optional() + .describe('Filter tilesets by type: "raster" or "vector".'), + visibility: z + .enum(['public', 'private']) + .optional() + .describe('Filter tilesets by visibility.'), + sortby: z + .enum(['created', 'modified']) + .optional() + .describe('Sort tilesets by created or modified timestamp.'), + limit: z + .number() + .int() + .min(1) + .max(500) + .optional() + .describe('Maximum number of tilesets to return (1-500). Default 100.'), + start: z + .string() + .optional() + .describe( + "Tileset id to start pagination from (from a prior response's Link header)." + ) +}); + +export type ListTilesetsInput = z.infer; diff --git a/src/tools/list-tilesets-tool/ListTilesetsTool.ts b/src/tools/list-tilesets-tool/ListTilesetsTool.ts new file mode 100644 index 0000000..162a64d --- /dev/null +++ b/src/tools/list-tilesets-tool/ListTilesetsTool.ts @@ -0,0 +1,117 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; +import { + ListTilesetsSchema, + ListTilesetsInput +} from './ListTilesetsTool.input.schema.js'; + +// Docs: https://docs.mapbox.com/api/maps/mapbox-tiling-service/#list-tilesets + +export class ListTilesetsTool extends MapboxApiBasedTool< + typeof ListTilesetsSchema +> { + readonly name = 'list_tilesets_tool'; + readonly description = + 'List Mapbox tilesets for the authenticated user. Supports filtering by type (raster/vector), visibility (public/private), sorting, and pagination. Requires the `tilesets:list` scope on the access token.'; + readonly annotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + title: 'List Mapbox Tilesets' + }; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: ListTilesetsSchema, + httpRequest: params.httpRequest + }); + } + + protected async execute( + input: ListTilesetsInput, + accessToken: string + ): Promise { + if (!accessToken) { + return { + isError: true, + content: [{ type: 'text', text: 'MAPBOX_ACCESS_TOKEN is not set' }] + }; + } + + let userName: string; + try { + userName = getUserNameFromToken(accessToken); + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Invalid access token: ${(error as Error).message}` + } + ] + }; + } + + const url = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}tilesets/v1/${userName}` + ); + url.searchParams.set('access_token', accessToken); + if (input.type) url.searchParams.set('type', input.type); + if (input.visibility) url.searchParams.set('visibility', input.visibility); + if (input.sortby) url.searchParams.set('sortby', input.sortby); + if (input.limit !== undefined) + url.searchParams.set('limit', String(input.limit)); + if (input.start) url.searchParams.set('start', input.start); + + const response = await this.httpRequest(url.toString(), { method: 'GET' }); + if (!response.ok) { + return this.handleApiError(response, 'list tilesets'); + } + + const data = (await response.json()) as unknown; + const linkHeader = response.headers.get('Link'); + const nextStart = linkHeader ? extractNextStart(linkHeader) : undefined; + + const tilesets = Array.isArray(data) ? data : []; + const summary = + tilesets.length === 0 + ? 'No tilesets found.' + : `Found ${tilesets.length} tileset${tilesets.length === 1 ? '' : 's'}.` + + (nextStart ? ` More available; next_start: ${nextStart}` : ''); + + return { + content: [ + { type: 'text', text: summary }, + { + type: 'text', + text: JSON.stringify({ tilesets, next_start: nextStart }, null, 2) + } + ], + structuredContent: { + tilesets, + count: tilesets.length, + next_start: nextStart + }, + isError: false + }; + } +} + +function extractNextStart(linkHeader: string): string | undefined { + // Link: ; rel="next" + const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/); + if (!match) return undefined; + try { + const url = new URL(match[1]); + return url.searchParams.get('start') ?? undefined; + } catch { + return undefined; + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 5e9237d..c019989 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -10,6 +10,10 @@ import { CreateStyleTool } from './create-style-tool/CreateStyleTool.js'; import { CreateTokenTool } from './create-token-tool/CreateTokenTool.js'; import { DeleteStyleTool } from './delete-style-tool/DeleteStyleTool.js'; import { GetFeedbackTool } from './get-feedback-tool/GetFeedbackTool.js'; +import { GetTilesetTool } from './get-tileset-tool/GetTilesetTool.js'; +import { GetTilesetRecipeTool } from './get-tileset-recipe-tool/GetTilesetRecipeTool.js'; +import { GetTilesetStatusTool } from './get-tileset-status-tool/GetTilesetStatusTool.js'; +import { ListTilesetsTool } from './list-tilesets-tool/ListTilesetsTool.js'; import { ListFeedbackTool } from './list-feedback-tool/ListFeedbackTool.js'; import { GeojsonPreviewTool } from './geojson-preview-tool/GeojsonPreviewTool.js'; import { ListStylesTool } from './list-styles-tool/ListStylesTool.js'; @@ -51,6 +55,10 @@ export const CORE_TOOLS = [ new GetFeedbackTool({ httpRequest }), new ListFeedbackTool({ httpRequest }), new TilequeryTool({ httpRequest }), + new ListTilesetsTool({ httpRequest }), + new GetTilesetTool({ httpRequest }), + new GetTilesetStatusTool({ httpRequest }), + new GetTilesetRecipeTool({ httpRequest }), new ValidateExpressionTool(), new ValidateGeojsonTool(), new ValidateStyleTool() diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index e8e0c2e..9dab48b 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -52,6 +52,21 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot "description": "Get a single user feedback item from the Mapbox Feedback API by its unique ID. Use this tool to retrieve detailed information about a specific user-reported issue, suggestion, or feedback about map data, routing, or POI details. Requires user-feedback:read scope on the access token.", "toolName": "get_feedback_tool", }, + { + "className": "GetTilesetRecipeTool", + "description": "Retrieve the MTS recipe JSON for a tileset. The recipe defines layers, sources, and processing steps. Useful for inspecting how a tileset was built or as a template for creating a new tileset. Requires the \`tilesets:read\` scope.", + "toolName": "get_tileset_recipe_tool", + }, + { + "className": "GetTilesetStatusTool", + "description": "Get the publish job status for a Mapbox tileset. With just \`tileset_id\`, returns the most recent job's status summary (\`queued\` / \`processing\` / \`success\` / \`failed\`) and any errors. With \`job_id\`, returns the full detail for that specific job. Useful for polling a publish in progress. Requires the \`tilesets:read\` scope.", + "toolName": "get_tileset_status_tool", + }, + { + "className": "GetTilesetTool", + "description": "Retrieve metadata for a single Mapbox tileset by id (\`username.id\`). Returns information like name, visibility, created/modified timestamps, and bounds. Requires the \`tilesets:read\` scope.", + "toolName": "get_tileset_tool", + }, { "className": "ListFeedbackTool", "description": "List user feedback items from the Mapbox Feedback API with filtering, sorting, and pagination. Use this tool to access user-reported issues, suggestions, and feedback about map data, routing, and POI details. Supports comprehensive filtering by status, category, date ranges, trace IDs, and search text. Requires user-feedback:read scope on the access token.", @@ -62,6 +77,11 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot "description": "List styles for a Mapbox account. Use limit parameter to avoid large responses (recommended: limit=5-10). Use start parameter for pagination.", "toolName": "list_styles_tool", }, + { + "className": "ListTilesetsTool", + "description": "List Mapbox tilesets for the authenticated user. Supports filtering by type (raster/vector), visibility (public/private), sorting, and pagination. Requires the \`tilesets:list\` scope on the access token.", + "toolName": "list_tilesets_tool", + }, { "className": "ListTokensTool", "description": "List Mapbox access tokens for the authenticated user with optional filtering and pagination. Returns metadata for all tokens (public and secret), but the actual token value is only included for public tokens (secret token values are omitted for security). When using pagination, the "start" parameter must be obtained from the "next_start" field of the previous response (it is not a token ID)", diff --git a/test/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.test.ts b/test/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.test.ts new file mode 100644 index 0000000..6f4c828 --- /dev/null +++ b/test/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; +import { GetTilesetRecipeTool } from '../../../src/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.js'; + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + +const fakeRecipe = { + version: 1, + layers: { + points: { + source: 'mapbox://tileset-source/testuser/points', + minzoom: 0, + maxzoom: 5 + } + } +}; + +describe('GetTilesetRecipeTool', () => { + afterEach(() => vi.restoreAllMocks()); + + it('fetches a recipe by tileset id', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => fakeRecipe + } as Response); + + const tool = new GetTilesetRecipeTool({ httpRequest }); + tool['log'] = vi.fn(); + + const result = await tool.run({ tileset_id: 'testuser.abc' }); + + expect(result.isError).toBe(false); + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('tilesets/v1/testuser.abc/recipe'); + }); + + it('rejects malformed tileset_id', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const tool = new GetTilesetRecipeTool({ httpRequest }); + tool['log'] = vi.fn(); + + const result = await tool.run({ tileset_id: 'nope' }); + + expect(result.isError).toBe(true); + expect(mockHttpRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/test/tools/get-tileset-status-tool/GetTilesetStatusTool.test.ts b/test/tools/get-tileset-status-tool/GetTilesetStatusTool.test.ts new file mode 100644 index 0000000..176fc87 --- /dev/null +++ b/test/tools/get-tileset-status-tool/GetTilesetStatusTool.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; +import { GetTilesetStatusTool } from '../../../src/tools/get-tileset-status-tool/GetTilesetStatusTool.js'; + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + +describe('GetTilesetStatusTool', () => { + afterEach(() => vi.restoreAllMocks()); + + it('hits the /status endpoint when only tileset_id is given', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => ({ status: 'success', jobs: [] }) + } as Response); + + const tool = new GetTilesetStatusTool({ httpRequest }); + tool['log'] = vi.fn(); + + const result = await tool.run({ tileset_id: 'testuser.abc' }); + + expect(result.isError).toBe(false); + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('tilesets/v1/testuser.abc/status'); + expect(calledUrl).not.toContain('/jobs/'); + }); + + it('hits the /jobs/{job_id} endpoint when job_id is provided', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => ({ id: 'job123', stage: 'processing' }) + } as Response); + + const tool = new GetTilesetStatusTool({ httpRequest }); + tool['log'] = vi.fn(); + + const result = await tool.run({ + tileset_id: 'testuser.abc', + job_id: 'job123' + }); + + expect(result.isError).toBe(false); + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('tilesets/v1/testuser.abc/jobs/job123'); + const summary = (result.content[0] as { type: 'text'; text: string }).text; + expect(summary).toContain('processing'); + }); + + it('rejects malformed tileset_id', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const tool = new GetTilesetStatusTool({ httpRequest }); + tool['log'] = vi.fn(); + + const result = await tool.run({ tileset_id: 'bogus' }); + + expect(result.isError).toBe(true); + expect(mockHttpRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/test/tools/get-tileset-tool/GetTilesetTool.test.ts b/test/tools/get-tileset-tool/GetTilesetTool.test.ts new file mode 100644 index 0000000..ec3705e --- /dev/null +++ b/test/tools/get-tileset-tool/GetTilesetTool.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; +import { GetTilesetTool } from '../../../src/tools/get-tileset-tool/GetTilesetTool.js'; + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + +const fakeTileset = { + id: 'testuser.abc', + name: 'Example Tileset', + type: 'vector', + visibility: 'private', + bounds: [-180, -85, 180, 85] +}; + +describe('GetTilesetTool', () => { + afterEach(() => vi.restoreAllMocks()); + + it('fetches a tileset by id', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => fakeTileset + } as Response); + + const tool = new GetTilesetTool({ httpRequest }); + tool['log'] = vi.fn(); + + const result = await tool.run({ tileset_id: 'testuser.abc' }); + + expect(result.isError).toBe(false); + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('tilesets/v1/testuser.abc'); + }); + + it('rejects malformed tileset_id', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const tool = new GetTilesetTool({ httpRequest }); + tool['log'] = vi.fn(); + + const result = await tool.run({ tileset_id: 'not-a-real-id' }); + + expect(result.isError).toBe(true); + expect(mockHttpRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/test/tools/list-tilesets-tool/ListTilesetsTool.test.ts b/test/tools/list-tilesets-tool/ListTilesetsTool.test.ts new file mode 100644 index 0000000..34b88c0 --- /dev/null +++ b/test/tools/list-tilesets-tool/ListTilesetsTool.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; +import { ListTilesetsTool } from '../../../src/tools/list-tilesets-tool/ListTilesetsTool.js'; + +const tokenPayload = Buffer.from(JSON.stringify({ u: 'testuser' })).toString( + 'base64' +); +process.env.MAPBOX_ACCESS_TOKEN = `eyJhbGciOiJIUzI1NiJ9.${tokenPayload}.signature`; + +type TextContent = { type: 'text'; text: string }; + +const fakeTilesets = [ + { + id: 'testuser.abc', + name: 'Example Tileset', + type: 'vector', + visibility: 'private', + created: '2026-01-01T00:00:00.000Z' + }, + { + id: 'testuser.def', + name: 'Other Tileset', + type: 'raster', + visibility: 'public', + created: '2026-02-01T00:00:00.000Z' + } +]; + +describe('ListTilesetsTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns tilesets for the authenticated user', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + headers: new Headers(), + json: async () => fakeTilesets + } as Response); + + const tool = new ListTilesetsTool({ httpRequest }); + tool['log'] = vi.fn(); + + const result = await tool.run({}); + + expect(result.isError).toBe(false); + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('tilesets/v1/testuser'); + + const payload = JSON.parse((result.content[1] as TextContent).text); + expect(payload.tilesets).toHaveLength(2); + }); + + it('forwards filter + pagination params', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + headers: new Headers(), + json: async () => [] + } as Response); + + const tool = new ListTilesetsTool({ httpRequest }); + tool['log'] = vi.fn(); + + await tool.run({ + type: 'vector', + visibility: 'private', + sortby: 'created', + limit: 50, + start: 'testuser.xyz' + }); + + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('type=vector'); + expect(calledUrl).toContain('visibility=private'); + expect(calledUrl).toContain('sortby=created'); + expect(calledUrl).toContain('limit=50'); + expect(calledUrl).toContain('start=testuser.xyz'); + }); + + it('extracts next_start from Link header for pagination', async () => { + const headers = new Headers({ + Link: '; rel="next"' + }); + + const { httpRequest } = setupHttpRequest({ + headers, + json: async () => fakeTilesets + } as Response); + + const tool = new ListTilesetsTool({ httpRequest }); + tool['log'] = vi.fn(); + + const result = await tool.run({}); + + const structured = ( + result as unknown as { + structuredContent?: { next_start?: string }; + } + ).structuredContent; + expect(structured?.next_start).toBe('testuser.next'); + }); + + it('surfaces non-2xx responses as errors', async () => { + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers(), + json: async () => ({ message: 'Insufficient scope' }), + text: async () => '{"message":"Insufficient scope"}' + } as Response); + + const tool = new ListTilesetsTool({ httpRequest }); + tool['log'] = vi.fn(); + + const result = await tool.run({}); + expect(result.isError).toBe(true); + }); +});