diff --git a/src/tools/__snapshots__/tool-naming-convention.test.ts.snap b/src/tools/__snapshots__/tool-naming-convention.test.ts.snap index 66587c6..03dfbd1 100644 --- a/src/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/src/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -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", diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts new file mode 100644 index 0000000..6f51be4 --- /dev/null +++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts @@ -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; diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.test.ts b/src/tools/style-comparison-tool/StyleComparisonTool.test.ts new file mode 100644 index 0000000..f57750f --- /dev/null +++ b/src/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -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' + ); + }); + }); +}); diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts new file mode 100644 index 0000000..334a858 --- /dev/null +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -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 + }; + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index c8dc93c..d13881f 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -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 @@ -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];