diff --git a/CHANGELOG.md b/CHANGELOG.md
index 706edfa..15a100e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
## Unreleased
+### New Features
+
+- **Style ID validation for `style_comparison_tool`**: `before` and `after` inputs are now validated to contain only alphanumeric characters, hyphens, and underscores (after stripping the optional `mapbox://styles/` prefix). Validation is enforced at both the Zod schema layer and inside `processStyleId()`. Malformed style IDs are rejected with a descriptive error before any URL is constructed.
+
## 0.8.1 - 2026-06-11
### Changed
diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts
index ec25e25..1265c11 100644
--- a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts
+++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts
@@ -6,11 +6,19 @@ import { z } from 'zod';
export const StyleComparisonSchema = z.object({
before: z
.string()
+ .regex(
+ /^(?:mapbox:\/\/styles\/)?[a-zA-Z0-9_-]+(\/[a-zA-Z0-9_-]+)?$/,
+ 'Invalid style format. Use mapbox://styles/username/styleId, username/styleId, or a styleId containing only letters, numbers, hyphens, and underscores.'
+ )
.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()
+ .regex(
+ /^(?:mapbox:\/\/styles\/)?[a-zA-Z0-9_-]+(\/[a-zA-Z0-9_-]+)?$/,
+ 'Invalid style format. Use mapbox://styles/username/styleId, username/styleId, or a styleId containing only letters, numbers, hyphens, and underscores.'
+ )
.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'
),
diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts
index 82f7638..e916b5a 100644
--- a/src/tools/style-comparison-tool/StyleComparisonTool.ts
+++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts
@@ -40,33 +40,49 @@ export class StyleComparisonTool extends BaseTool<
super({ inputSchema: StyleComparisonSchema });
}
+ /**
+ * Validates that a resolved username/styleId contains only safe characters.
+ * Style IDs must be alphanumeric with hyphens and underscores only.
+ */
+ private validateStyleId(resolved: string): void {
+ if (!/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/.test(resolved)) {
+ throw new Error(
+ `Invalid style ID format: "${resolved}". ` +
+ `Style IDs must be in username/styleId format using only letters, numbers, hyphens, and underscores.`
+ );
+ }
+ }
+
/**
* Processes style input to extract username/styleId format
*/
private processStyleId(style: string, accessToken: string): string {
+ let resolved: 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;
+ resolved = style.replace('mapbox://styles/', '');
+ } else if (style.includes('/')) {
+ // If it contains a slash, assume it's already username/styleId format
+ resolved = style;
+ } else {
+ // If it's just a style ID, try to get username from the token
+ try {
+ const username = getUserNameFromToken(accessToken);
+ resolved = `${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`
+ );
+ }
}
- // If it's just a style ID, try to get username from the token
- try {
- const username = 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`
- );
- }
+ this.validateStyleId(resolved);
+ return resolved;
}
protected async execute(
diff --git a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts
index e715011..5024af7 100644
--- a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts
+++ b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts
@@ -130,6 +130,36 @@ describe('StyleComparisonTool', () => {
).toContain('Invalid token type');
});
+ it('should reject style IDs with invalid characters', async () => {
+ const input = {
+ before: 'mapbox/streets-v12',
+ after: 'bad
',
+ 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('Invalid style format');
+ });
+
+ it('should reject style URLs with invalid characters after stripping scheme', async () => {
+ const input = {
+ before: 'mapbox://styles/mapbox/streets-v12',
+ after: 'mapbox://styles/bad/evil"style',
+ 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('Invalid style format');
+ });
+
it('should return error for style ID without valid username in token', async () => {
// Mock getUserNameFromToken to throw an error
vi.spyOn(jwtUtils, 'getUserNameFromToken').mockImplementation(() => {