diff --git a/README.md b/README.md index d8f64a8..cc25fa3 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,17 @@ Complete set of tools for managing Mapbox styles via the Styles API: - Returns: URL to open the style preview in browser - **Note**: This tool automatically fetches the first available public token from your account for the preview URL. Requires at least one public token with `styles:read` scope. +**ValidateStyleTool** - Validate Mapbox style JSON against the Mapbox Style Specification + +- Input: `style` (Mapbox style JSON object or JSON string) +- Returns: Validation results including errors, warnings, info messages, and style summary +- Performs comprehensive offline validation checking: + - Required fields (version, sources, layers) + - Valid layer and source types + - Source references and layer IDs + - Common configuration issues +- **Note**: This is an offline validation tool that doesn't require API access or token scopes + **⚠️ Required Token Scopes:** **All style tools require a valid Mapbox access token with specific scopes. Using a token without the correct scope will result in authentication errors.** diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 087b595..f57611a 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -20,6 +20,7 @@ import { StyleBuilderTool } from './style-builder-tool/StyleBuilderTool.js'; import { StyleComparisonTool } from './style-comparison-tool/StyleComparisonTool.js'; import { TilequeryTool } from './tilequery-tool/TilequeryTool.js'; import { UpdateStyleTool } from './update-style-tool/UpdateStyleTool.js'; +import { ValidateStyleTool } from './validate-style-tool/ValidateStyleTool.js'; import { httpRequest } from '../utils/httpPipeline.js'; // Central registry of all tools @@ -42,7 +43,8 @@ export const ALL_TOOLS = [ new GetMapboxDocSourceTool({ httpRequest }), new GetReferenceTool(), new StyleComparisonTool(), - new TilequeryTool({ httpRequest }) + new TilequeryTool({ httpRequest }), + new ValidateStyleTool() ] as const; export type ToolInstance = (typeof ALL_TOOLS)[number]; diff --git a/src/tools/validate-style-tool/ValidateStyleTool.input.schema.ts b/src/tools/validate-style-tool/ValidateStyleTool.input.schema.ts new file mode 100644 index 0000000..21522f0 --- /dev/null +++ b/src/tools/validate-style-tool/ValidateStyleTool.input.schema.ts @@ -0,0 +1,21 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +/** + * Input schema for ValidateStyleTool + * Validates Mapbox GL JS style JSON against the style specification + */ +export const ValidateStyleInputSchema = z.object({ + style: z + .union([z.string(), z.record(z.unknown())]) + .describe( + 'Mapbox style JSON object or JSON string to validate against the Mapbox Style Specification' + ) +}); + +/** + * Inferred TypeScript type for ValidateStyleTool input + */ +export type ValidateStyleInput = z.infer; diff --git a/src/tools/validate-style-tool/ValidateStyleTool.output.schema.ts b/src/tools/validate-style-tool/ValidateStyleTool.output.schema.ts new file mode 100644 index 0000000..4afb75f --- /dev/null +++ b/src/tools/validate-style-tool/ValidateStyleTool.output.schema.ts @@ -0,0 +1,45 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +const ValidationIssueSchema = z.object({ + severity: z + .enum(['error', 'warning', 'info']) + .describe('Severity level of the issue'), + message: z.string().describe('Description of the validation issue'), + path: z.string().optional().describe('JSON path to the problematic property'), + suggestion: z.string().optional().describe('Suggested fix for the issue') +}); + +/** + * Output schema for ValidateStyleTool + * Returns comprehensive validation results for a Mapbox style JSON + */ +export const ValidateStyleOutputSchema = z.object({ + valid: z.boolean().describe('Whether the style is valid'), + errors: z + .array(ValidationIssueSchema) + .describe('Critical errors that prevent the style from working'), + warnings: z + .array(ValidationIssueSchema) + .describe('Non-critical issues that may cause unexpected behavior'), + info: z + .array(ValidationIssueSchema) + .describe('Informational messages and suggestions for improvement'), + summary: z + .object({ + version: z.number().optional().describe('Style specification version'), + layerCount: z.number().describe('Number of layers'), + sourceCount: z.number().describe('Number of sources'), + hasSprite: z.boolean().describe('Whether style has sprite defined'), + hasGlyphs: z.boolean().describe('Whether style has glyphs defined') + }) + .describe('Summary of style structure') +}); + +/** + * Type inference for ValidateStyleOutput + */ +export type ValidateStyleOutput = z.infer; +export type ValidationIssue = z.infer; diff --git a/src/tools/validate-style-tool/ValidateStyleTool.ts b/src/tools/validate-style-tool/ValidateStyleTool.ts new file mode 100644 index 0000000..99b4c1b --- /dev/null +++ b/src/tools/validate-style-tool/ValidateStyleTool.ts @@ -0,0 +1,430 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseTool } from '../BaseTool.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { ValidateStyleInputSchema } from './ValidateStyleTool.input.schema.js'; +import { + ValidateStyleOutputSchema, + type ValidateStyleOutput, + type ValidationIssue +} from './ValidateStyleTool.output.schema.js'; + +interface MapboxStyle { + version?: number; + name?: string; + sources?: Record; + layers?: Array; + sprite?: string; + glyphs?: string; + [key: string]: any; +} + +/** + * ValidateStyleTool - Validates Mapbox GL JS style JSON + * + * Performs comprehensive validation of Mapbox style JSON against the Mapbox Style Specification. + * Checks for required fields, valid layer types, source references, and common configuration issues. + * + * @example + * ```typescript + * const tool = new ValidateStyleTool(); + * const result = await tool.run({ + * style: { version: 8, sources: {}, layers: [] } + * }); + * ``` + */ +export class ValidateStyleTool extends BaseTool< + typeof ValidateStyleInputSchema, + typeof ValidateStyleOutputSchema +> { + readonly name = 'validate_style_tool'; + readonly description = + 'Validates Mapbox style JSON against the Mapbox Style Specification, checking for errors, warnings, and providing suggestions for improvement'; + readonly annotations = { + title: 'Validate Style Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }; + + private static readonly VALID_LAYER_TYPES = [ + 'fill', + 'line', + 'symbol', + 'circle', + 'heatmap', + 'fill-extrusion', + 'raster', + 'hillshade', + 'background', + 'sky' + ]; + + private static readonly VALID_SOURCE_TYPES = [ + 'vector', + 'raster', + 'raster-dem', + 'geojson', + 'image', + 'video' + ]; + + constructor() { + super({ + inputSchema: ValidateStyleInputSchema, + outputSchema: ValidateStyleOutputSchema + }); + } + + /** + * Execute the validation + */ + protected async execute( + input: z.infer + ): Promise { + try { + // Parse style if it's a string + let style: MapboxStyle; + if (typeof input.style === 'string') { + try { + style = JSON.parse(input.style); + } catch (parseError) { + return { + content: [ + { + type: 'text', + text: `Error parsing style JSON: ${(parseError as Error).message}` + } + ], + isError: true + }; + } + } else { + style = input.style as MapboxStyle; + } + + const errors: ValidationIssue[] = []; + const warnings: ValidationIssue[] = []; + const info: ValidationIssue[] = []; + + // Validate structure + this.validateStructure(style, errors, warnings, info); + this.validateSources(style, errors, warnings, info); + this.validateLayers(style, errors, warnings, info); + this.validateReferences(style, errors, warnings, info); + + const result: ValidateStyleOutput = { + valid: errors.length === 0, + errors, + warnings, + info, + summary: { + version: style.version, + layerCount: style.layers?.length || 0, + sourceCount: Object.keys(style.sources || {}).length, + hasSprite: !!style.sprite, + hasGlyphs: !!style.glyphs + } + }; + + const validatedResult = ValidateStyleOutputSchema.parse(result); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(validatedResult, null, 2) + } + ], + structuredContent: validatedResult, + isError: false + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.log('error', `${this.name}: ${errorMessage}`); + + return { + content: [{ type: 'text', text: `Error: ${errorMessage}` }], + isError: true + }; + } + } + + private validateStructure( + style: MapboxStyle, + errors: ValidationIssue[], + warnings: ValidationIssue[], + info: ValidationIssue[] + ): void { + // Check version + if (!style.version) { + errors.push({ + severity: 'error', + message: 'Missing required "version" property', + path: 'version', + suggestion: 'Add "version": 8 to your style' + }); + } else if (style.version !== 8) { + warnings.push({ + severity: 'warning', + message: `Style version is ${style.version}, but version 8 is the current standard`, + path: 'version', + suggestion: 'Consider updating to version 8' + }); + } + + // Check layers array + if (!style.layers) { + errors.push({ + severity: 'error', + message: 'Missing required "layers" array', + path: 'layers', + suggestion: 'Add "layers": [] to your style' + }); + } else if (!Array.isArray(style.layers)) { + errors.push({ + severity: 'error', + message: '"layers" must be an array', + path: 'layers' + }); + } else if (style.layers.length === 0) { + warnings.push({ + severity: 'warning', + message: 'Style has no layers', + path: 'layers', + suggestion: 'Add at least one layer to make your map visible' + }); + } + + // Check sources object + if (!style.sources) { + errors.push({ + severity: 'error', + message: 'Missing required "sources" object', + path: 'sources', + suggestion: 'Add "sources": {} to your style' + }); + } else if ( + typeof style.sources !== 'object' || + Array.isArray(style.sources) + ) { + errors.push({ + severity: 'error', + message: '"sources" must be an object', + path: 'sources' + }); + } + + // Check sprite + if (!style.sprite) { + info.push({ + severity: 'info', + message: 'No sprite URL defined', + path: 'sprite', + suggestion: 'Add a sprite URL if you plan to use icons in symbol layers' + }); + } + + // Check glyphs + if (!style.glyphs) { + info.push({ + severity: 'info', + message: 'No glyphs URL defined', + path: 'glyphs', + suggestion: 'Add a glyphs URL if you plan to use text labels' + }); + } + } + + private validateSources( + style: MapboxStyle, + errors: ValidationIssue[], + _warnings: ValidationIssue[], + info: ValidationIssue[] + ): void { + if (!style.sources || typeof style.sources !== 'object') { + return; + } + + const sourceIds = Object.keys(style.sources); + + if (sourceIds.length === 0) { + info.push({ + severity: 'info', + message: 'No sources defined', + path: 'sources', + suggestion: + 'Add sources to provide data for your layers (e.g., vector tiles, GeoJSON)' + }); + } + + for (const [sourceId, source] of Object.entries(style.sources)) { + if (!source || typeof source !== 'object') { + errors.push({ + severity: 'error', + message: `Source "${sourceId}" is not a valid object`, + path: `sources.${sourceId}` + }); + continue; + } + + // Check source type + if (!source.type) { + errors.push({ + severity: 'error', + message: `Source "${sourceId}" is missing required "type" property`, + path: `sources.${sourceId}.type`, + suggestion: `Specify one of: ${ValidateStyleTool.VALID_SOURCE_TYPES.join(', ')}` + }); + } else if (!ValidateStyleTool.VALID_SOURCE_TYPES.includes(source.type)) { + errors.push({ + severity: 'error', + message: `Source "${sourceId}" has invalid type "${source.type}"`, + path: `sources.${sourceId}.type`, + suggestion: `Valid types are: ${ValidateStyleTool.VALID_SOURCE_TYPES.join(', ')}` + }); + } + + // Type-specific validation + if (source.type === 'vector' || source.type === 'raster') { + if (!source.url && !source.tiles) { + errors.push({ + severity: 'error', + message: `Source "${sourceId}" must have either "url" or "tiles" property`, + path: `sources.${sourceId}`, + suggestion: 'Add a "url" or "tiles" array to specify tile data' + }); + } + } + + if (source.type === 'geojson') { + if (!source.data) { + errors.push({ + severity: 'error', + message: `GeoJSON source "${sourceId}" is missing required "data" property`, + path: `sources.${sourceId}.data`, + suggestion: + 'Add "data" property with GeoJSON object or URL to GeoJSON file' + }); + } + } + } + } + + private validateLayers( + style: MapboxStyle, + errors: ValidationIssue[], + warnings: ValidationIssue[], + _info: ValidationIssue[] + ): void { + if (!style.layers || !Array.isArray(style.layers)) { + return; + } + + const layerIds = new Set(); + + for (let i = 0; i < style.layers.length; i++) { + const layer = style.layers[i]; + const layerPath = `layers[${i}]`; + + // Check layer ID + if (!layer.id) { + errors.push({ + severity: 'error', + message: `Layer at index ${i} is missing required "id" property`, + path: layerPath, + suggestion: 'Add a unique "id" string to identify this layer' + }); + } else { + // Check for duplicate IDs + if (layerIds.has(layer.id)) { + errors.push({ + severity: 'error', + message: `Duplicate layer ID "${layer.id}"`, + path: `${layerPath}.id`, + suggestion: 'Each layer must have a unique ID' + }); + } + layerIds.add(layer.id); + } + + // Check layer type + if (!layer.type) { + errors.push({ + severity: 'error', + message: `Layer "${layer.id || `at index ${i}`}" is missing required "type" property`, + path: `${layerPath}.type`, + suggestion: `Specify one of: ${ValidateStyleTool.VALID_LAYER_TYPES.join(', ')}` + }); + } else if (!ValidateStyleTool.VALID_LAYER_TYPES.includes(layer.type)) { + errors.push({ + severity: 'error', + message: `Layer "${layer.id}" has invalid type "${layer.type}"`, + path: `${layerPath}.type`, + suggestion: `Valid types are: ${ValidateStyleTool.VALID_LAYER_TYPES.join(', ')}` + }); + } + + // Check source requirement + if (layer.type !== 'background' && layer.type !== 'sky') { + if (!layer.source) { + errors.push({ + severity: 'error', + message: `Layer "${layer.id}" of type "${layer.type}" must have a "source" property`, + path: `${layerPath}.source`, + suggestion: 'Reference a source ID defined in the "sources" object' + }); + } + } + + // Check source-layer for vector sources + if ( + layer.type !== 'background' && + layer.type !== 'sky' && + layer.type !== 'raster' + ) { + const source = style.sources?.[layer.source]; + if (source?.type === 'vector' && !layer['source-layer']) { + warnings.push({ + severity: 'warning', + message: `Layer "${layer.id}" uses vector source but missing "source-layer"`, + path: `${layerPath}.source-layer`, + suggestion: + 'Specify which source layer from the vector tileset to use' + }); + } + } + } + } + + private validateReferences( + style: MapboxStyle, + errors: ValidationIssue[], + _warnings: ValidationIssue[], + _info: ValidationIssue[] + ): void { + if (!style.layers || !Array.isArray(style.layers)) { + return; + } + + const sourceIds = new Set(Object.keys(style.sources || {})); + + for (let i = 0; i < style.layers.length; i++) { + const layer = style.layers[i]; + + // Check if referenced source exists + if (layer.source && !sourceIds.has(layer.source)) { + errors.push({ + severity: 'error', + message: `Layer "${layer.id || `at index ${i}`}" references non-existent source "${layer.source}"`, + path: `layers[${i}].source`, + suggestion: `Source "${layer.source}" is not defined in the "sources" object` + }); + } + } + } +} diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index ff8cefb..a89df3d 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -157,5 +157,10 @@ If a layer type is not recognized, the tool will provide helpful suggestions sho "description": "Update an existing Mapbox style", "toolName": "update_style_tool", }, + { + "className": "ValidateStyleTool", + "description": "Validates Mapbox style JSON against the Mapbox Style Specification, checking for errors, warnings, and providing suggestions for improvement", + "toolName": "validate_style_tool", + }, ] `; diff --git a/test/tools/validate-style-tool/ValidateStyleTool.test.ts b/test/tools/validate-style-tool/ValidateStyleTool.test.ts new file mode 100644 index 0000000..bfa1a9b --- /dev/null +++ b/test/tools/validate-style-tool/ValidateStyleTool.test.ts @@ -0,0 +1,251 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ValidateStyleTool } from '../../../src/tools/validate-style-tool/ValidateStyleTool.js'; + +describe('ValidateStyleTool', () => { + let tool: ValidateStyleTool; + + beforeEach(() => { + tool = new ValidateStyleTool(); + }); + + it('should have correct tool metadata', () => { + expect(tool.name).toBe('validate_style_tool'); + expect(tool.description).toBeTruthy(); + expect(tool.annotations).toBeDefined(); + }); + + it('should validate a valid minimal style', async () => { + const validStyle = { + version: 8, + sources: { + 'my-source': { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + } + }, + layers: [ + { + id: 'background', + type: 'background', + paint: { 'background-color': '#000000' } + } + ] + }; + + const result = await tool.run({ style: validStyle }); + + expect(result.isError).toBe(false); + const parsedResponse = JSON.parse(result.content[0].text); + expect(parsedResponse.valid).toBe(true); + expect(parsedResponse.errors).toHaveLength(0); + expect(parsedResponse.summary.layerCount).toBe(1); + expect(parsedResponse.summary.sourceCount).toBe(1); + }); + + it('should detect missing version', async () => { + const invalidStyle = { + sources: {}, + layers: [] + }; + + const result = await tool.run({ style: invalidStyle }); + + expect(result.isError).toBe(false); + const parsedResponse = JSON.parse(result.content[0].text); + expect(parsedResponse.valid).toBe(false); + expect(parsedResponse.errors).toContainEqual( + expect.objectContaining({ + severity: 'error', + message: 'Missing required "version" property' + }) + ); + }); + + it('should detect missing layers array', async () => { + const invalidStyle = { + version: 8, + sources: {} + }; + + const result = await tool.run({ style: invalidStyle }); + + expect(result.isError).toBe(false); + const parsedResponse = JSON.parse(result.content[0].text); + expect(parsedResponse.valid).toBe(false); + expect(parsedResponse.errors).toContainEqual( + expect.objectContaining({ + severity: 'error', + message: 'Missing required "layers" array' + }) + ); + }); + + it('should detect invalid layer type', async () => { + const invalidStyle = { + version: 8, + sources: {}, + layers: [ + { + id: 'test-layer', + type: 'invalid-type', + source: 'test-source' + } + ] + }; + + const result = await tool.run({ style: invalidStyle }); + + expect(result.isError).toBe(false); + const parsedResponse = JSON.parse(result.content[0].text); + expect(parsedResponse.valid).toBe(false); + expect(parsedResponse.errors).toContainEqual( + expect.objectContaining({ + severity: 'error', + message: expect.stringContaining('invalid type') + }) + ); + }); + + it('should detect duplicate layer IDs', async () => { + const invalidStyle = { + version: 8, + sources: { + 'test-source': { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + } + }, + layers: [ + { + id: 'duplicate-id', + type: 'fill', + source: 'test-source' + }, + { + id: 'duplicate-id', + type: 'line', + source: 'test-source' + } + ] + }; + + const result = await tool.run({ style: invalidStyle }); + + expect(result.isError).toBe(false); + const parsedResponse = JSON.parse(result.content[0].text); + expect(parsedResponse.valid).toBe(false); + expect(parsedResponse.errors).toContainEqual( + expect.objectContaining({ + severity: 'error', + message: 'Duplicate layer ID "duplicate-id"' + }) + ); + }); + + it('should detect non-existent source reference', async () => { + const invalidStyle = { + version: 8, + sources: {}, + layers: [ + { + id: 'test-layer', + type: 'fill', + source: 'non-existent-source' + } + ] + }; + + const result = await tool.run({ style: invalidStyle }); + + expect(result.isError).toBe(false); + const parsedResponse = JSON.parse(result.content[0].text); + expect(parsedResponse.valid).toBe(false); + expect(parsedResponse.errors).toContainEqual( + expect.objectContaining({ + severity: 'error', + message: expect.stringContaining('references non-existent source') + }) + ); + }); + + it('should accept style as JSON string', async () => { + const validStyle = { + version: 8, + sources: {}, + layers: [] + }; + + const result = await tool.run({ style: JSON.stringify(validStyle) }); + + expect(result.isError).toBe(false); + const parsedResponse = JSON.parse(result.content[0].text); + expect(parsedResponse).toHaveProperty('valid'); + expect(parsedResponse).toHaveProperty('errors'); + }); + + it('should return error for invalid JSON string', async () => { + const result = await tool.run({ style: '{ invalid json }' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error parsing style JSON'); + }); + + it('should provide warnings for missing sprite and glyphs', async () => { + const style = { + version: 8, + sources: {}, + layers: [] + }; + + const result = await tool.run({ style }); + + expect(result.isError).toBe(false); + const parsedResponse = JSON.parse(result.content[0].text); + expect(parsedResponse.info).toContainEqual( + expect.objectContaining({ + severity: 'info', + message: 'No sprite URL defined' + }) + ); + expect(parsedResponse.info).toContainEqual( + expect.objectContaining({ + severity: 'info', + message: 'No glyphs URL defined' + }) + ); + }); + + it('should include summary information', async () => { + const style = { + version: 8, + sources: { + source1: { type: 'vector', url: 'mapbox://mapbox.mapbox-streets-v8' }, + source2: { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + } + }, + layers: [ + { id: 'layer1', type: 'background' }, + { id: 'layer2', type: 'fill', source: 'source2' } + ], + sprite: 'mapbox://sprites/mapbox/streets-v11', + glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf' + }; + + const result = await tool.run({ style }); + + expect(result.isError).toBe(false); + const parsedResponse = JSON.parse(result.content[0].text); + expect(parsedResponse.summary).toEqual({ + version: 8, + layerCount: 2, + sourceCount: 2, + hasSprite: true, + hasGlyphs: true + }); + }); +});