Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof GetTilesetRecipeSchema>;
76 changes: 76 additions & 0 deletions src/tools/get-tileset-recipe-tool/GetTilesetRecipeTool.ts
Original file line number Diff line number Diff line change
@@ -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<CallToolResult> {
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<string, unknown>;

return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
structuredContent: data,
isError: false
};
}
}
Original file line number Diff line number Diff line change
@@ -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<typeof GetTilesetStatusSchema>;
97 changes: 97 additions & 0 deletions src/tools/get-tileset-status-tool/GetTilesetStatusTool.ts
Original file line number Diff line number Diff line change
@@ -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<CallToolResult> {
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<string, unknown>;

// 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
};
}
}
15 changes: 15 additions & 0 deletions src/tools/get-tileset-tool/GetTilesetTool.input.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof GetTilesetSchema>;
76 changes: 76 additions & 0 deletions src/tools/get-tileset-tool/GetTilesetTool.ts
Original file line number Diff line number Diff line change
@@ -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<CallToolResult> {
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<string, unknown>;

return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
structuredContent: data,
isError: false
};
}
}
34 changes: 34 additions & 0 deletions src/tools/list-tilesets-tool/ListTilesetsTool.input.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ListTilesetsSchema>;
Loading
Loading