Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/tools/__snapshots__/tool-naming-convention.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ exports[`Tool Naming Convention should maintain consistent tool list (snapshot t
"description": "Retrieve a specific Mapbox style by ID",
"toolName": "retrieve_style_tool",
},
{
"className": "StyleComparisonTool",
"description": "Generate a comparison URL for comparing two Mapbox styles side-by-side",
"toolName": "style_comparison_tool",
},
{
"className": "UpdateStyleTool",
"description": "Update an existing Mapbox style",
Expand Down
43 changes: 43 additions & 0 deletions src/tools/style-comparison-tool/StyleComparisonTool.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { z } from 'zod';

export const StyleComparisonSchema = z.object({
before: z
.string()
.describe(
'Mapbox style for the "before" side. Accepts: full style URL (mapbox://styles/username/styleId), username/styleId format, or just styleId if using your own styles'
),
after: z
.string()
.describe(
'Mapbox style for the "after" side. Accepts: full style URL (mapbox://styles/username/styleId), username/styleId format, or just styleId if using your own styles'
),
accessToken: z
.string()
.describe(
'Mapbox public access token (required, must start with pk.* and have styles:read permission). Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs. Please use a public token or create one with styles:read permission.'
),
zoom: z
.number()
.optional()
.describe(
'Initial zoom level for the map view (0-22). If provided along with latitude and longitude, sets the initial map position.'
),
latitude: z
.number()
.min(-90)
.max(90)
.optional()
.describe(
'Latitude coordinate for the initial map center (-90 to 90). Must be provided together with longitude and zoom.'
),
longitude: z
.number()
.min(-180)
.max(180)
.optional()
.describe(
'Longitude coordinate for the initial map center (-180 to 180). Must be provided together with latitude and zoom.'
)
});

export type StyleComparisonInput = z.infer<typeof StyleComparisonSchema>;
212 changes: 212 additions & 0 deletions src/tools/style-comparison-tool/StyleComparisonTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js';
import { StyleComparisonTool } from './StyleComparisonTool.js';

describe('StyleComparisonTool', () => {
let tool: StyleComparisonTool;

beforeEach(() => {
tool = new StyleComparisonTool();
});

afterEach(() => {
jest.restoreAllMocks();
});

describe('run', () => {
it('should generate comparison URL with provided access token', async () => {
const input = {
before: 'mapbox/streets-v11',
after: 'mapbox/outdoors-v12',
accessToken: 'pk.test.token'
};

const result = await tool.run(input);

expect(result.isError).toBe(false);
expect(result.content[0].type).toBe('text');
const url = (result.content[0] as { type: 'text'; text: string }).text;
expect(url).toContain('https://agent.mapbox.com/tools/style-compare');
expect(url).toContain('access_token=pk.test.token');
expect(url).toContain('before=mapbox%2Fstreets-v11');
expect(url).toContain('after=mapbox%2Foutdoors-v12');
});

it('should require access token', async () => {
const input = {
before: 'mapbox/streets-v11',
after: 'mapbox/satellite-v9'
// Missing accessToken
};

const result = await tool.run(input as any);

expect(result.isError).toBe(true);
expect(
(result.content[0] as { type: 'text'; text: string }).text
).toContain('Required');
});

it('should handle full style URLs', async () => {
const input = {
before: 'mapbox://styles/mapbox/streets-v11',
after: 'mapbox://styles/mapbox/outdoors-v12',
accessToken: 'pk.test.token'
};

const result = await tool.run(input);

expect(result.isError).toBe(false);
const url = (result.content[0] as { type: 'text'; text: string }).text;
expect(url).toContain('before=mapbox%2Fstreets-v11');
expect(url).toContain('after=mapbox%2Foutdoors-v12');
});

it('should handle just style IDs with valid public token', async () => {
// Mock MapboxApiBasedTool.getUserNameFromToken to return a username
jest
.spyOn(MapboxApiBasedTool, 'getUserNameFromToken')
.mockReturnValue('testuser');

const input = {
before: 'style-id-1',
after: 'style-id-2',
accessToken: 'pk.test.token'
};

const result = await tool.run(input);

expect(result.isError).toBe(false);
const url = (result.content[0] as { type: 'text'; text: string }).text;
expect(url).toContain('before=testuser%2Fstyle-id-1');
expect(url).toContain('after=testuser%2Fstyle-id-2');
});

it('should reject secret tokens', async () => {
const input = {
before: 'mapbox/streets-v11',
after: 'mapbox/outdoors-v12',
accessToken: 'sk.secret.token'
};

const result = await tool.run(input);

expect(result.isError).toBe(true);
expect(
(result.content[0] as { type: 'text'; text: string }).text
).toContain('Invalid token type');
expect(
(result.content[0] as { type: 'text'; text: string }).text
).toContain('Secret tokens (sk.*) cannot be exposed');
});

it('should reject invalid token formats', async () => {
const input = {
before: 'mapbox/streets-v11',
after: 'mapbox/outdoors-v12',
accessToken: 'invalid.token'
};

const result = await tool.run(input);

expect(result.isError).toBe(true);
expect(
(result.content[0] as { type: 'text'; text: string }).text
).toContain('Invalid token type');
});

it('should return error for style ID without valid username in token', async () => {
// Mock getUserNameFromToken to throw an error
jest
.spyOn(MapboxApiBasedTool, 'getUserNameFromToken')
.mockImplementation(() => {
throw new Error(
'MAPBOX_ACCESS_TOKEN does not contain username in payload'
);
});

const input = {
before: 'style-id-only',
after: 'mapbox/outdoors-v12',
accessToken: 'pk.test.token'
};

const result = await tool.run(input);

expect(result.isError).toBe(true);
expect(
(result.content[0] as { type: 'text'; text: string }).text
).toContain('Could not determine username');
});

it('should properly encode URL parameters', async () => {
const input = {
before: 'user-name/style-id-1',
after: 'user-name/style-id-2',
accessToken: 'pk.test.token'
};

const result = await tool.run(input);

expect(result.isError).toBe(false);
const url = (result.content[0] as { type: 'text'; text: string }).text;
// Check that forward slashes are URL encoded
expect(url).toContain('before=user-name%2Fstyle-id-1');
expect(url).toContain('after=user-name%2Fstyle-id-2');
});

it('should include hash fragment with map position when coordinates are provided', async () => {
const input = {
before: 'mapbox/streets-v11',
after: 'mapbox/outdoors-v12',
accessToken: 'pk.test.token',
zoom: 5.72,
latitude: 9.503,
longitude: -67.473
};

const result = await tool.run(input);

expect(result.isError).toBe(false);
const url = (result.content[0] as { type: 'text'; text: string }).text;
expect(url).toContain('#5.72/9.503/-67.473');
});

it('should not include hash fragment when coordinates are incomplete', async () => {
// Only zoom provided
const input1 = {
before: 'mapbox/streets-v11',
after: 'mapbox/outdoors-v12',
accessToken: 'pk.test.token',
zoom: 10
};

const result1 = await tool.run(input1);
expect(result1.isError).toBe(false);
const url1 = (result1.content[0] as { type: 'text'; text: string }).text;
expect(url1).not.toContain('#');

// Only latitude and longitude, no zoom
const input2 = {
before: 'mapbox/streets-v11',
after: 'mapbox/outdoors-v12',
accessToken: 'pk.test.token',
latitude: 40.7128,
longitude: -74.006
};

const result2 = await tool.run(input2);
expect(result2.isError).toBe(false);
const url2 = (result2.content[0] as { type: 'text'; text: string }).text;
expect(url2).not.toContain('#');
});
});

describe('metadata', () => {
it('should have correct name and description', () => {
expect(tool.name).toBe('style_comparison_tool');
expect(tool.description).toBe(
'Generate a comparison URL for comparing two Mapbox styles side-by-side'
);
});
});
});
95 changes: 95 additions & 0 deletions src/tools/style-comparison-tool/StyleComparisonTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { BaseTool } from '../BaseTool.js';
import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js';
import {
StyleComparisonSchema,
StyleComparisonInput
} from './StyleComparisonTool.schema.js';

export class StyleComparisonTool extends BaseTool<
typeof StyleComparisonSchema
> {
readonly name = 'style_comparison_tool';
readonly description =
'Generate a comparison URL for comparing two Mapbox styles side-by-side';

constructor() {
super({ inputSchema: StyleComparisonSchema });
}

/**
* Validates that the token is a public token
*/
private validatePublicToken(token: string): void {
if (!token.startsWith('pk.')) {
throw new Error(
`Invalid token type. Style comparison requires a public token (pk.*) that can be used in browser URLs. ` +
`Secret tokens (sk.*) cannot be exposed in client-side applications. ` +
`Please provide a public token with styles:read permission.`
);
}
}

/**
* Processes style input to extract username/styleId format
*/
private processStyleId(style: string, accessToken: string): string {
// If it's a full URL, extract the username/styleId part
if (style.startsWith('mapbox://styles/')) {
return style.replace('mapbox://styles/', '');
}

// If it contains a slash, assume it's already username/styleId format
if (style.includes('/')) {
return style;
}

// If it's just a style ID, try to get username from the token
try {
const username = MapboxApiBasedTool.getUserNameFromToken(accessToken);
return `${username}/${style}`;
} catch (error) {
throw new Error(
`Could not determine username for style ID "${style}". ${error instanceof Error ? error.message : ''}\n` +
`Please provide either:\n` +
`1. Full style URL: mapbox://styles/username/${style}\n` +
`2. Username/styleId format: username/${style}\n` +
`3. Just the style ID with a valid Mapbox token that contains username information`
);
}
}

