From 754e1103bff84524431f698abcbdf3673964b539 Mon Sep 17 00:00:00 2001 From: jussi-sa Date: Wed, 27 Aug 2025 19:01:43 +0300 Subject: [PATCH 1/5] tileset comparison --- .../tool-naming-convention.test.ts.snap | 5 + .../TilesetComparisonTool.schema.ts | 37 +++ .../TilesetComparisonTool.test.ts | 267 ++++++++++++++++ .../TilesetComparisonTool.ts | 285 ++++++++++++++++++ src/tools/toolRegistry.ts | 4 +- 5 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 src/tools/tileset-comparison-tool/TilesetComparisonTool.schema.ts create mode 100644 src/tools/tileset-comparison-tool/TilesetComparisonTool.test.ts create mode 100644 src/tools/tileset-comparison-tool/TilesetComparisonTool.ts diff --git a/src/tools/__snapshots__/tool-naming-convention.test.ts.snap b/src/tools/__snapshots__/tool-naming-convention.test.ts.snap index 66587c6..749841e 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": "TilesetComparisonTool", + "description": "Generate an HTML file for comparing two Mapbox styles side-by-side using mapbox-gl-compare", + "toolName": "tileset_comparison_tool", + }, { "className": "UpdateStyleTool", "description": "Update an existing Mapbox style", diff --git a/src/tools/tileset-comparison-tool/TilesetComparisonTool.schema.ts b/src/tools/tileset-comparison-tool/TilesetComparisonTool.schema.ts new file mode 100644 index 0000000..ff83e01 --- /dev/null +++ b/src/tools/tileset-comparison-tool/TilesetComparisonTool.schema.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const TilesetComparisonSchema = 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() + .optional() + .describe( + 'Mapbox public access token (must start with pk.*). Secret tokens (sk.*) cannot be used in browser-based HTML. If not provided, will attempt to find an existing public token from your account' + ), + title: z.string().optional().describe('Title for the comparison view'), + center: z + .array(z.number()) + .length(2) + .optional() + .describe('Initial map center as [longitude, latitude]'), + zoom: z.number().optional().describe('Initial zoom level'), + bearing: z + .number() + .optional() + .describe('Initial bearing (rotation) of the map in degrees'), + pitch: z + .number() + .optional() + .describe('Initial pitch (tilt) of the map in degrees') +}); + +export type TilesetComparisonInput = z.infer; diff --git a/src/tools/tileset-comparison-tool/TilesetComparisonTool.test.ts b/src/tools/tileset-comparison-tool/TilesetComparisonTool.test.ts new file mode 100644 index 0000000..80de311 --- /dev/null +++ b/src/tools/tileset-comparison-tool/TilesetComparisonTool.test.ts @@ -0,0 +1,267 @@ +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import { ListTokensTool } from '../list-tokens-tool/ListTokensTool.js'; +import { TilesetComparisonTool } from './TilesetComparisonTool.js'; + +describe('TilesetComparisonTool', () => { + let tool: TilesetComparisonTool; + let mockListTokensTool: jest.SpyInstance; + + beforeEach(() => { + tool = new TilesetComparisonTool(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('run', () => { + it('should generate HTML with provided access token', async () => { + const input = { + before: 'mapbox/streets-v11', + after: 'mapbox/outdoors-v12', + accessToken: 'pk.test.token', + title: 'Street vs Outdoors Comparison', + center: [-122.4194, 37.7749] as [number, number], + zoom: 12 + }; + + const result = await tool.run(input); + + expect(result.isError).toBe(false); + expect(result.content[0].type).toBe('text'); + const html = (result.content[0] as { type: 'text'; text: string }).text; + expect(html).toContain(''); + expect(html).toContain('mapbox-gl-compare'); + expect(html).toContain('pk.test.token'); + expect(html).toContain('mapbox://styles/mapbox/streets-v11'); + expect(html).toContain('mapbox://styles/mapbox/outdoors-v12'); + expect(html).toContain('Street vs Outdoors Comparison'); + expect(html).toContain('center: [-122.4194, 37.7749]'); + expect(html).toContain('zoom: 12'); + }); + + it('should attempt to fetch public token when no token provided', async () => { + mockListTokensTool = jest + .spyOn(ListTokensTool.prototype, 'run') + .mockResolvedValue({ + isError: false, + content: [ + { + type: 'text', + text: JSON.stringify({ + tokens: [ + { + token: 'pk.fetched.token', + name: 'Public Token' + } + ] + }) + } + ] + }); + + const input = { + before: 'mapbox/streets-v11', + after: 'mapbox/satellite-v9' + }; + + const result = await tool.run(input); + + expect(result.isError).toBe(false); + expect(mockListTokensTool).toHaveBeenCalledWith({ usage: 'pk' }); + const html = (result.content[0] as { type: 'text'; text: string }).text; + expect(html).toContain('pk.fetched.token'); + }); + + 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 html = (result.content[0] as { type: 'text'; text: string }).text; + expect(html).toContain('mapbox://styles/mapbox/streets-v11'); + expect(html).toContain('mapbox://styles/mapbox/outdoors-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 html = (result.content[0] as { type: 'text'; text: string }).text; + expect(html).toContain('mapbox://styles/testuser/style-id-1'); + expect(html).toContain('mapbox://styles/testuser/style-id-2'); + }); + + it('should reject secret tokens and try to fetch public token', async () => { + mockListTokensTool = jest + .spyOn(ListTokensTool.prototype, 'run') + .mockResolvedValue({ + isError: false, + content: [ + { + type: 'text', + text: JSON.stringify({ + tokens: [ + { + token: 'pk.fetched.public.token', + name: 'Public Token' + } + ] + }) + } + ] + }); + + const input = { + before: 'mapbox/streets-v11', + after: 'mapbox/outdoors-v12', + accessToken: 'sk.secret.token' + }; + + const result = await tool.run(input); + + expect(result.isError).toBe(false); + expect(mockListTokensTool).toHaveBeenCalledWith({ usage: 'pk' }); + const html = (result.content[0] as { type: 'text'; text: string }).text; + expect(html).toContain('pk.fetched.public.token'); + expect(html).not.toContain('sk.secret.token'); + }); + + it('should error when secret token provided and no public token available', async () => { + mockListTokensTool = jest + .spyOn(ListTokensTool.prototype, 'run') + .mockResolvedValue({ + isError: false, + content: [ + { + type: 'text', + text: JSON.stringify({ tokens: [] }) + } + ] + }); + + 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('HTML comparison requires a public token'); + }); + + it('should include all map options when provided', async () => { + const input = { + before: 'mapbox/streets-v11', + after: 'mapbox/outdoors-v12', + accessToken: 'pk.test.token', + center: [-74.006, 40.7128] as [number, number], + zoom: 10, + bearing: 45, + pitch: 60 + }; + + const result = await tool.run(input); + + expect(result.isError).toBe(false); + const html = (result.content[0] as { type: 'text'; text: string }).text; + expect(html).toContain('center: [-74.006, 40.7128]'); + expect(html).toContain('zoom: 10'); + expect(html).toContain('bearing: 45'); + expect(html).toContain('pitch: 60'); + }); + + it('should return error when no token available', async () => { + mockListTokensTool = jest + .spyOn(ListTokensTool.prototype, 'run') + .mockResolvedValue({ + isError: false, + content: [ + { + type: 'text', + text: JSON.stringify({ tokens: [] }) + } + ] + }); + + const input = { + before: 'mapbox/streets-v11', + after: 'mapbox/outdoors-v12' + }; + + const result = await tool.run(input); + + expect(result.isError).toBe(true); + expect( + (result.content[0] as { type: 'text'; text: string }).text + ).toContain('No access token provided'); + }); + + 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 not include overlay when title is not provided', 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); + const html = (result.content[0] as { type: 'text'; text: string }).text; + expect(html).not.toContain('class="map-overlay"'); + }); + }); + + describe('metadata', () => { + it('should have correct name and description', () => { + expect(tool.name).toBe('tileset_comparison_tool'); + expect(tool.description).toBe( + 'Generate an HTML file for comparing two Mapbox styles side-by-side using mapbox-gl-compare' + ); + }); + }); +}); diff --git a/src/tools/tileset-comparison-tool/TilesetComparisonTool.ts b/src/tools/tileset-comparison-tool/TilesetComparisonTool.ts new file mode 100644 index 0000000..09973a8 --- /dev/null +++ b/src/tools/tileset-comparison-tool/TilesetComparisonTool.ts @@ -0,0 +1,285 @@ +import { BaseTool } from '../BaseTool.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import { ListTokensTool } from '../list-tokens-tool/ListTokensTool.js'; +import { + TilesetComparisonSchema, + TilesetComparisonInput +} from './TilesetComparisonTool.schema.js'; + +// HTML template as a string constant - avoids file I/O and compatibility issues +const COMPARISON_TEMPLATE = ` + + + + {{title}} + + + + + + + + + {{overlay}} +
+
+
+
+ + + +`; + +export class TilesetComparisonTool extends BaseTool< + typeof TilesetComparisonSchema +> { + readonly name = 'tileset_comparison_tool'; + readonly description = + 'Generate an HTML file for comparing two Mapbox styles side-by-side using mapbox-gl-compare'; + + constructor() { + super({ inputSchema: TilesetComparisonSchema }); + } + + protected async execute( + input: TilesetComparisonInput, + providedToken?: string + ): Promise<{ type: 'text'; text: string }> { + let accessToken = input.accessToken || providedToken; + + // If no token provided, try to get a public token from the account + if (!accessToken) { + try { + const listTokensTool = new ListTokensTool(); + const tokensResult = await listTokensTool.run({ + usage: 'pk' // Filter for public tokens only + }); + + if (!tokensResult.isError) { + const firstContent = tokensResult.content[0]; + if (firstContent.type === 'text') { + const tokensData = JSON.parse(firstContent.text); + const publicTokens = tokensData.tokens; + if (publicTokens && publicTokens.length > 0) { + accessToken = publicTokens[0].token; + } + } + } + } catch { + // Silently continue, will fail later if no token + } + } + + if (!accessToken) { + throw new Error( + 'No access token provided and no public token found. Please provide a public access token (pk.*).' + ); + } + + // Ensure the token is a public token (starts with pk.) + // Secret tokens (sk.*) cannot be used in client-side HTML + if (!accessToken.startsWith('pk.')) { + // If a secret token was provided, try to get a public token instead + if (accessToken.startsWith('sk.')) { + try { + const listTokensTool = new ListTokensTool(); + const tokensResult = await listTokensTool.run({ + usage: 'pk' // Filter for public tokens only + }); + + if (!tokensResult.isError) { + const firstContent = tokensResult.content[0]; + if (firstContent.type === 'text') { + const tokensData = JSON.parse(firstContent.text); + const publicTokens = tokensData.tokens; + if (publicTokens && publicTokens.length > 0) { + accessToken = publicTokens[0].token; + } else { + throw new Error( + 'A secret token (sk.*) was provided, but HTML comparison requires a public token (pk.*). ' + + 'No public token found in your account. Please create a public token first.' + ); + } + } + } + } catch { + throw new Error( + 'A secret token (sk.*) was provided, but HTML comparison requires a public token (pk.*). ' + + 'Failed to fetch public tokens from your account. Please provide a public token directly.' + ); + } + } else { + throw new Error( + `Invalid token format. Expected a public token starting with 'pk.' but got a token starting with '${accessToken.substring(0, 3)}'. ` + + 'HTML comparison requires a public token for client-side usage.' + ); + } + } + + // Process style URLs - if just an ID is provided, convert to full style URL + const processStyleUrl = (style: string): string => { + // If it's already a full URL, return as is + if (style.startsWith('mapbox://styles/')) { + return style; + } + // If it contains a slash, assume it's username/styleId format + if (style.includes('/')) { + return `mapbox://styles/${style}`; + } + // If it's just a style ID, try to get username from the token + try { + const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + return `mapbox://styles/${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` + ); + } + }; + + const beforeStyle = processStyleUrl(input.before); + const afterStyle = processStyleUrl(input.after); + + // Build map initialization options + const mapOptions: string[] = []; + if (input.center) { + mapOptions.push(`center: [${input.center[0]}, ${input.center[1]}]`); + } + if (input.zoom !== undefined) { + mapOptions.push(`zoom: ${input.zoom}`); + } + if (input.bearing !== undefined) { + mapOptions.push(`bearing: ${input.bearing}`); + } + if (input.pitch !== undefined) { + mapOptions.push(`pitch: ${input.pitch}`); + } + + const mapOptionsString = + mapOptions.length > 0 + ? ',\n ' + mapOptions.join(',\n ') + : ''; + + // Build overlay HTML if title is provided + const overlay = input.title + ? `
+

