Skip to content

Commit d023bd6

Browse files
authored
Merge pull request #8 from mapbox/tileset-comparison
Style comparison
2 parents bdb9d6f + e23c610 commit d023bd6

File tree

5 files changed

+358
-1
lines changed

5 files changed

+358
-1
lines changed

src/tools/__snapshots__/tool-naming-convention.test.ts.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ exports[`Tool Naming Convention should maintain consistent tool list (snapshot t
5757
"description": "Retrieve a specific Mapbox style by ID",
5858
"toolName": "retrieve_style_tool",
5959
},
60+
{
61+
"className": "StyleComparisonTool",
62+
"description": "Generate a comparison URL for comparing two Mapbox styles side-by-side",
63+
"toolName": "style_comparison_tool",
64+
},
6065
{
6166
"className": "UpdateStyleTool",
6267
"description": "Update an existing Mapbox style",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { z } from 'zod';
2+
3+
export const StyleComparisonSchema = z.object({
4+
before: z
5+
.string()
6+
.describe(
7+
'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'
8+
),
9+
after: z
10+
.string()
11+
.describe(
12+
'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'
13+
),
14+
accessToken: z
15+
.string()
16+
.describe(
17+
'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.'
18+
),
19+
zoom: z
20+
.number()
21+
.optional()
22+
.describe(
23+
'Initial zoom level for the map view (0-22). If provided along with latitude and longitude, sets the initial map position.'
24+
),
25+
latitude: z
26+
.number()
27+
.min(-90)
28+
.max(90)
29+
.optional()
30+
.describe(
31+
'Latitude coordinate for the initial map center (-90 to 90). Must be provided together with longitude and zoom.'
32+
),
33+
longitude: z
34+
.number()
35+
.min(-180)
36+
.max(180)
37+
.optional()
38+
.describe(
39+
'Longitude coordinate for the initial map center (-180 to 180). Must be provided together with latitude and zoom.'
40+
)
41+
});
42+
43+
export type StyleComparisonInput = z.infer<typeof StyleComparisonSchema>;
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js';
2+
import { StyleComparisonTool } from './StyleComparisonTool.js';
3+
4+
describe('StyleComparisonTool', () => {
5+
let tool: StyleComparisonTool;
6+
7+
beforeEach(() => {
8+
tool = new StyleComparisonTool();
9+
});
10+
11+
afterEach(() => {
12+
jest.restoreAllMocks();
13+
});
14+
15+
describe('run', () => {
16+
it('should generate comparison URL with provided access token', async () => {
17+
const input = {
18+
before: 'mapbox/streets-v11',
19+
after: 'mapbox/outdoors-v12',
20+
accessToken: 'pk.test.token'
21+
};
22+
23+
const result = await tool.run(input);
24+
25+
expect(result.isError).toBe(false);
26+
expect(result.content[0].type).toBe('text');
27+
const url = (result.content[0] as { type: 'text'; text: string }).text;
28+
expect(url).toContain('https://agent.mapbox.com/tools/style-compare');
29+
expect(url).toContain('access_token=pk.test.token');
30+
expect(url).toContain('before=mapbox%2Fstreets-v11');
31+
expect(url).toContain('after=mapbox%2Foutdoors-v12');
32+
});
33+
34+
it('should require access token', async () => {
35+
const input = {
36+
before: 'mapbox/streets-v11',
37+
after: 'mapbox/satellite-v9'
38+
// Missing accessToken
39+
};
40+
41+
const result = await tool.run(input as any);
42+
43+
expect(result.isError).toBe(true);
44+
expect(
45+
(result.content[0] as { type: 'text'; text: string }).text
46+
).toContain('Required');
47+
});
48+
49+
it('should handle full style URLs', async () => {
50+
const input = {
51+
before: 'mapbox://styles/mapbox/streets-v11',
52+
after: 'mapbox://styles/mapbox/outdoors-v12',
53+
accessToken: 'pk.test.token'
54+
};
55+
56+
const result = await tool.run(input);
57+
58+
expect(result.isError).toBe(false);
59+
const url = (result.content[0] as { type: 'text'; text: string }).text;
60+
expect(url).toContain('before=mapbox%2Fstreets-v11');
61+
expect(url).toContain('after=mapbox%2Foutdoors-v12');
62+
});
63+
64+
it('should handle just style IDs with valid public token', async () => {
65+
// Mock MapboxApiBasedTool.getUserNameFromToken to return a username
66+
jest
67+
.spyOn(MapboxApiBasedTool, 'getUserNameFromToken')
68+
.mockReturnValue('testuser');
69+
70+
const input = {
71+
before: 'style-id-1',
72+
after: 'style-id-2',
73+
accessToken: 'pk.test.token'
74+
};
75+
76+
const result = await tool.run(input);
77+
78+
expect(result.isError).toBe(false);
79+
const url = (result.content[0] as { type: 'text'; text: string }).text;
80+
expect(url).toContain('before=testuser%2Fstyle-id-1');
81+
expect(url).toContain('after=testuser%2Fstyle-id-2');
82+
});
83+
84+
it('should reject secret tokens', async () => {
85+
const input = {
86+
before: 'mapbox/streets-v11',
87+
after: 'mapbox/outdoors-v12',
88+
accessToken: 'sk.secret.token'
89+
};
90+
91+
const result = await tool.run(input);
92+
93+
expect(result.isError).toBe(true);
94+
expect(
95+
(result.content[0] as { type: 'text'; text: string }).text
96+
).toContain('Invalid token type');
97+
expect(
98+
(result.content[0] as { type: 'text'; text: string }).text
99+
).toContain('Secret tokens (sk.*) cannot be exposed');
100+
});
101+
102+
it('should reject invalid token formats', async () => {
103+
const input = {
104+
before: 'mapbox/streets-v11',
105+
after: 'mapbox/outdoors-v12',
106+
accessToken: 'invalid.token'
107+
};
108+
109+
const result = await tool.run(input);
110+
111+
expect(result.isError).toBe(true);
112+
expect(
113+
(result.content[0] as { type: 'text'; text: string }).text
114+
).toContain('Invalid token type');
115+
});
116+
117+
it('should return error for style ID without valid username in token', async () => {
118+
// Mock getUserNameFromToken to throw an error
119+
jest
120+
.spyOn(MapboxApiBasedTool, 'getUserNameFromToken')
121+
.mockImplementation(() => {
122+
throw new Error(
123+
'MAPBOX_ACCESS_TOKEN does not contain username in payload'
124+
);
125+
});
126+
127+
const input = {
128+
before: 'style-id-only',
129+
after: 'mapbox/outdoors-v12',
130+
accessToken: 'pk.test.token'
131+
};
132+
133+
const result = await tool.run(input);
134+
135+
expect(result.isError).toBe(true);
136+
expect(
137+
(result.content[0] as { type: 'text'; text: string }).text
138+
).toContain('Could not determine username');
139+
});
140+
141+
it('should properly encode URL parameters', async () => {
142+
const input = {
143+
before: 'user-name/style-id-1',
144+
after: 'user-name/style-id-2',
145+
accessToken: 'pk.test.token'
146+
};
147+
148+
const result = await tool.run(input);
149+
150+
expect(result.isError).toBe(false);
151+
const url = (result.content[0] as { type: 'text'; text: string }).text;
152+
// Check that forward slashes are URL encoded
153+
expect(url).toContain('before=user-name%2Fstyle-id-1');
154+
expect(url).toContain('after=user-name%2Fstyle-id-2');
155+
});
156+
157+
it('should include hash fragment with map position when coordinates are provided', async () => {
158+
const input = {
159+
before: 'mapbox/streets-v11',
160+
after: 'mapbox/outdoors-v12',
161+
accessToken: 'pk.test.token',
162+
zoom: 5.72,
163+
latitude: 9.503,
164+
longitude: -67.473
165+
};
166+
167+
const result = await tool.run(input);
168+
169+
expect(result.isError).toBe(false);
170+
const url = (result.content[0] as { type: 'text'; text: string }).text;
171+
expect(url).toContain('#5.72/9.503/-67.473');
172+
});
173+
174+
it('should not include hash fragment when coordinates are incomplete', async () => {
175+
// Only zoom provided
176+
const input1 = {
177+
before: 'mapbox/streets-v11',
178+
after: 'mapbox/outdoors-v12',
179+
accessToken: 'pk.test.token',
180+
zoom: 10
181+
};
182+
183+
const result1 = await tool.run(input1);
184+
expect(result1.isError).toBe(false);
185+
const url1 = (result1.content[0] as { type: 'text'; text: string }).text;
186+
expect(url1).not.toContain('#');
187+
188+
// Only latitude and longitude, no zoom
189+
const input2 = {
190+
before: 'mapbox/streets-v11',
191+
after: 'mapbox/outdoors-v12',
192+
accessToken: 'pk.test.token',
193+
latitude: 40.7128,
194+
longitude: -74.006
195+
};
196+
197+
const result2 = await tool.run(input2);
198+
expect(result2.isError).toBe(false);
199+
const url2 = (result2.content[0] as { type: 'text'; text: string }).text;
200+
expect(url2).not.toContain('#');
201+
});
202+
});
203+
204+
describe('metadata', () => {
205+
it('should have correct name and description', () => {
206+
expect(tool.name).toBe('style_comparison_tool');
207+
expect(tool.description).toBe(
208+
'Generate a comparison URL for comparing two Mapbox styles side-by-side'
209+
);
210+
});
211+
});
212+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { BaseTool } from '../BaseTool.js';
2+
import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js';
3+
import {
4+
StyleComparisonSchema,
5+
StyleComparisonInput
6+
} from './StyleComparisonTool.schema.js';
7+
8+
export class StyleComparisonTool extends BaseTool<
9+
typeof StyleComparisonSchema
10+
> {
11+
readonly name = 'style_comparison_tool';
12+
readonly description =
13+
'Generate a comparison URL for comparing two Mapbox styles side-by-side';
14+
15+
constructor() {
16+
super({ inputSchema: StyleComparisonSchema });
17+
}
18+
19+
/**
20+
* Validates that the token is a public token
21+
*/
22+
private validatePublicToken(token: string): void {
23+
if (!token.startsWith('pk.')) {
24+
throw new Error(
25+
`Invalid token type. Style comparison requires a public token (pk.*) that can be used in browser URLs. ` +
26+
`Secret tokens (sk.*) cannot be exposed in client-side applications. ` +
27+
`Please provide a public token with styles:read permission.`
28+
);
29+
}
30+
}
31+
32+
/**
33+
* Processes style input to extract username/styleId format
34+
*/
35+
private processStyleId(style: string, accessToken: string): string {
36+
// If it's a full URL, extract the username/styleId part
37+
if (style.startsWith('mapbox://styles/')) {
38+
return style.replace('mapbox://styles/', '');
39+
}
40+
41+
// If it contains a slash, assume it's already username/styleId format
42+
if (style.includes('/')) {
43+
return style;
44+
}
45+
46+
// If it's just a style ID, try to get username from the token
47+
try {
48+
const username = MapboxApiBasedTool.getUserNameFromToken(accessToken);
49+
return `${username}/${style}`;
50+
} catch (error) {
51+
throw new Error(
52+
`Could not determine username for style ID "${style}". ${error instanceof Error ? error.message : ''}\n` +
53+
`Please provide either:\n` +
54+
`1. Full style URL: mapbox://styles/username/${style}\n` +
55+
`2. Username/styleId format: username/${style}\n` +
56+
`3. Just the style ID with a valid Mapbox token that contains username information`
57+
);
58+
}
59+
}
60+
61+
protected async execute(
62+
input: StyleComparisonInput
63+
): Promise<{ type: 'text'; text: string }> {
64+
// Validate that we have a public token
65+
this.validatePublicToken(input.accessToken);
66+
67+
// Process style IDs to get username/styleId format
68+
const beforeStyleId = this.processStyleId(input.before, input.accessToken);
69+
const afterStyleId = this.processStyleId(input.after, input.accessToken);
70+
71+
// Build the comparison URL
72+
const params = new URLSearchParams();
73+
params.append('access_token', input.accessToken);
74+
params.append('before', beforeStyleId);
75+
params.append('after', afterStyleId);
76+
77+
// Build base URL
78+
let url = `https://agent.mapbox.com/tools/style-compare?${params.toString()}`;
79+
80+
// Add hash fragment for map position if all coordinates are provided
81+
if (
82+
input.zoom !== undefined &&
83+
input.latitude !== undefined &&
84+
input.longitude !== undefined
85+
) {
86+
// Format: #zoom/latitude/longitude
87+
url += `#${input.zoom}/${input.latitude}/${input.longitude}`;
88+
}
89+
90+
return {
91+
type: 'text',
92+
text: url
93+
};
94+
}
95+
}

src/tools/toolRegistry.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ListStylesTool } from './list-styles-tool/ListStylesTool.js';
99
import { ListTokensTool } from './list-tokens-tool/ListTokensTool.js';
1010
import { PreviewStyleTool } from './preview-style-tool/PreviewStyleTool.js';
1111
import { RetrieveStyleTool } from './retrieve-style-tool/RetrieveStyleTool.js';
12+
import { StyleComparisonTool } from './style-comparison-tool/StyleComparisonTool.js';
1213
import { UpdateStyleTool } from './update-style-tool/UpdateStyleTool.js';
1314

1415
// Central registry of all tools
@@ -24,7 +25,8 @@ export const ALL_TOOLS = [
2425
new ListTokensTool(),
2526
new BoundingBoxTool(),
2627
new CountryBoundingBoxTool(),
27-
new CoordinateConversionTool()
28+
new CoordinateConversionTool(),
29+
new StyleComparisonTool()
2830
] as const;
2931

3032
export type ToolInstance = (typeof ALL_TOOLS)[number];

0 commit comments

Comments
 (0)