protected async execute(
input: StyleComparisonInput
): Promise<{ type: 'text'; text: string }> {
// Validate that we have a public token
this.validatePublicToken(input.accessToken);

// Process style IDs to get username/styleId format
const beforeStyleId = this.processStyleId(input.before, input.accessToken);
const afterStyleId = this.processStyleId(input.after, input.accessToken);

// Build the comparison URL
const params = new URLSearchParams();
params.append('access_token', input.accessToken);
params.append('before', beforeStyleId);
params.append('after', afterStyleId);

// Build base URL
let url = `https://agent.mapbox.com/tools/style-compare?${params.toString()}`;

// Add hash fragment for map position if all coordinates are provided
if (
input.zoom !== undefined &&
input.latitude !== undefined &&
input.longitude !== undefined
) {
// Format: #zoom/latitude/longitude
url += `#${input.zoom}/${input.latitude}/${input.longitude}`;
}

return {
type: 'text',
text: url
};
}
}
4 changes: 3 additions & 1 deletion src/tools/toolRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ListStylesTool } from './list-styles-tool/ListStylesTool.js';
import { ListTokensTool } from './list-tokens-tool/ListTokensTool.js';
import { PreviewStyleTool } from './preview-style-tool/PreviewStyleTool.js';
import { RetrieveStyleTool } from './retrieve-style-tool/RetrieveStyleTool.js';
import { StyleComparisonTool } from './style-comparison-tool/StyleComparisonTool.js';
import { UpdateStyleTool } from './update-style-tool/UpdateStyleTool.js';

// Central registry of all tools
Expand All @@ -24,7 +25,8 @@ export const ALL_TOOLS = [
new ListTokensTool(),
new BoundingBoxTool(),
new CountryBoundingBoxTool(),
new CoordinateConversionTool()
new CoordinateConversionTool(),
new StyleComparisonTool()
] as const;

export type ToolInstance = (typeof ALL_TOOLS)[number];
Expand Down
Loading