diff --git a/README.md b/README.md index e8d6913..24461bc 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ https://github.com/user-attachments/assets/8b1b8ef2-9fba-4951-bc9a-beaed4f6aff6 - [GeoJSON Preview tool (Beta)](#geojson-preview-tool-beta) - [Coordinate Conversion tool](#coordinate-conversion-tool) - [Bounding Box tool](#bounding-box-tool) + - [compare_styles_tool](#compare_styles_tool) - [Style Optimization tool](#style-optimization-tool) - [Resources](#resources) - [Observability \& Tracing](#observability--tracing) @@ -624,6 +625,53 @@ An array of four numbers representing the bounding box: `[minX, minY, maxX, maxY - "Calculate the bounding box of this GeoJSON file" (then upload a .geojson file) - "What's the bounding box for the coordinates in the uploaded parks.geojson file?" +#### compare_styles_tool + +Compares two Mapbox styles and reports structural differences, including changes to layers, sources, and properties. This offline comparison tool performs deep object comparison without requiring API access. + +**Parameters:** + +- `styleA` (string or object, required): First Mapbox style to compare (JSON string or style object) +- `styleB` (string or object, required): Second Mapbox style to compare (JSON string or style object) +- `ignoreMetadata` (boolean, optional): If true, ignores metadata fields (id, owner, created, modified, draft, visibility) when comparing + +**Comparison features:** + +- Deep recursive comparison of nested structures +- Layer comparison by ID (not array position) +- Detailed diff reporting with JSON paths +- Identifies additions, removals, and modifications +- Optional metadata filtering + +**Returns:** + +```json +{ + "identical": false, + "differences": [ + { + "path": "layers.water.paint.fill-color", + "type": "modified", + "valueA": "#a0c8f0", + "valueB": "#b0d0ff", + "description": "Modified property at layers.water.paint.fill-color" + } + ], + "summary": { + "totalDifferences": 1, + "added": 0, + "removed": 0, + "modified": 1 + } +} +``` + +**Example prompts:** + +- "Compare these two Mapbox styles and show me the differences" +- "What changed between my old style and new style?" +- "Compare styles ignoring metadata fields" + #### Style Optimization tool Optimizes Mapbox styles by removing redundancies, simplifying expressions, and reducing file size. diff --git a/src/tools/compare-styles-tool/CompareStylesTool.input.schema.ts b/src/tools/compare-styles-tool/CompareStylesTool.input.schema.ts new file mode 100644 index 0000000..673b9cf --- /dev/null +++ b/src/tools/compare-styles-tool/CompareStylesTool.input.schema.ts @@ -0,0 +1,19 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const CompareStylesInputSchema = z.object({ + styleA: z + .union([z.string(), z.record(z.unknown())]) + .describe('First Mapbox style (JSON string or style object)'), + styleB: z + .union([z.string(), z.record(z.unknown())]) + .describe('Second Mapbox style (JSON string or style object)'), + ignoreMetadata: z + .boolean() + .optional() + .describe('Ignore metadata fields like id, owner, created, modified') +}); + +export type CompareStylesInput = z.infer; diff --git a/src/tools/compare-styles-tool/CompareStylesTool.output.schema.ts b/src/tools/compare-styles-tool/CompareStylesTool.output.schema.ts new file mode 100644 index 0000000..64705a2 --- /dev/null +++ b/src/tools/compare-styles-tool/CompareStylesTool.output.schema.ts @@ -0,0 +1,28 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +const DifferenceSchema = z.object({ + path: z.string().describe('JSON path to the difference'), + type: z.enum(['added', 'removed', 'modified']).describe('Type of difference'), + valueA: z.unknown().optional().describe('Value in style A (if exists)'), + valueB: z.unknown().optional().describe('Value in style B (if exists)'), + description: z.string().optional().describe('Human-readable description') +}); + +export const CompareStylesOutputSchema = z.object({ + identical: z.boolean().describe('Whether the styles are identical'), + differences: z.array(DifferenceSchema).describe('List of differences found'), + summary: z + .object({ + totalDifferences: z.number().describe('Total number of differences'), + added: z.number().describe('Number of additions in style B'), + removed: z.number().describe('Number of removals from style A'), + modified: z.number().describe('Number of modifications') + }) + .describe('Summary of differences') +}); + +export type CompareStylesOutput = z.infer; +export type Difference = z.infer; diff --git a/src/tools/compare-styles-tool/CompareStylesTool.ts b/src/tools/compare-styles-tool/CompareStylesTool.ts new file mode 100644 index 0000000..96a2655 --- /dev/null +++ b/src/tools/compare-styles-tool/CompareStylesTool.ts @@ -0,0 +1,315 @@ +// 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 { CompareStylesInputSchema } from './CompareStylesTool.input.schema.js'; +import { + CompareStylesOutputSchema, + type CompareStylesOutput, + type Difference +} from './CompareStylesTool.output.schema.js'; + +/** + * CompareStylesTool - Compares two Mapbox styles and reports differences + * + * Performs deep comparison of two Mapbox style JSON objects, identifying + * additions, removals, and modifications. + */ +export class CompareStylesTool extends BaseTool< + typeof CompareStylesInputSchema, + typeof CompareStylesOutputSchema +> { + readonly name = 'compare_styles_tool'; + readonly description = + 'Compares two Mapbox styles and reports differences in structure, layers, sources, and properties'; + readonly annotations = { + title: 'Compare Styles Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }; + + // Metadata fields to ignore when ignoreMetadata is true + private static readonly METADATA_FIELDS = [ + 'id', + 'owner', + 'created', + 'modified', + 'draft', + 'visibility' + ]; + + constructor() { + super({ + inputSchema: CompareStylesInputSchema, + outputSchema: CompareStylesOutputSchema + }); + } + + protected async execute( + input: z.infer + ): Promise { + try { + // Parse styles if they're strings + let styleA: any; + let styleB: any; + + if (typeof input.styleA === 'string') { + try { + styleA = JSON.parse(input.styleA); + } catch (parseError) { + return { + content: [ + { + type: 'text', + text: `Error parsing style A: ${(parseError as Error).message}` + } + ], + isError: true + }; + } + } else { + styleA = input.styleA; + } + + if (typeof input.styleB === 'string') { + try { + styleB = JSON.parse(input.styleB); + } catch (parseError) { + return { + content: [ + { + type: 'text', + text: `Error parsing style B: ${(parseError as Error).message}` + } + ], + isError: true + }; + } + } else { + styleB = input.styleB; + } + + // Remove metadata fields if requested + if (input.ignoreMetadata) { + styleA = this.removeMetadata(styleA); + styleB = this.removeMetadata(styleB); + } + + // Perform comparison + const differences: Difference[] = []; + this.compareObjects(styleA, styleB, '', differences); + + // Calculate summary + const added = differences.filter((d) => d.type === 'added').length; + const removed = differences.filter((d) => d.type === 'removed').length; + const modified = differences.filter((d) => d.type === 'modified').length; + + const result: CompareStylesOutput = { + identical: differences.length === 0, + differences, + summary: { + totalDifferences: differences.length, + added, + removed, + modified + } + }; + + const validatedResult = CompareStylesOutputSchema.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 removeMetadata(style: any): any { + if (typeof style !== 'object' || style === null) { + return style; + } + + const cleaned = { ...style }; + for (const field of CompareStylesTool.METADATA_FIELDS) { + delete cleaned[field]; + } + return cleaned; + } + + private compareObjects( + objA: any, + objB: any, + path: string, + differences: Difference[] + ): void { + // Handle null/undefined + if (objA === null || objA === undefined) { + if (objB !== null && objB !== undefined) { + differences.push({ + path: path || 'root', + type: 'added', + valueB: objB, + description: `Property added in style B` + }); + } + return; + } + + if (objB === null || objB === undefined) { + differences.push({ + path: path || 'root', + type: 'removed', + valueA: objA, + description: `Property removed in style B` + }); + return; + } + + // Handle arrays + if (Array.isArray(objA) && Array.isArray(objB)) { + this.compareArrays(objA, objB, path, differences); + return; + } + + // Handle primitives + if (typeof objA !== 'object' || typeof objB !== 'object') { + if (objA !== objB) { + differences.push({ + path: path || 'root', + type: 'modified', + valueA: objA, + valueB: objB, + description: `Value changed from ${JSON.stringify(objA)} to ${JSON.stringify(objB)}` + }); + } + return; + } + + // Handle objects + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + const allKeys = new Set([...keysA, ...keysB]); + + for (const key of allKeys) { + const newPath = path ? `${path}.${key}` : key; + + if (!(key in objA)) { + differences.push({ + path: newPath, + type: 'added', + valueB: objB[key], + description: `Property "${key}" added` + }); + } else if (!(key in objB)) { + differences.push({ + path: newPath, + type: 'removed', + valueA: objA[key], + description: `Property "${key}" removed` + }); + } else { + this.compareObjects(objA[key], objB[key], newPath, differences); + } + } + } + + private compareArrays( + arrA: any[], + arrB: any[], + path: string, + differences: Difference[] + ): void { + // Special handling for layers array - compare by ID + if (path.endsWith('layers')) { + this.compareLayerArrays(arrA, arrB, path, differences); + return; + } + + // Special handling for sources object keys + if (path.endsWith('sources')) { + this.compareObjects(arrA, arrB, path, differences); + return; + } + + // For other arrays, compare by index + const maxLength = Math.max(arrA.length, arrB.length); + + for (let i = 0; i < maxLength; i++) { + const newPath = `${path}[${i}]`; + + if (i >= arrA.length) { + differences.push({ + path: newPath, + type: 'added', + valueB: arrB[i], + description: `Array element added at index ${i}` + }); + } else if (i >= arrB.length) { + differences.push({ + path: newPath, + type: 'removed', + valueA: arrA[i], + description: `Array element removed at index ${i}` + }); + } else { + this.compareObjects(arrA[i], arrB[i], newPath, differences); + } + } + } + + private compareLayerArrays( + layersA: any[], + layersB: any[], + path: string, + differences: Difference[] + ): void { + const layersAById = new Map(layersA.map((layer) => [layer.id, layer])); + const layersBById = new Map(layersB.map((layer) => [layer.id, layer])); + + // Check for removed layers + for (const [id, layer] of layersAById) { + if (!layersBById.has(id)) { + differences.push({ + path: `${path}[id="${id}"]`, + type: 'removed', + valueA: layer, + description: `Layer "${id}" removed` + }); + } + } + + // Check for added and modified layers + for (const [id, layerB] of layersBById) { + if (!layersAById.has(id)) { + differences.push({ + path: `${path}[id="${id}"]`, + type: 'added', + valueB: layerB, + description: `Layer "${id}" added` + }); + } else { + const layerA = layersAById.get(id); + this.compareObjects(layerA, layerB, `${path}[id="${id}"]`, differences); + } + } + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index ee12b47..9dda413 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { BoundingBoxTool } from './bounding-box-tool/BoundingBoxTool.js'; +import { CompareStylesTool } from './compare-styles-tool/CompareStylesTool.js'; import { CountryBoundingBoxTool } from './bounding-box-tool/CountryBoundingBoxTool.js'; import { CoordinateConversionTool } from './coordinate-conversion-tool/CoordinateConversionTool.js'; import { CreateStyleTool } from './create-style-tool/CreateStyleTool.js'; @@ -36,6 +37,7 @@ export const ALL_TOOLS = [ new PreviewStyleTool(), new StyleBuilderTool(), new GeojsonPreviewTool(), + new CompareStylesTool(), new OptimizeStyleTool(), new CreateTokenTool({ httpRequest }), new ListTokensTool({ httpRequest }), diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index 283d20d..2c0e992 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -7,6 +7,11 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot "description": "Calculates bounding box of given GeoJSON content, returns as [minX, minY, maxX, maxY]", "toolName": "bounding_box_tool", }, + { + "className": "CompareStylesTool", + "description": "Compares two Mapbox styles and reports differences in structure, layers, sources, and properties", + "toolName": "compare_styles_tool", + }, { "className": "CoordinateConversionTool", "description": "Converts coordinates between WGS84 (longitude/latitude) and EPSG:3857 (Web Mercator) coordinate systems", diff --git a/test/tools/compare-styles-tool/CompareStylesTool.test.ts b/test/tools/compare-styles-tool/CompareStylesTool.test.ts new file mode 100644 index 0000000..9eb58d8 --- /dev/null +++ b/test/tools/compare-styles-tool/CompareStylesTool.test.ts @@ -0,0 +1,439 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { CompareStylesTool } from '../../../src/tools/compare-styles-tool/CompareStylesTool.js'; + +describe('CompareStylesTool', () => { + let tool: CompareStylesTool; + + beforeEach(() => { + tool = new CompareStylesTool(); + }); + + describe('tool metadata', () => { + it('should have correct name and description', () => { + expect(tool.name).toBe('compare_styles_tool'); + expect(tool.description).toBe( + 'Compares two Mapbox styles and reports differences in structure, layers, sources, and properties' + ); + }); + + it('should have correct annotations', () => { + expect(tool.annotations).toEqual({ + title: 'Compare Styles Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }); + }); + }); + + describe('identical styles', () => { + it('should detect identical styles', async () => { + const style = { + version: 8, + name: 'Test Style', + sources: {}, + layers: [] + }; + + const input = { + styleA: style, + styleB: style + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.identical).toBe(true); + expect(parsed.differences).toHaveLength(0); + expect(parsed.summary.totalDifferences).toBe(0); + }); + }); + + describe('property differences', () => { + it('should detect added property', async () => { + const styleA = { + version: 8, + sources: {} + }; + + const styleB = { + version: 8, + sources: {}, + layers: [] + }; + + const input = { + styleA, + styleB + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.identical).toBe(false); + expect(parsed.differences).toHaveLength(1); + expect(parsed.differences[0].type).toBe('added'); + expect(parsed.differences[0].path).toBe('layers'); + expect(parsed.summary.added).toBe(1); + }); + + it('should detect removed property', async () => { + const styleA = { + version: 8, + sources: {}, + layers: [] + }; + + const styleB = { + version: 8, + sources: {} + }; + + const input = { + styleA, + styleB + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.identical).toBe(false); + expect(parsed.differences).toHaveLength(1); + expect(parsed.differences[0].type).toBe('removed'); + expect(parsed.differences[0].path).toBe('layers'); + expect(parsed.summary.removed).toBe(1); + }); + + it('should detect modified property', async () => { + const styleA = { + version: 8, + name: 'Style A' + }; + + const styleB = { + version: 8, + name: 'Style B' + }; + + const input = { + styleA, + styleB + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.identical).toBe(false); + expect(parsed.differences).toHaveLength(1); + expect(parsed.differences[0].type).toBe('modified'); + expect(parsed.differences[0].path).toBe('name'); + expect(parsed.differences[0].valueA).toBe('Style A'); + expect(parsed.differences[0].valueB).toBe('Style B'); + expect(parsed.summary.modified).toBe(1); + }); + }); + + describe('layer differences', () => { + it('should detect added layer', async () => { + const styleA = { + version: 8, + sources: {}, + layers: [] + }; + + const styleB = { + version: 8, + sources: {}, + layers: [ + { + id: 'background', + type: 'background', + paint: { 'background-color': '#fff' } + } + ] + }; + + const input = { + styleA, + styleB + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.identical).toBe(false); + expect( + parsed.differences.some((d: any) => d.path.includes('background')) + ).toBe(true); + }); + + it('should detect removed layer', async () => { + const styleA = { + version: 8, + sources: {}, + layers: [ + { + id: 'background', + type: 'background' + } + ] + }; + + const styleB = { + version: 8, + sources: {}, + layers: [] + }; + + const input = { + styleA, + styleB + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.identical).toBe(false); + expect( + parsed.differences.some( + (d: any) => d.type === 'removed' && d.path.includes('background') + ) + ).toBe(true); + }); + + it('should detect modified layer', async () => { + const styleA = { + version: 8, + sources: {}, + layers: [ + { + id: 'background', + type: 'background', + paint: { 'background-color': '#fff' } + } + ] + }; + + const styleB = { + version: 8, + sources: {}, + layers: [ + { + id: 'background', + type: 'background', + paint: { 'background-color': '#000' } + } + ] + }; + + const input = { + styleA, + styleB + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.identical).toBe(false); + expect(parsed.differences.some((d: any) => d.type === 'modified')).toBe( + true + ); + }); + }); + + describe('metadata handling', () => { + it('should ignore metadata fields when requested', async () => { + const styleA = { + version: 8, + id: 'style-a', + owner: 'user1', + created: '2024-01-01', + modified: '2024-01-02', + sources: {}, + layers: [] + }; + + const styleB = { + version: 8, + id: 'style-b', + owner: 'user2', + created: '2024-02-01', + modified: '2024-02-02', + sources: {}, + layers: [] + }; + + const input = { + styleA, + styleB, + ignoreMetadata: true + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.identical).toBe(true); + expect(parsed.differences).toHaveLength(0); + }); + + it('should not ignore metadata fields by default', async () => { + const styleA = { + version: 8, + id: 'style-a', + sources: {}, + layers: [] + }; + + const styleB = { + version: 8, + id: 'style-b', + sources: {}, + layers: [] + }; + + const input = { + styleA, + styleB + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.identical).toBe(false); + expect(parsed.differences).toHaveLength(1); + expect(parsed.differences[0].path).toBe('id'); + }); + }); + + describe('JSON string input', () => { + it('should accept JSON strings', async () => { + const styleA = { + version: 8, + sources: {}, + layers: [] + }; + + const input = { + styleA: JSON.stringify(styleA), + styleB: JSON.stringify(styleA) + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.identical).toBe(true); + }); + }); + + describe('error handling', () => { + it('should handle invalid JSON for style A', async () => { + const input = { + styleA: '{invalid json', + styleB: { version: 8 } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error parsing style A'); + }); + + it('should handle invalid JSON for style B', async () => { + const input = { + styleA: { version: 8 }, + styleB: '{invalid json' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error parsing style B'); + }); + }); + + describe('nested differences', () => { + it('should detect nested property changes', async () => { + const styleA = { + version: 8, + sources: { + 'mapbox-streets': { + type: 'vector', + url: 'mapbox://mapbox.streets' + } + } + }; + + const styleB = { + version: 8, + sources: { + 'mapbox-streets': { + type: 'vector', + url: 'mapbox://mapbox.streets-v8' + } + } + }; + + const input = { + styleA, + styleB + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.identical).toBe(false); + expect( + parsed.differences.some((d: any) => + d.path.includes('mapbox-streets.url') + ) + ).toBe(true); + }); + }); + + describe('summary statistics', () => { + it('should correctly count difference types', async () => { + const styleA = { + version: 8, + name: 'Old Name', + sources: { + removed: { type: 'vector' } + } + }; + + const styleB = { + version: 8, + name: 'New Name', + sources: { + added: { type: 'raster' } + } + }; + + const input = { + styleA, + styleB + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.summary.totalDifferences).toBeGreaterThan(0); + expect(parsed.summary.added).toBeGreaterThan(0); + expect(parsed.summary.removed).toBeGreaterThan(0); + expect(parsed.summary.modified).toBeGreaterThan(0); + }); + }); +});