${input.title}

+ Before: ${input.before}
+ After: ${input.after} +
` + : ''; + + // Replace placeholders in template + // At this point, accessToken is guaranteed to be defined due to our checks above + const html = COMPARISON_TEMPLATE.replace( + '{{title}}', + input.title || 'Tileset Comparison' + ) + .replace('{{overlay}}', overlay) + .replace('{{accessToken}}', accessToken as string) + .replace('{{beforeStyle}}', beforeStyle) + .replace('{{afterStyle}}', afterStyle) + .replace('{{beforeMapOptions}}', mapOptionsString) + .replace('{{afterMapOptions}}', mapOptionsString); + + // Return the HTML content + return { + type: 'text', + text: html + }; + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index c8dc93c..e650804 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 { TilesetComparisonTool } from './tileset-comparison-tool/TilesetComparisonTool.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 TilesetComparisonTool() ] as const; export type ToolInstance = (typeof ALL_TOOLS)[number]; From efb5bd22759abec45dda6e4146991637445719a3 Mon Sep 17 00:00:00 2001 From: jussi-sa Date: Wed, 27 Aug 2025 19:11:08 +0300 Subject: [PATCH 2/5] clean up logic and nested statemenets --- .../TilesetComparisonTool.test.ts | 2 +- .../TilesetComparisonTool.ts | 121 +++++++++--------- 2 files changed, 61 insertions(+), 62 deletions(-) diff --git a/src/tools/tileset-comparison-tool/TilesetComparisonTool.test.ts b/src/tools/tileset-comparison-tool/TilesetComparisonTool.test.ts index 80de311..541e13e 100644 --- a/src/tools/tileset-comparison-tool/TilesetComparisonTool.test.ts +++ b/src/tools/tileset-comparison-tool/TilesetComparisonTool.test.ts @@ -167,7 +167,7 @@ describe('TilesetComparisonTool', () => { expect(result.isError).toBe(true); expect( (result.content[0] as { type: 'text'; text: string }).text - ).toContain('HTML comparison requires a public token'); + ).toContain('Secret tokens (sk.*) cannot be used in client-side HTML'); }); it('should include all map options when provided', async () => { diff --git a/src/tools/tileset-comparison-tool/TilesetComparisonTool.ts b/src/tools/tileset-comparison-tool/TilesetComparisonTool.ts index 09973a8..fc54009 100644 --- a/src/tools/tileset-comparison-tool/TilesetComparisonTool.ts +++ b/src/tools/tileset-comparison-tool/TilesetComparisonTool.ts @@ -131,81 +131,80 @@ export class TilesetComparisonTool extends BaseTool< super({ inputSchema: TilesetComparisonSchema }); } - protected async execute( - input: TilesetComparisonInput, - providedToken?: string - ): Promise<{ type: 'text'; text: string }> { - let accessToken = input.accessToken || providedToken; - - // If no token provided, try to get a public token from the account - if (!accessToken) { - try { - const listTokensTool = new ListTokensTool(); - const tokensResult = await listTokensTool.run({ - usage: 'pk' // Filter for public tokens only - }); + /** + * Fetches the first available public token from the user's account + */ + private async fetchPublicToken(): Promise { + try { + const listTokensTool = new ListTokensTool(); + const tokensResult = await listTokensTool.run({ + usage: 'pk' // Filter for public tokens only + }); - if (!tokensResult.isError) { - const firstContent = tokensResult.content[0]; - if (firstContent.type === 'text') { - const tokensData = JSON.parse(firstContent.text); - const publicTokens = tokensData.tokens; - if (publicTokens && publicTokens.length > 0) { - accessToken = publicTokens[0].token; - } + if (!tokensResult.isError) { + const firstContent = tokensResult.content[0]; + if (firstContent.type === 'text') { + const tokensData = JSON.parse(firstContent.text); + const publicTokens = tokensData.tokens; + if (publicTokens && publicTokens.length > 0) { + return publicTokens[0].token; } } - } catch { - // Silently continue, will fail later if no token } + } catch { + // Return null if fetching fails } + return null; + } - if (!accessToken) { - throw new Error( - 'No access token provided and no public token found. Please provide a public access token (pk.*).' - ); + /** + * Ensures we have a valid public token for client-side HTML usage + */ + private async ensurePublicToken(providedToken?: string): Promise { + // If no token provided, try to get one from the account + if (!providedToken) { + const fetchedToken = await this.fetchPublicToken(); + if (!fetchedToken) { + throw new Error( + 'No access token provided and no public token found. Please provide a public access token (pk.*).' + ); + } + return fetchedToken; } - // Ensure the token is a public token (starts with pk.) - // Secret tokens (sk.*) cannot be used in client-side HTML - if (!accessToken.startsWith('pk.')) { - // If a secret token was provided, try to get a public token instead - if (accessToken.startsWith('sk.')) { - try { - const listTokensTool = new ListTokensTool(); - const tokensResult = await listTokensTool.run({ - usage: 'pk' // Filter for public tokens only - }); + // If it's already a public token, use it + if (providedToken.startsWith('pk.')) { + return providedToken; + } - if (!tokensResult.isError) { - const firstContent = tokensResult.content[0]; - if (firstContent.type === 'text') { - const tokensData = JSON.parse(firstContent.text); - const publicTokens = tokensData.tokens; - if (publicTokens && publicTokens.length > 0) { - accessToken = publicTokens[0].token; - } else { - throw new Error( - 'A secret token (sk.*) was provided, but HTML comparison requires a public token (pk.*). ' + - 'No public token found in your account. Please create a public token first.' - ); - } - } - } - } catch { - throw new Error( - 'A secret token (sk.*) was provided, but HTML comparison requires a public token (pk.*). ' + - 'Failed to fetch public tokens from your account. Please provide a public token directly.' - ); - } - } else { + // If it's a secret token, try to get a public token instead + if (providedToken.startsWith('sk.')) { + const publicToken = await this.fetchPublicToken(); + if (!publicToken) { throw new Error( - `Invalid token format. Expected a public token starting with 'pk.' but got a token starting with '${accessToken.substring(0, 3)}'. ` + - 'HTML comparison requires a public token for client-side usage.' + 'Secret tokens (sk.*) cannot be used in client-side HTML. ' + + 'No public token found in your account. Please create a public token or provide one directly.' ); } + return publicToken; } + // Unknown token format + throw new Error( + `Invalid token format. Expected a public token starting with 'pk.' but got '${providedToken.substring(0, 3)}...'. ` + + 'HTML comparison requires a public token for client-side usage.' + ); + } + + protected async execute( + input: TilesetComparisonInput, + providedToken?: string + ): Promise<{ type: 'text'; text: string }> { + // Ensure we have a valid public token + const accessToken = await this.ensurePublicToken( + input.accessToken || providedToken + ); + // Process style URLs - if just an ID is provided, convert to full style URL const processStyleUrl = (style: string): string => { // If it's already a full URL, return as is From 74f6ba3e35161a38effe6d35562915d8c5289e90 Mon Sep 17 00:00:00 2001 From: jussi-sa Date: Fri, 29 Aug 2025 10:07:26 +0300 Subject: [PATCH 3/5] style comparison tool --- .../tool-naming-convention.test.ts.snap | 6 +- .../StyleComparisonTool.schema.ts | 22 ++ .../StyleComparisonTool.test.ts} | 90 ++---- .../StyleComparisonTool.ts | 140 +++++++++ .../TilesetComparisonTool.schema.ts | 37 --- .../TilesetComparisonTool.ts | 284 ------------------ src/tools/toolRegistry.ts | 4 +- 7 files changed, 199 insertions(+), 384 deletions(-) create mode 100644 src/tools/style-comparison-tool/StyleComparisonTool.schema.ts rename src/tools/{tileset-comparison-tool/TilesetComparisonTool.test.ts => style-comparison-tool/StyleComparisonTool.test.ts} (65%) create mode 100644 src/tools/style-comparison-tool/StyleComparisonTool.ts delete mode 100644 src/tools/tileset-comparison-tool/TilesetComparisonTool.schema.ts delete mode 100644 src/tools/tileset-comparison-tool/TilesetComparisonTool.ts diff --git a/src/tools/__snapshots__/tool-naming-convention.test.ts.snap b/src/tools/__snapshots__/tool-naming-convention.test.ts.snap index 749841e..03dfbd1 100644 --- a/src/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/src/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -58,9 +58,9 @@ exports[`Tool Naming Convention should maintain consistent tool list (snapshot t "toolName": "retrieve_style_tool", }, { - "className": "TilesetComparisonTool", - "description": "Generate an HTML file for comparing two Mapbox styles side-by-side using mapbox-gl-compare", - "toolName": "tileset_comparison_tool", + "className": "StyleComparisonTool", + "description": "Generate a comparison URL for comparing two Mapbox styles side-by-side", + "toolName": "style_comparison_tool", }, { "className": "UpdateStyleTool", 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..505ca4a --- /dev/null +++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts @@ -0,0 +1,22 @@ +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() + .optional() + .describe( + 'Mapbox public access token (must start with pk.* and have styles:read permission). If not provided, will attempt to find an existing public token from your account' + ) +}); + +export type StyleComparisonInput = z.infer; diff --git a/src/tools/tileset-comparison-tool/TilesetComparisonTool.test.ts b/src/tools/style-comparison-tool/StyleComparisonTool.test.ts similarity index 65% rename from src/tools/tileset-comparison-tool/TilesetComparisonTool.test.ts rename to src/tools/style-comparison-tool/StyleComparisonTool.test.ts index 541e13e..60d1178 100644 --- a/src/tools/tileset-comparison-tool/TilesetComparisonTool.test.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -1,13 +1,13 @@ import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { ListTokensTool } from '../list-tokens-tool/ListTokensTool.js'; -import { TilesetComparisonTool } from './TilesetComparisonTool.js'; +import { StyleComparisonTool } from './StyleComparisonTool.js'; -describe('TilesetComparisonTool', () => { - let tool: TilesetComparisonTool; +describe('StyleComparisonTool', () => { + let tool: StyleComparisonTool; let mockListTokensTool: jest.SpyInstance; beforeEach(() => { - tool = new TilesetComparisonTool(); + tool = new StyleComparisonTool(); }); afterEach(() => { @@ -15,29 +15,22 @@ describe('TilesetComparisonTool', () => { }); describe('run', () => { - it('should generate HTML with provided access token', async () => { + it('should generate comparison URL with provided access token', async () => { const input = { before: 'mapbox/streets-v11', after: 'mapbox/outdoors-v12', - accessToken: 'pk.test.token', - title: 'Street vs Outdoors Comparison', - center: [-122.4194, 37.7749] as [number, number], - zoom: 12 + accessToken: 'pk.test.token' }; const result = await tool.run(input); expect(result.isError).toBe(false); expect(result.content[0].type).toBe('text'); - const html = (result.content[0] as { type: 'text'; text: string }).text; - expect(html).toContain(''); - expect(html).toContain('mapbox-gl-compare'); - expect(html).toContain('pk.test.token'); - expect(html).toContain('mapbox://styles/mapbox/streets-v11'); - expect(html).toContain('mapbox://styles/mapbox/outdoors-v12'); - expect(html).toContain('Street vs Outdoors Comparison'); - expect(html).toContain('center: [-122.4194, 37.7749]'); - expect(html).toContain('zoom: 12'); + 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 attempt to fetch public token when no token provided', async () => { @@ -69,8 +62,8 @@ describe('TilesetComparisonTool', () => { expect(result.isError).toBe(false); expect(mockListTokensTool).toHaveBeenCalledWith({ usage: 'pk' }); - const html = (result.content[0] as { type: 'text'; text: string }).text; - expect(html).toContain('pk.fetched.token'); + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain('access_token=pk.fetched.token'); }); it('should handle full style URLs', async () => { @@ -83,9 +76,9 @@ describe('TilesetComparisonTool', () => { const result = await tool.run(input); expect(result.isError).toBe(false); - const html = (result.content[0] as { type: 'text'; text: string }).text; - expect(html).toContain('mapbox://styles/mapbox/streets-v11'); - expect(html).toContain('mapbox://styles/mapbox/outdoors-v12'); + 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 () => { @@ -103,9 +96,9 @@ describe('TilesetComparisonTool', () => { const result = await tool.run(input); expect(result.isError).toBe(false); - const html = (result.content[0] as { type: 'text'; text: string }).text; - expect(html).toContain('mapbox://styles/testuser/style-id-1'); - expect(html).toContain('mapbox://styles/testuser/style-id-2'); + 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 and try to fetch public token', async () => { @@ -138,9 +131,9 @@ describe('TilesetComparisonTool', () => { expect(result.isError).toBe(false); expect(mockListTokensTool).toHaveBeenCalledWith({ usage: 'pk' }); - const html = (result.content[0] as { type: 'text'; text: string }).text; - expect(html).toContain('pk.fetched.public.token'); - expect(html).not.toContain('sk.secret.token'); + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain('access_token=pk.fetched.public.token'); + expect(url).not.toContain('sk.secret.token'); }); it('should error when secret token provided and no public token available', async () => { @@ -167,28 +160,7 @@ describe('TilesetComparisonTool', () => { expect(result.isError).toBe(true); expect( (result.content[0] as { type: 'text'; text: string }).text - ).toContain('Secret tokens (sk.*) cannot be used in client-side HTML'); - }); - - it('should include all map options when provided', async () => { - const input = { - before: 'mapbox/streets-v11', - after: 'mapbox/outdoors-v12', - accessToken: 'pk.test.token', - center: [-74.006, 40.7128] as [number, number], - zoom: 10, - bearing: 45, - pitch: 60 - }; - - const result = await tool.run(input); - - expect(result.isError).toBe(false); - const html = (result.content[0] as { type: 'text'; text: string }).text; - expect(html).toContain('center: [-74.006, 40.7128]'); - expect(html).toContain('zoom: 10'); - expect(html).toContain('bearing: 45'); - expect(html).toContain('pitch: 60'); + ).toContain('Secret tokens (sk.*) cannot be used for style comparison'); }); it('should return error when no token available', async () => { @@ -241,26 +213,28 @@ describe('TilesetComparisonTool', () => { ).toContain('Could not determine username'); }); - it('should not include overlay when title is not provided', async () => { + it('should properly encode URL parameters', async () => { const input = { - before: 'mapbox/streets-v11', - after: 'mapbox/outdoors-v12', + 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 html = (result.content[0] as { type: 'text'; text: string }).text; - expect(html).not.toContain('class="map-overlay"'); + 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'); }); }); describe('metadata', () => { it('should have correct name and description', () => { - expect(tool.name).toBe('tileset_comparison_tool'); + expect(tool.name).toBe('style_comparison_tool'); expect(tool.description).toBe( - 'Generate an HTML file for comparing two Mapbox styles side-by-side using mapbox-gl-compare' + '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..d9fa2cd --- /dev/null +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -0,0 +1,140 @@ +import { BaseTool } from '../BaseTool.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import { ListTokensTool } from '../list-tokens-tool/ListTokensTool.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 }); + } + + /** + * Fetches the first available public token from the user's account + */ + private async fetchPublicToken(): Promise { + try { + const listTokensTool = new ListTokensTool(); + const tokensResult = await listTokensTool.run({ + usage: 'pk' // Filter for public tokens only + }); + + if (!tokensResult.isError) { + const firstContent = tokensResult.content[0]; + if (firstContent.type === 'text') { + const tokensData = JSON.parse(firstContent.text); + const publicTokens = tokensData.tokens; + if (publicTokens && publicTokens.length > 0) { + return publicTokens[0].token; + } + } + } + } catch { + // Return null if fetching fails + } + return null; + } + + /** + * Ensures we have a valid public token for the comparison URL + */ + private async ensurePublicToken(providedToken?: string): Promise { + // If no token provided, try to get one from the account + if (!providedToken) { + const fetchedToken = await this.fetchPublicToken(); + if (!fetchedToken) { + throw new Error( + 'No access token provided and no public token found. Please provide a public access token (pk.*) with styles:read permission.' + ); + } + return fetchedToken; + } + + // If it's already a public token, use it + if (providedToken.startsWith('pk.')) { + return providedToken; + } + + // If it's a secret token, try to get a public token instead + if (providedToken.startsWith('sk.')) { + const publicToken = await this.fetchPublicToken(); + if (!publicToken) { + throw new Error( + 'Secret tokens (sk.*) cannot be used for style comparison. ' + + 'No public token found in your account. Please create a public token with styles:read permission or provide one directly.' + ); + } + return publicToken; + } + + // Unknown token format + throw new Error( + `Invalid token format. Expected a public token starting with 'pk.' but got '${providedToken.substring(0, 3)}...'. ` + + 'Style comparison requires 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, + providedToken?: string + ): Promise<{ type: 'text'; text: string }> { + // Ensure we have a valid public token + const accessToken = await this.ensurePublicToken( + input.accessToken || providedToken + ); + + // Process style IDs to get username/styleId format + const beforeStyleId = this.processStyleId(input.before, accessToken); + const afterStyleId = this.processStyleId(input.after, accessToken); + + // Build the comparison URL + const params = new URLSearchParams(); + params.append('access_token', accessToken); + params.append('before', beforeStyleId); + params.append('after', afterStyleId); + + const url = `https://agent.mapbox.com/tools/style-compare?${params.toString()}`; + + return { + type: 'text', + text: url + }; + } +} diff --git a/src/tools/tileset-comparison-tool/TilesetComparisonTool.schema.ts b/src/tools/tileset-comparison-tool/TilesetComparisonTool.schema.ts deleted file mode 100644 index ff83e01..0000000 --- a/src/tools/tileset-comparison-tool/TilesetComparisonTool.schema.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { z } from 'zod'; - -export const TilesetComparisonSchema = 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() - .optional() - .describe( - 'Mapbox public access token (must start with pk.*). Secret tokens (sk.*) cannot be used in browser-based HTML. If not provided, will attempt to find an existing public token from your account' - ), - title: z.string().optional().describe('Title for the comparison view'), - center: z - .array(z.number()) - .length(2) - .optional() - .describe('Initial map center as [longitude, latitude]'), - zoom: z.number().optional().describe('Initial zoom level'), - bearing: z - .number() - .optional() - .describe('Initial bearing (rotation) of the map in degrees'), - pitch: z - .number() - .optional() - .describe('Initial pitch (tilt) of the map in degrees') -}); - -export type TilesetComparisonInput = z.infer; diff --git a/src/tools/tileset-comparison-tool/TilesetComparisonTool.ts b/src/tools/tileset-comparison-tool/TilesetComparisonTool.ts deleted file mode 100644 index fc54009..0000000 --- a/src/tools/tileset-comparison-tool/TilesetComparisonTool.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { BaseTool } from '../BaseTool.js'; -import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import { ListTokensTool } from '../list-tokens-tool/ListTokensTool.js'; -import { - TilesetComparisonSchema, - TilesetComparisonInput -} from './TilesetComparisonTool.schema.js'; - -// HTML template as a string constant - avoids file I/O and compatibility issues -const COMPARISON_TEMPLATE = ` - - - - {{title}} - - - - - - - - - {{overlay}} -
-
-
-
- - - -`; - -export class TilesetComparisonTool extends BaseTool< - typeof TilesetComparisonSchema -> { - readonly name = 'tileset_comparison_tool'; - readonly description = - 'Generate an HTML file for comparing two Mapbox styles side-by-side using mapbox-gl-compare'; - - constructor() { - super({ inputSchema: TilesetComparisonSchema }); - } - - /** - * Fetches the first available public token from the user's account - */ - private async fetchPublicToken(): Promise { - try { - const listTokensTool = new ListTokensTool(); - const tokensResult = await listTokensTool.run({ - usage: 'pk' // Filter for public tokens only - }); - - if (!tokensResult.isError) { - const firstContent = tokensResult.content[0]; - if (firstContent.type === 'text') { - const tokensData = JSON.parse(firstContent.text); - const publicTokens = tokensData.tokens; - if (publicTokens && publicTokens.length > 0) { - return publicTokens[0].token; - } - } - } - } catch { - // Return null if fetching fails - } - return null; - } - - /** - * Ensures we have a valid public token for client-side HTML usage - */ - private async ensurePublicToken(providedToken?: string): Promise { - // If no token provided, try to get one from the account - if (!providedToken) { - const fetchedToken = await this.fetchPublicToken(); - if (!fetchedToken) { - throw new Error( - 'No access token provided and no public token found. Please provide a public access token (pk.*).' - ); - } - return fetchedToken; - } - - // If it's already a public token, use it - if (providedToken.startsWith('pk.')) { - return providedToken; - } - - // If it's a secret token, try to get a public token instead - if (providedToken.startsWith('sk.')) { - const publicToken = await this.fetchPublicToken(); - if (!publicToken) { - throw new Error( - 'Secret tokens (sk.*) cannot be used in client-side HTML. ' + - 'No public token found in your account. Please create a public token or provide one directly.' - ); - } - return publicToken; - } - - // Unknown token format - throw new Error( - `Invalid token format. Expected a public token starting with 'pk.' but got '${providedToken.substring(0, 3)}...'. ` + - 'HTML comparison requires a public token for client-side usage.' - ); - } - - protected async execute( - input: TilesetComparisonInput, - providedToken?: string - ): Promise<{ type: 'text'; text: string }> { - // Ensure we have a valid public token - const accessToken = await this.ensurePublicToken( - input.accessToken || providedToken - ); - - // Process style URLs - if just an ID is provided, convert to full style URL - const processStyleUrl = (style: string): string => { - // If it's already a full URL, return as is - if (style.startsWith('mapbox://styles/')) { - return style; - } - // If it contains a slash, assume it's username/styleId format - if (style.includes('/')) { - return `mapbox://styles/${style}`; - } - // If it's just a style ID, try to get username from the token - try { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); - return `mapbox://styles/${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` - ); - } - }; - - const beforeStyle = processStyleUrl(input.before); - const afterStyle = processStyleUrl(input.after); - - // Build map initialization options - const mapOptions: string[] = []; - if (input.center) { - mapOptions.push(`center: [${input.center[0]}, ${input.center[1]}]`); - } - if (input.zoom !== undefined) { - mapOptions.push(`zoom: ${input.zoom}`); - } - if (input.bearing !== undefined) { - mapOptions.push(`bearing: ${input.bearing}`); - } - if (input.pitch !== undefined) { - mapOptions.push(`pitch: ${input.pitch}`); - } - - const mapOptionsString = - mapOptions.length > 0 - ? ',\n ' + mapOptions.join(',\n ') - : ''; - - // Build overlay HTML if title is provided - const overlay = input.title - ? `
-

${input.title}

- Before: ${input.before}
- After: ${input.after} -
` - : ''; - - // Replace placeholders in template - // At this point, accessToken is guaranteed to be defined due to our checks above - const html = COMPARISON_TEMPLATE.replace( - '{{title}}', - input.title || 'Tileset Comparison' - ) - .replace('{{overlay}}', overlay) - .replace('{{accessToken}}', accessToken as string) - .replace('{{beforeStyle}}', beforeStyle) - .replace('{{afterStyle}}', afterStyle) - .replace('{{beforeMapOptions}}', mapOptionsString) - .replace('{{afterMapOptions}}', mapOptionsString); - - // Return the HTML content - return { - type: 'text', - text: html - }; - } -} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index e650804..d13881f 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -9,7 +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 { TilesetComparisonTool } from './tileset-comparison-tool/TilesetComparisonTool.js'; +import { StyleComparisonTool } from './style-comparison-tool/StyleComparisonTool.js'; import { UpdateStyleTool } from './update-style-tool/UpdateStyleTool.js'; // Central registry of all tools @@ -26,7 +26,7 @@ export const ALL_TOOLS = [ new BoundingBoxTool(), new CountryBoundingBoxTool(), new CoordinateConversionTool(), - new TilesetComparisonTool() + new StyleComparisonTool() ] as const; export type ToolInstance = (typeof ALL_TOOLS)[number]; From 4678233578c3882b1b8666d252992ba8b41097d7 Mon Sep 17 00:00:00 2001 From: jussi-sa Date: Fri, 29 Aug 2025 12:34:42 +0300 Subject: [PATCH 4/5] style comparison updates --- .../StyleComparisonTool.schema.ts | 30 ++- .../StyleComparisonTool.test.ts | 214 ++++++++++-------- .../StyleComparisonTool.ts | 102 +++------ 3 files changed, 183 insertions(+), 163 deletions(-) diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts index 505ca4a..89a34f8 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts @@ -13,9 +13,37 @@ export const StyleComparisonSchema = z.object({ ), 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.' + ), + noCache: z + .boolean() + .optional() + .default(false) + .describe( + 'Set to true if either style has been recently updated to bypass caching and see the latest changes immediately. Set to false (default) to use cached versions for better performance. Only use true during development when you need to see style updates.' + ), + 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( - 'Mapbox public access token (must start with pk.* and have styles:read permission). If not provided, will attempt to find an existing public token from your account' + 'Longitude coordinate for the initial map center (-180 to 180). Must be provided together with latitude and zoom.' ) }); diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.test.ts b/src/tools/style-comparison-tool/StyleComparisonTool.test.ts index 60d1178..7b7e078 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.test.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -1,10 +1,8 @@ import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import { ListTokensTool } from '../list-tokens-tool/ListTokensTool.js'; import { StyleComparisonTool } from './StyleComparisonTool.js'; describe('StyleComparisonTool', () => { let tool: StyleComparisonTool; - let mockListTokensTool: jest.SpyInstance; beforeEach(() => { tool = new StyleComparisonTool(); @@ -33,37 +31,19 @@ describe('StyleComparisonTool', () => { expect(url).toContain('after=mapbox%2Foutdoors-v12'); }); - it('should attempt to fetch public token when no token provided', async () => { - mockListTokensTool = jest - .spyOn(ListTokensTool.prototype, 'run') - .mockResolvedValue({ - isError: false, - content: [ - { - type: 'text', - text: JSON.stringify({ - tokens: [ - { - token: 'pk.fetched.token', - name: 'Public Token' - } - ] - }) - } - ] - }); - + it('should require access token', async () => { const input = { before: 'mapbox/streets-v11', after: 'mapbox/satellite-v9' + // Missing accessToken }; - const result = await tool.run(input); + const result = await tool.run(input as any); - expect(result.isError).toBe(false); - expect(mockListTokensTool).toHaveBeenCalledWith({ usage: 'pk' }); - const url = (result.content[0] as { type: 'text'; text: string }).text; - expect(url).toContain('access_token=pk.fetched.token'); + expect(result.isError).toBe(true); + expect( + (result.content[0] as { type: 'text'; text: string }).text + ).toContain('Required'); }); it('should handle full style URLs', async () => { @@ -101,54 +81,7 @@ describe('StyleComparisonTool', () => { expect(url).toContain('after=testuser%2Fstyle-id-2'); }); - it('should reject secret tokens and try to fetch public token', async () => { - mockListTokensTool = jest - .spyOn(ListTokensTool.prototype, 'run') - .mockResolvedValue({ - isError: false, - content: [ - { - type: 'text', - text: JSON.stringify({ - tokens: [ - { - token: 'pk.fetched.public.token', - name: 'Public Token' - } - ] - }) - } - ] - }); - - const input = { - before: 'mapbox/streets-v11', - after: 'mapbox/outdoors-v12', - accessToken: 'sk.secret.token' - }; - - const result = await tool.run(input); - - expect(result.isError).toBe(false); - expect(mockListTokensTool).toHaveBeenCalledWith({ usage: 'pk' }); - const url = (result.content[0] as { type: 'text'; text: string }).text; - expect(url).toContain('access_token=pk.fetched.public.token'); - expect(url).not.toContain('sk.secret.token'); - }); - - it('should error when secret token provided and no public token available', async () => { - mockListTokensTool = jest - .spyOn(ListTokensTool.prototype, 'run') - .mockResolvedValue({ - isError: false, - content: [ - { - type: 'text', - text: JSON.stringify({ tokens: [] }) - } - ] - }); - + it('should reject secret tokens', async () => { const input = { before: 'mapbox/streets-v11', after: 'mapbox/outdoors-v12', @@ -160,25 +93,17 @@ describe('StyleComparisonTool', () => { expect(result.isError).toBe(true); expect( (result.content[0] as { type: 'text'; text: string }).text - ).toContain('Secret tokens (sk.*) cannot be used for style comparison'); + ).toContain('Invalid token type'); + expect( + (result.content[0] as { type: 'text'; text: string }).text + ).toContain('Secret tokens (sk.*) cannot be exposed'); }); - it('should return error when no token available', async () => { - mockListTokensTool = jest - .spyOn(ListTokensTool.prototype, 'run') - .mockResolvedValue({ - isError: false, - content: [ - { - type: 'text', - text: JSON.stringify({ tokens: [] }) - } - ] - }); - + it('should reject invalid token formats', async () => { const input = { before: 'mapbox/streets-v11', - after: 'mapbox/outdoors-v12' + after: 'mapbox/outdoors-v12', + accessToken: 'invalid.token' }; const result = await tool.run(input); @@ -186,7 +111,7 @@ describe('StyleComparisonTool', () => { expect(result.isError).toBe(true); expect( (result.content[0] as { type: 'text'; text: string }).text - ).toContain('No access token provided'); + ).toContain('Invalid token type'); }); it('should return error for style ID without valid username in token', async () => { @@ -228,6 +153,113 @@ describe('StyleComparisonTool', () => { expect(url).toContain('before=user-name%2Fstyle-id-1'); expect(url).toContain('after=user-name%2Fstyle-id-2'); }); + + it('should include nocache parameter when noCache is true', async () => { + const input = { + before: 'mapbox/streets-v11', + after: 'mapbox/outdoors-v12', + accessToken: 'pk.test.token', + noCache: true + }; + + 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('nocache=true'); + }); + + it('should not include nocache parameter when noCache is false or undefined', async () => { + const input = { + before: 'mapbox/streets-v11', + after: 'mapbox/outdoors-v12', + accessToken: 'pk.test.token', + noCache: false + }; + + const result = await tool.run(input); + + expect(result.isError).toBe(false); + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).not.toContain('nocache'); + + // Test with undefined (default) + const inputWithoutNoCache = { + before: 'mapbox/streets-v11', + after: 'mapbox/outdoors-v12', + accessToken: 'pk.test.token' + }; + + const result2 = await tool.run(inputWithoutNoCache); + expect(result2.isError).toBe(false); + const url2 = (result2.content[0] as { type: 'text'; text: string }).text; + expect(url2).not.toContain('nocache'); + }); + + 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('#'); + }); + + it('should handle both nocache and map position together', async () => { + const input = { + before: 'mapbox/streets-v11', + after: 'mapbox/outdoors-v12', + accessToken: 'pk.test.token', + noCache: true, + zoom: 12, + latitude: 37.7749, + longitude: -122.4194 + }; + + 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('nocache=true'); + expect(url).toContain('#12/37.7749/-122.4194'); + }); }); describe('metadata', () => { diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index d9fa2cd..c5fbeab 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -1,6 +1,5 @@ import { BaseTool } from '../BaseTool.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import { ListTokensTool } from '../list-tokens-tool/ListTokensTool.js'; import { StyleComparisonSchema, StyleComparisonInput @@ -18,68 +17,16 @@ export class StyleComparisonTool extends BaseTool< } /** - * Fetches the first available public token from the user's account + * Validates that the token is a public token */ - private async fetchPublicToken(): Promise { - try { - const listTokensTool = new ListTokensTool(); - const tokensResult = await listTokensTool.run({ - usage: 'pk' // Filter for public tokens only - }); - - if (!tokensResult.isError) { - const firstContent = tokensResult.content[0]; - if (firstContent.type === 'text') { - const tokensData = JSON.parse(firstContent.text); - const publicTokens = tokensData.tokens; - if (publicTokens && publicTokens.length > 0) { - return publicTokens[0].token; - } - } - } - } catch { - // Return null if fetching fails - } - return null; - } - - /** - * Ensures we have a valid public token for the comparison URL - */ - private async ensurePublicToken(providedToken?: string): Promise { - // If no token provided, try to get one from the account - if (!providedToken) { - const fetchedToken = await this.fetchPublicToken(); - if (!fetchedToken) { - throw new Error( - 'No access token provided and no public token found. Please provide a public access token (pk.*) with styles:read permission.' - ); - } - return fetchedToken; - } - - // If it's already a public token, use it - if (providedToken.startsWith('pk.')) { - return providedToken; - } - - // If it's a secret token, try to get a public token instead - if (providedToken.startsWith('sk.')) { - const publicToken = await this.fetchPublicToken(); - if (!publicToken) { - throw new Error( - 'Secret tokens (sk.*) cannot be used for style comparison. ' + - 'No public token found in your account. Please create a public token with styles:read permission or provide one directly.' - ); - } - return publicToken; + 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.` + ); } - - // Unknown token format - throw new Error( - `Invalid token format. Expected a public token starting with 'pk.' but got '${providedToken.substring(0, 3)}...'. ` + - 'Style comparison requires a public token with styles:read permission.' - ); } /** @@ -112,25 +59,38 @@ export class StyleComparisonTool extends BaseTool< } protected async execute( - input: StyleComparisonInput, - providedToken?: string + input: StyleComparisonInput ): Promise<{ type: 'text'; text: string }> { - // Ensure we have a valid public token - const accessToken = await this.ensurePublicToken( - input.accessToken || providedToken - ); + // 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, accessToken); - const afterStyleId = this.processStyleId(input.after, accessToken); + 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', accessToken); + params.append('access_token', input.accessToken); params.append('before', beforeStyleId); params.append('after', afterStyleId); - const url = `https://agent.mapbox.com/tools/style-compare?${params.toString()}`; + // Add nocache parameter if requested + if (input.noCache === true) { + params.append('nocache', 'true'); + } + + // 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', From e23c610d06adf131a3dc26a3a907a752a1917cc5 Mon Sep 17 00:00:00 2001 From: jussi-sa Date: Fri, 29 Aug 2025 12:45:38 +0300 Subject: [PATCH 5/5] remove no cache param --- .../StyleComparisonTool.schema.ts | 7 --- .../StyleComparisonTool.test.ts | 61 ------------------- .../StyleComparisonTool.ts | 5 -- 3 files changed, 73 deletions(-) diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts index 89a34f8..6f51be4 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts @@ -16,13 +16,6 @@ export const StyleComparisonSchema = z.object({ .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.' ), - noCache: z - .boolean() - .optional() - .default(false) - .describe( - 'Set to true if either style has been recently updated to bypass caching and see the latest changes immediately. Set to false (default) to use cached versions for better performance. Only use true during development when you need to see style updates.' - ), zoom: z .number() .optional() diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.test.ts b/src/tools/style-comparison-tool/StyleComparisonTool.test.ts index 7b7e078..f57750f 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.test.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -154,48 +154,6 @@ describe('StyleComparisonTool', () => { expect(url).toContain('after=user-name%2Fstyle-id-2'); }); - it('should include nocache parameter when noCache is true', async () => { - const input = { - before: 'mapbox/streets-v11', - after: 'mapbox/outdoors-v12', - accessToken: 'pk.test.token', - noCache: true - }; - - 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('nocache=true'); - }); - - it('should not include nocache parameter when noCache is false or undefined', async () => { - const input = { - before: 'mapbox/streets-v11', - after: 'mapbox/outdoors-v12', - accessToken: 'pk.test.token', - noCache: false - }; - - const result = await tool.run(input); - - expect(result.isError).toBe(false); - const url = (result.content[0] as { type: 'text'; text: string }).text; - expect(url).not.toContain('nocache'); - - // Test with undefined (default) - const inputWithoutNoCache = { - before: 'mapbox/streets-v11', - after: 'mapbox/outdoors-v12', - accessToken: 'pk.test.token' - }; - - const result2 = await tool.run(inputWithoutNoCache); - expect(result2.isError).toBe(false); - const url2 = (result2.content[0] as { type: 'text'; text: string }).text; - expect(url2).not.toContain('nocache'); - }); - it('should include hash fragment with map position when coordinates are provided', async () => { const input = { before: 'mapbox/streets-v11', @@ -241,25 +199,6 @@ describe('StyleComparisonTool', () => { const url2 = (result2.content[0] as { type: 'text'; text: string }).text; expect(url2).not.toContain('#'); }); - - it('should handle both nocache and map position together', async () => { - const input = { - before: 'mapbox/streets-v11', - after: 'mapbox/outdoors-v12', - accessToken: 'pk.test.token', - noCache: true, - zoom: 12, - latitude: 37.7749, - longitude: -122.4194 - }; - - 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('nocache=true'); - expect(url).toContain('#12/37.7749/-122.4194'); - }); }); describe('metadata', () => { diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index c5fbeab..334a858 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -74,11 +74,6 @@ export class StyleComparisonTool extends BaseTool< params.append('before', beforeStyleId); params.append('after', afterStyleId); - // Add nocache parameter if requested - if (input.noCache === true) { - params.append('nocache', 'true'); - } - // Build base URL let url = `https://agent.mapbox.com/tools/style-compare?${params.toString()}`;