Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
22 changes: 0 additions & 22 deletions src/tools/BaseTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,26 +135,4 @@ export abstract class BaseTool<
this.server.server.sendLoggingMessage({ level, data });
}
}

/**
* Validates output data against the output schema.
* If validation fails, logs a warning and returns the raw data instead of throwing an error.
* This allows tools to continue functioning when API responses deviate from expected schemas.
*/
protected validateOutput<T>(
schema: ZodTypeAny,
rawData: unknown,
toolName: string
): T {
try {
return schema.parse(rawData) as T;
} catch (validationError) {
this.log(
'warning',
`${toolName}: Output schema validation failed - ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}`
);
// Graceful fallback to raw data
return rawData as T;
}
}
}
9 changes: 7 additions & 2 deletions src/tools/MapboxApiBasedTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import { context, trace, SpanStatusCode } from '@opentelemetry/api';
import type { ToolExecutionContext } from '../utils/tracing.js';
import { createToolExecutionContext } from '../utils/tracing.js';

/** Remove access_token query parameter values from strings before logging or returning to callers. */
export function redactToken(s: string): string {
return s.replace(/access_token=[^&\s#"']+/g, 'access_token=***');
}

/**
* Standard error response format from Mapbox API
*/
Expand Down Expand Up @@ -126,8 +131,8 @@ export abstract class MapboxApiBasedTool<
toolContext.span.end();
return result;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const rawMessage = error instanceof Error ? error.message : String(error);
const errorMessage = redactToken(rawMessage);
this.log(
'error',
`${this.name}: Error during execution: ${errorMessage}`
Expand Down
20 changes: 14 additions & 6 deletions src/tools/create-token-tool/CreateTokenTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,20 @@ export class CreateTokenTool extends MapboxApiBasedTool<

const rawData = await response.json();

// Validate response against schema with graceful fallback
const data = this.validateOutput<Record<string, unknown>>(
CreateTokenOutputSchema,
rawData,
'CreateTokenTool'
);
let data;
try {
data = CreateTokenOutputSchema.parse(rawData);
} catch {
return {
isError: true,
content: [
{
type: 'text',
text: 'Unexpected API response format from Mapbox API'
}
]
};
}

this.log('info', `CreateTokenTool: Successfully created token`);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { z } from 'zod';
import { styleIdSchema } from '../shared/styleId.schema.js';

export const DeleteStyleSchema = z.object({
styleId: z.string().describe('Style ID to delete')
styleId: styleIdSchema.describe('Style ID to delete')
});

export type DeleteStyleInput = z.infer<typeof DeleteStyleSchema>;
2 changes: 1 addition & 1 deletion src/tools/delete-style-tool/DeleteStyleTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class DeleteStyleTool extends MapboxApiBasedTool<
_context: ToolExecutionContext
): Promise<CallToolResult> {
const username = getUserNameFromToken(accessToken);
const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`;
const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodeURIComponent(username)}/${encodeURIComponent(input.styleId)}?access_token=${accessToken}`;

const response = await this.httpRequest(url, {
method: 'DELETE'
Expand Down
20 changes: 14 additions & 6 deletions src/tools/list-styles-tool/ListStylesTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,20 @@ export class ListStylesTool extends MapboxApiBasedTool<

const rawData = await response.json();

// Validate the API response (which is an array) with graceful fallback
const validatedData = this.validateOutput(
StylesArraySchema,
rawData,
'ListStylesTool'
);
let validatedData;
try {
validatedData = StylesArraySchema.parse(rawData);
} catch {
return {
isError: true,
content: [
{
type: 'text',
text: 'Unexpected API response format from Mapbox API'
}
]
};
}

this.log('info', `ListStylesTool: Successfully listed styles`);

Expand Down
68 changes: 42 additions & 26 deletions src/tools/list-tokens-tool/ListTokensTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type { HttpRequest } from '../../utils/types.js';
import type { ToolExecutionContext } from '../../utils/tracing.js';
import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js';
import { MapboxApiBasedTool, redactToken } from '../MapboxApiBasedTool.js';
import {
ListTokensSchema,
ListTokensInput
Expand Down Expand Up @@ -110,7 +110,7 @@ export class ListTokensTool extends MapboxApiBasedTool<
while (url) {
pageCount++;
this.log('info', `ListTokensTool: Fetching page ${pageCount}`);
this.log('debug', `ListTokensTool: Fetching URL: ${url}`);
this.log('debug', `ListTokensTool: Fetching URL: ${redactToken(url)}`);

const response = await this.httpRequest(url, {
method: 'GET',
Expand All @@ -130,12 +130,20 @@ export class ListTokensTool extends MapboxApiBasedTool<
? data
: (data as { tokens?: unknown[] }).tokens || [];

// Validate tokens array against TokenObjectSchema with graceful fallback
const validatedTokens = this.validateOutput<unknown[]>(
TokenObjectSchema.array(),
tokens,
'ListTokensTool'
);
let validatedTokens;
try {
validatedTokens = TokenObjectSchema.array().parse(tokens);
} catch {
return {
isError: true,
content: [
{
type: 'text',
text: 'Unexpected API response format from Mapbox API'
}
]
};
}

allTokens.push(...validatedTokens);
this.log(
Expand All @@ -149,26 +157,34 @@ export class ListTokensTool extends MapboxApiBasedTool<
if (linkHeader) {
const links = this.parseLinkHeader(linkHeader);
if (links.next) {
// Ensure the next URL includes the access token
const nextUrl = new URL(links.next);
if (!nextUrl.searchParams.has('access_token')) {
nextUrl.searchParams.append('access_token', accessToken);
}
const nextUrlString = nextUrl.toString();

if (shouldAutoPaginate) {
url = nextUrlString;
this.log('info', `ListTokensTool: Next page available: ${url}`);
// Reject cross-origin Link headers to prevent token exfiltration
const apiOrigin = new URL(ListTokensTool.mapboxApiEndpoint).origin;
if (nextUrl.origin !== apiOrigin) {
this.log(
'warning',
`ListTokensTool: Refusing cross-origin Link header: ${nextUrl.origin}`
);
} else {
// For manual pagination, extract the start parameter from the next URL
const nextUrl = new URL(links.next);
const startParam = nextUrl.searchParams.get('start');
if (startParam) {
nextPageUrl = startParam;
this.log(
'info',
`ListTokensTool: Next page start token saved for manual pagination: ${nextPageUrl}`
);
// Ensure the next URL includes the access token
if (!nextUrl.searchParams.has('access_token')) {
nextUrl.searchParams.append('access_token', accessToken);
}
const nextUrlString = nextUrl.toString();

if (shouldAutoPaginate) {
url = nextUrlString;
this.log('info', 'ListTokensTool: Next page available');
} else {
// For manual pagination, extract the start parameter from the next URL
const startParam = nextUrl.searchParams.get('start');
if (startParam) {
nextPageUrl = startParam;
this.log(
'info',
'ListTokensTool: Next page start token saved for manual pagination'
);
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { z } from 'zod';
import { styleIdSchema } from '../shared/styleId.schema.js';

export const PreviewStyleSchema = z.object({
styleId: z.string().describe('Style ID to preview'),
styleId: styleIdSchema.describe('Style ID to preview'),
accessToken: z
.string()
.startsWith(
Expand Down
4 changes: 2 additions & 2 deletions src/tools/preview-style-tool/PreviewStyleTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
const hashFragment =
hashParams.length > 0 ? `#${hashParams.join('/')}` : '';

const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${userName}/${input.styleId}.html?${params.toString()}${hashFragment}`;
const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodeURIComponent(userName)}/${encodeURIComponent(input.styleId)}.html?${params.toString()}${hashFragment}`;

// Build content array with URL
const content: CallToolResult['content'] = [
Expand All @@ -86,7 +86,7 @@ export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {

// Add MCP-UI resource (for legacy MCP-UI clients)
const uiResource = createUIResource({
uri: `ui://mapbox/preview-style/${userName}/${input.styleId}`,
uri: `ui://mapbox/preview-style/${encodeURIComponent(userName)}/${encodeURIComponent(input.styleId)}`,
content: {
type: 'externalUrl',
iframeUrl: url
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { z } from 'zod';
import { styleIdSchema } from '../shared/styleId.schema.js';

export const RetrieveStyleSchema = z.object({
styleId: z.string().describe('Style ID to retrieve')
styleId: styleIdSchema.describe('Style ID to retrieve')
});

export type RetrieveStyleInput = z.infer<typeof RetrieveStyleSchema>;
24 changes: 15 additions & 9 deletions src/tools/retrieve-style-tool/RetrieveStyleTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class RetrieveStyleTool extends MapboxApiBasedTool<
_context: ToolExecutionContext
): Promise<CallToolResult> {
const username = getUserNameFromToken(accessToken);
const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`;
const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodeURIComponent(username)}/${encodeURIComponent(input.styleId)}?access_token=${accessToken}`;

const response = await this.httpRequest(url);

Expand All @@ -57,16 +57,22 @@ export class RetrieveStyleTool extends MapboxApiBasedTool<
let data: MapboxStyleOutput;
try {
data = MapboxStyleOutputSchema.parse(rawData);
} catch (validationError) {
this.log(
'warning',
`Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}`
);
// Graceful fallback to raw data
data = rawData as MapboxStyleOutput;
} catch {
return {
isError: true,
content: [
{
type: 'text',
text: 'Unexpected API response format from Mapbox API'
Comment thread
zmofei marked this conversation as resolved.
Outdated
}
]
};
}

this.log('info', `UpdateStyleTool: Successfully updated style ${data.id}`);
this.log(
'info',
`RetrieveStyleTool: Successfully retrieved style ${data.id}`
);

return {
content: [
Expand Down
12 changes: 12 additions & 0 deletions src/tools/shared/styleId.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { z } from 'zod';

export const styleIdSchema = z
.string()
.regex(
/^[a-z0-9][a-z0-9-]*[a-z0-9]$/,
'Invalid Mapbox style ID (only lowercase letters, numbers, and hyphens allowed)'
)
.describe('Mapbox style ID');
4 changes: 4 additions & 0 deletions src/tools/tilequery-tool/TilequeryTool.input.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { z } from 'zod';
export const TilequerySchema = z.object({
tilesetId: z
.string()
.regex(
/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/,
'Invalid tileset ID format (expected: owner.tileset-name)'
)
.optional()
.default('mapbox.mapbox-streets-v8')
.describe('Tileset ID to query (default: mapbox.mapbox-streets-v8)'),
Expand Down
20 changes: 11 additions & 9 deletions src/tools/tilequery-tool/TilequeryTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class TilequeryTool extends MapboxApiBasedTool<
): Promise<CallToolResult> {
const { tilesetId, longitude, latitude, ...queryParams } = input;
const url = new URL(
`${MapboxApiBasedTool.mapboxApiEndpoint}v4/${tilesetId}/tilequery/${longitude},${latitude}.json`
`${MapboxApiBasedTool.mapboxApiEndpoint}v4/${encodeURIComponent(tilesetId)}/tilequery/${longitude},${latitude}.json`
);

if (queryParams.radius !== undefined) {
Expand Down Expand Up @@ -81,17 +81,19 @@ export class TilequeryTool extends MapboxApiBasedTool<

const rawData = await response.json();

// Validate response against schema with graceful fallback
let data: TilequeryResponse;
try {
data = TilequeryResponseSchema.parse(rawData);
} catch (validationError) {
this.log(
'warning',
`Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}`
);
// Graceful fallback to raw data
data = rawData as TilequeryResponse;
} catch {
return {
isError: true,
content: [
{
type: 'text',
text: 'Unexpected API response format from Mapbox API'
}
]
};
}

this.log(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
// Licensed under the MIT License.

import { z } from 'zod';
import { styleIdSchema } from '../shared/styleId.schema.js';

// INPUT Schema - Accepts a complete Mapbox Style Specification as a generic object
// This avoids complex schemas with .passthrough() that break some MCP clients (Cursor + OpenAI)
export const UpdateStyleInputSchema = z.object({
styleId: z.string().describe('Style ID to update'),
styleId: styleIdSchema.describe('Style ID to update'),
name: z.string().optional().describe('New name for the style'),
style: z
.record(z.string(), z.any())
Expand Down
Loading
Loading