diff --git a/README.md b/README.md index cc25fa3..4fbabab 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,82 @@ Generate a geojson.io URL to visualize GeoJSON data. This tool: - "Generate a preview URL for this GeoJSON data" - "Create a geojson.io link for my uploaded route.geojson file" +#### Validate GeoJSON tool + +Validates GeoJSON objects for correctness, checking structure, coordinates, and geometry types. This offline validation tool performs comprehensive checks on GeoJSON data without requiring API access. + +**Parameters:** + +- `geojson` (string or object, required): GeoJSON object or JSON string to validate + +**What it validates:** + +- GeoJSON type validity (Feature, FeatureCollection, Point, LineString, Polygon, etc.) +- Required properties (type, coordinates, geometry, features) +- Coordinate array structure and position validity +- Longitude ranges [-180, 180] and latitude ranges [-90, 90] +- Polygon ring closure (first and last coordinates should match) +- Minimum position requirements (LineString needs 2+, Polygon rings need 4+ positions) + +**Returns:** + +Validation results including: + +- `valid` (boolean): Overall validity +- `errors` (array): Critical errors that make the GeoJSON invalid +- `warnings` (array): Non-critical issues (e.g., unclosed polygon rings, out-of-range coordinates) +- `info` (array): Informational messages +- `statistics`: Object with type, feature count, geometry types, and bounding box + +Each issue includes: + +- `severity`: "error", "warning", or "info" +- `message`: Description of the issue +- `path`: JSON path to the problem (optional) +- `suggestion`: How to fix the issue (optional) + +**Example:** + +```json +{ + "geojson": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [102.0, 0.5] + }, + "properties": { + "name": "Test Point" + } + } +} +``` + +**Returns:** + +```json +{ + "valid": true, + "errors": [], + "warnings": [], + "info": [], + "statistics": { + "type": "Feature", + "featureCount": 1, + "geometryTypes": ["Point"], + "bbox": [102.0, 0.5, 102.0, 0.5] + } +} +``` + +**Example prompts:** + +- "Validate this GeoJSON file and tell me if there are any errors" +- "Check if my GeoJSON coordinates are valid" +- "Is this Feature Collection properly formatted?" + +**Note:** This is an offline validation tool that doesn't require API access or token scopes. + #### Coordinate Conversion tool Convert coordinates between different coordinate reference systems (CRS), specifically between WGS84 (EPSG:4326) and Web Mercator (EPSG:3857). diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index f57611a..efde876 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 { ValidateGeojsonTool } from './validate-geojson-tool/ValidateGeojsonTool.js'; import { ValidateStyleTool } from './validate-style-tool/ValidateStyleTool.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -44,6 +45,7 @@ export const ALL_TOOLS = [ new GetReferenceTool(), new StyleComparisonTool(), new TilequeryTool({ httpRequest }), + new ValidateGeojsonTool(), new ValidateStyleTool() ] as const; diff --git a/src/tools/validate-geojson-tool/ValidateGeojsonTool.input.schema.ts b/src/tools/validate-geojson-tool/ValidateGeojsonTool.input.schema.ts new file mode 100644 index 0000000..cb635ad --- /dev/null +++ b/src/tools/validate-geojson-tool/ValidateGeojsonTool.input.schema.ts @@ -0,0 +1,12 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const ValidateGeojsonInputSchema = z.object({ + geojson: z + .union([z.string(), z.record(z.unknown())]) + .describe('GeoJSON object or JSON string to validate') +}); + +export type ValidateGeojsonInput = z.infer; diff --git a/src/tools/validate-geojson-tool/ValidateGeojsonTool.output.schema.ts b/src/tools/validate-geojson-tool/ValidateGeojsonTool.output.schema.ts new file mode 100644 index 0000000..da9a8f3 --- /dev/null +++ b/src/tools/validate-geojson-tool/ValidateGeojsonTool.output.schema.ts @@ -0,0 +1,32 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +const GeojsonIssueSchema = z.object({ + severity: z.enum(['error', 'warning', 'info']).describe('Issue severity'), + message: z.string().describe('Description of the issue'), + path: z.string().optional().describe('JSON path to the problem'), + suggestion: z.string().optional().describe('How to fix the issue') +}); + +export const ValidateGeojsonOutputSchema = z.object({ + valid: z.boolean().describe('Whether the GeoJSON is valid'), + errors: z.array(GeojsonIssueSchema).describe('Critical errors'), + warnings: z.array(GeojsonIssueSchema).describe('Non-critical warnings'), + info: z.array(GeojsonIssueSchema).describe('Informational messages'), + statistics: z + .object({ + type: z.string().describe('GeoJSON type'), + featureCount: z.number().optional().describe('Number of features'), + geometryTypes: z.array(z.string()).describe('Geometry types found'), + bbox: z + .array(z.number()) + .optional() + .describe('Bounding box [minLon, minLat, maxLon, maxLat]') + }) + .describe('GeoJSON statistics') +}); + +export type ValidateGeojsonOutput = z.infer; +export type GeojsonIssue = z.infer; diff --git a/src/tools/validate-geojson-tool/ValidateGeojsonTool.ts b/src/tools/validate-geojson-tool/ValidateGeojsonTool.ts new file mode 100644 index 0000000..229b334 --- /dev/null +++ b/src/tools/validate-geojson-tool/ValidateGeojsonTool.ts @@ -0,0 +1,578 @@ +// 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 { ValidateGeojsonInputSchema } from './ValidateGeojsonTool.input.schema.js'; +import { + ValidateGeojsonOutputSchema, + type ValidateGeojsonOutput, + type GeojsonIssue +} from './ValidateGeojsonTool.output.schema.js'; + +/** + * ValidateGeojsonTool - Validates GeoJSON structure and geometry + * + * Performs comprehensive validation of GeoJSON objects including type validation, + * coordinate validation, and geometry structure checks. + */ +export class ValidateGeojsonTool extends BaseTool< + typeof ValidateGeojsonInputSchema, + typeof ValidateGeojsonOutputSchema +> { + readonly name = 'validate_geojson_tool'; + readonly description = + 'Validates GeoJSON objects for correctness, checking structure, coordinates, and geometry types'; + readonly annotations = { + title: 'Validate GeoJSON Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }; + + private static readonly VALID_GEOJSON_TYPES = [ + 'Feature', + 'FeatureCollection', + 'Point', + 'MultiPoint', + 'LineString', + 'MultiLineString', + 'Polygon', + 'MultiPolygon', + 'GeometryCollection' + ]; + + private static readonly GEOMETRY_TYPES = [ + 'Point', + 'MultiPoint', + 'LineString', + 'MultiLineString', + 'Polygon', + 'MultiPolygon', + 'GeometryCollection' + ]; + + constructor() { + super({ + inputSchema: ValidateGeojsonInputSchema, + outputSchema: ValidateGeojsonOutputSchema + }); + } + + protected async execute( + input: z.infer + ): Promise { + try { + let geojson: any; + if (typeof input.geojson === 'string') { + try { + geojson = JSON.parse(input.geojson); + } catch (parseError) { + return { + content: [ + { + type: 'text', + text: `Error parsing GeoJSON: ${(parseError as Error).message}` + } + ], + isError: true + }; + } + } else { + geojson = input.geojson; + } + + const errors: GeojsonIssue[] = []; + const warnings: GeojsonIssue[] = []; + const info: GeojsonIssue[] = []; + + // Validate structure + this.validateStructure(geojson, errors, warnings, info); + + // Calculate statistics + const statistics = this.calculateStatistics(geojson); + + const result: ValidateGeojsonOutput = { + valid: errors.length === 0, + errors, + warnings, + info, + statistics + }; + + const validatedResult = ValidateGeojsonOutputSchema.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( + geojson: any, + errors: GeojsonIssue[], + warnings: GeojsonIssue[], + info: GeojsonIssue[] + ): void { + // Check type + if (!geojson.type) { + errors.push({ + severity: 'error', + message: 'Missing required "type" property', + path: 'type', + suggestion: 'Add a "type" property with a valid GeoJSON type' + }); + return; + } + + if (!ValidateGeojsonTool.VALID_GEOJSON_TYPES.includes(geojson.type)) { + errors.push({ + severity: 'error', + message: `Invalid GeoJSON type: "${geojson.type}"`, + path: 'type', + suggestion: `Valid types are: ${ValidateGeojsonTool.VALID_GEOJSON_TYPES.join(', ')}` + }); + return; + } + + // Type-specific validation + switch (geojson.type) { + case 'Feature': + this.validateFeature(geojson, errors, warnings, info); + break; + case 'FeatureCollection': + this.validateFeatureCollection(geojson, errors, warnings, info); + break; + case 'GeometryCollection': + this.validateGeometryCollection(geojson, errors, warnings, info); + break; + default: + // Geometry types + this.validateGeometry(geojson, errors, warnings, info); + break; + } + } + + private validateFeature( + feature: any, + errors: GeojsonIssue[], + warnings: GeojsonIssue[], + _info: GeojsonIssue[] + ): void { + if (!feature.geometry && feature.geometry !== null) { + errors.push({ + severity: 'error', + message: 'Feature missing "geometry" property', + path: 'geometry', + suggestion: 'Add a "geometry" object or set to null' + }); + } else if (feature.geometry !== null) { + this.validateGeometry( + feature.geometry, + errors, + warnings, + _info, + 'geometry' + ); + } + + if (!feature.properties && feature.properties !== null) { + warnings.push({ + severity: 'warning', + message: 'Feature missing "properties" property', + path: 'properties', + suggestion: 'Add a "properties" object (can be empty) or set to null' + }); + } + } + + private validateFeatureCollection( + fc: any, + errors: GeojsonIssue[], + warnings: GeojsonIssue[], + info: GeojsonIssue[] + ): void { + if (!fc.features) { + errors.push({ + severity: 'error', + message: 'FeatureCollection missing "features" array', + path: 'features', + suggestion: 'Add a "features" array' + }); + return; + } + + if (!Array.isArray(fc.features)) { + errors.push({ + severity: 'error', + message: '"features" must be an array', + path: 'features' + }); + return; + } + + if (fc.features.length === 0) { + warnings.push({ + severity: 'warning', + message: 'FeatureCollection has no features', + path: 'features', + suggestion: 'Add at least one feature' + }); + } + + fc.features.forEach((feature: any, index: number) => { + if (feature.type !== 'Feature') { + errors.push({ + severity: 'error', + message: `Feature at index ${index} has invalid type "${feature.type}"`, + path: `features[${index}].type`, + suggestion: 'Each item in features array must have type "Feature"' + }); + } else { + this.validateFeature(feature, errors, warnings, info); + } + }); + } + + private validateGeometry( + geometry: any, + errors: GeojsonIssue[], + warnings: GeojsonIssue[], + _info: GeojsonIssue[], + path: string = '' + ): void { + if (!geometry.type) { + errors.push({ + severity: 'error', + message: 'Geometry missing "type" property', + path: path ? `${path}.type` : 'type', + suggestion: 'Add a geometry "type" property' + }); + return; + } + + if (!ValidateGeojsonTool.GEOMETRY_TYPES.includes(geometry.type)) { + errors.push({ + severity: 'error', + message: `Invalid geometry type: "${geometry.type}"`, + path: path ? `${path}.type` : 'type', + suggestion: `Valid geometry types are: ${ValidateGeojsonTool.GEOMETRY_TYPES.join(', ')}` + }); + return; + } + + if (!geometry.coordinates && geometry.type !== 'GeometryCollection') { + errors.push({ + severity: 'error', + message: `Geometry of type "${geometry.type}" missing "coordinates" property`, + path: path ? `${path}.coordinates` : 'coordinates', + suggestion: 'Add a "coordinates" array' + }); + return; + } + + // Validate coordinates + if (geometry.type !== 'GeometryCollection') { + this.validateCoordinates( + geometry.type, + geometry.coordinates, + errors, + warnings, + path + ); + } + } + + private validateGeometryCollection( + gc: any, + errors: GeojsonIssue[], + warnings: GeojsonIssue[], + info: GeojsonIssue[] + ): void { + if (!gc.geometries) { + errors.push({ + severity: 'error', + message: 'GeometryCollection missing "geometries" array', + path: 'geometries', + suggestion: 'Add a "geometries" array' + }); + return; + } + + if (!Array.isArray(gc.geometries)) { + errors.push({ + severity: 'error', + message: '"geometries" must be an array', + path: 'geometries' + }); + return; + } + + gc.geometries.forEach((geometry: any, index: number) => { + this.validateGeometry( + geometry, + errors, + warnings, + info, + `geometries[${index}]` + ); + }); + } + + private validateCoordinates( + type: string, + coordinates: any, + errors: GeojsonIssue[], + warnings: GeojsonIssue[], + path: string + ): void { + const coordPath = path ? `${path}.coordinates` : 'coordinates'; + + if (!Array.isArray(coordinates)) { + errors.push({ + severity: 'error', + message: 'Coordinates must be an array', + path: coordPath + }); + return; + } + + switch (type) { + case 'Point': + this.validatePosition(coordinates, errors, warnings, coordPath); + break; + case 'MultiPoint': + case 'LineString': + if (coordinates.length === 0) { + errors.push({ + severity: 'error', + message: `${type} coordinates array is empty`, + path: coordPath + }); + } else { + coordinates.forEach((pos: any, i: number) => { + this.validatePosition(pos, errors, warnings, `${coordPath}[${i}]`); + }); + } + if (type === 'LineString' && coordinates.length < 2) { + errors.push({ + severity: 'error', + message: 'LineString must have at least 2 positions', + path: coordPath, + suggestion: 'Add more coordinate positions' + }); + } + break; + case 'Polygon': + case 'MultiLineString': + if (coordinates.length === 0) { + errors.push({ + severity: 'error', + message: `${type} coordinates array is empty`, + path: coordPath + }); + } else { + coordinates.forEach((ring: any, i: number) => { + if ( + !Array.isArray(ring) || + ring.length < (type === 'Polygon' ? 4 : 2) + ) { + errors.push({ + severity: 'error', + message: + type === 'Polygon' + ? 'Polygon ring must have at least 4 positions' + : 'LineString must have at least 2 positions', + path: `${coordPath}[${i}]` + }); + } else { + ring.forEach((pos: any, j: number) => { + this.validatePosition( + pos, + errors, + warnings, + `${coordPath}[${i}][${j}]` + ); + }); + // Check ring closure for Polygon + if (type === 'Polygon') { + const first = ring[0]; + const last = ring[ring.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) { + warnings.push({ + severity: 'warning', + message: 'Polygon ring is not closed', + path: `${coordPath}[${i}]`, + suggestion: 'First and last positions should be identical' + }); + } + } + } + }); + } + break; + case 'MultiPolygon': + coordinates.forEach((polygon: any, i: number) => { + if (!Array.isArray(polygon)) { + errors.push({ + severity: 'error', + message: 'MultiPolygon coordinate must be an array of rings', + path: `${coordPath}[${i}]` + }); + } + }); + break; + } + } + + private validatePosition( + position: any, + errors: GeojsonIssue[], + warnings: GeojsonIssue[], + path: string + ): void { + if (!Array.isArray(position)) { + errors.push({ + severity: 'error', + message: 'Position must be an array', + path + }); + return; + } + + if (position.length < 2) { + errors.push({ + severity: 'error', + message: 'Position must have at least 2 elements [longitude, latitude]', + path, + suggestion: 'Add longitude and latitude values' + }); + return; + } + + const [lon, lat] = position; + + if (typeof lon !== 'number' || typeof lat !== 'number') { + errors.push({ + severity: 'error', + message: 'Position coordinates must be numbers', + path + }); + return; + } + + if (lon < -180 || lon > 180) { + warnings.push({ + severity: 'warning', + message: `Longitude ${lon} is outside valid range [-180, 180]`, + path, + suggestion: 'Ensure longitude is between -180 and 180' + }); + } + + if (lat < -90 || lat > 90) { + warnings.push({ + severity: 'warning', + message: `Latitude ${lat} is outside valid range [-90, 90]`, + path, + suggestion: 'Ensure latitude is between -90 and 90' + }); + } + } + + private calculateStatistics(geojson: any): { + type: string; + featureCount?: number; + geometryTypes: string[]; + bbox?: number[]; + } { + const geometryTypes = new Set(); + let featureCount: number | undefined; + let minLon = Infinity, + minLat = Infinity, + maxLon = -Infinity, + maxLat = -Infinity; + let hasCoords = false; + + const collectGeometryType = (geometry: any) => { + if (geometry && geometry.type) { + geometryTypes.add(geometry.type); + } + }; + + const updateBbox = (coords: any) => { + if (Array.isArray(coords) && coords.length >= 2) { + if (typeof coords[0] === 'number' && typeof coords[1] === 'number') { + // Position + const [lon, lat] = coords; + minLon = Math.min(minLon, lon); + maxLon = Math.max(maxLon, lon); + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + hasCoords = true; + } else { + // Array of positions or deeper + coords.forEach(updateBbox); + } + } + }; + + if (geojson.type === 'FeatureCollection') { + featureCount = geojson.features?.length || 0; + geojson.features?.forEach((feature: any) => { + if (feature.geometry) { + collectGeometryType(feature.geometry); + if (feature.geometry.coordinates) { + updateBbox(feature.geometry.coordinates); + } + } + }); + } else if (geojson.type === 'Feature') { + featureCount = 1; + if (geojson.geometry) { + collectGeometryType(geojson.geometry); + if (geojson.geometry.coordinates) { + updateBbox(geojson.geometry.coordinates); + } + } + } else if (geojson.type === 'GeometryCollection') { + geojson.geometries?.forEach((geometry: any) => { + collectGeometryType(geometry); + if (geometry.coordinates) { + updateBbox(geometry.coordinates); + } + }); + } else { + // Geometry type + collectGeometryType(geojson); + if (geojson.coordinates) { + updateBbox(geojson.coordinates); + } + } + + return { + type: geojson.type || 'unknown', + featureCount, + geometryTypes: Array.from(geometryTypes), + bbox: hasCoords ? [minLon, minLat, maxLon, maxLat] : undefined + }; + } +} diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index a89df3d..9d34f3f 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -157,6 +157,11 @@ 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": "ValidateGeojsonTool", + "description": "Validates GeoJSON objects for correctness, checking structure, coordinates, and geometry types", + "toolName": "validate_geojson_tool", + }, { "className": "ValidateStyleTool", "description": "Validates Mapbox style JSON against the Mapbox Style Specification, checking for errors, warnings, and providing suggestions for improvement", diff --git a/test/tools/validate-geojson-tool/ValidateGeojsonTool.test.ts b/test/tools/validate-geojson-tool/ValidateGeojsonTool.test.ts new file mode 100644 index 0000000..b9970e9 --- /dev/null +++ b/test/tools/validate-geojson-tool/ValidateGeojsonTool.test.ts @@ -0,0 +1,607 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ValidateGeojsonTool } from '../../../src/tools/validate-geojson-tool/ValidateGeojsonTool.js'; + +describe('ValidateGeojsonTool', () => { + let tool: ValidateGeojsonTool; + + beforeEach(() => { + tool = new ValidateGeojsonTool(); + }); + + describe('tool metadata', () => { + it('should have correct name and description', () => { + expect(tool.name).toBe('validate_geojson_tool'); + expect(tool.description).toBe( + 'Validates GeoJSON objects for correctness, checking structure, coordinates, and geometry types' + ); + }); + + it('should have correct annotations', () => { + expect(tool.annotations).toEqual({ + title: 'Validate GeoJSON Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }); + }); + }); + + describe('valid GeoJSON', () => { + it('should validate a valid Point', async () => { + const input = { + geojson: { + type: 'Point', + coordinates: [102.0, 0.5] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.errors).toHaveLength(0); + expect(parsed.statistics.type).toBe('Point'); + expect(parsed.statistics.geometryTypes).toEqual(['Point']); + expect(parsed.statistics.bbox).toEqual([102.0, 0.5, 102.0, 0.5]); + }); + + it('should validate a valid LineString', async () => { + const input = { + geojson: { + type: 'LineString', + coordinates: [ + [102.0, 0.0], + [103.0, 1.0], + [104.0, 0.0], + [105.0, 1.0] + ] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.errors).toHaveLength(0); + expect(parsed.statistics.type).toBe('LineString'); + }); + + it('should validate a valid Polygon with closed ring', async () => { + const input = { + geojson: { + type: 'Polygon', + coordinates: [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0] + ] + ] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.errors).toHaveLength(0); + expect(parsed.warnings).toHaveLength(0); + expect(parsed.statistics.type).toBe('Polygon'); + }); + + it('should validate a valid Feature', async () => { + const input = { + geojson: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [102.0, 0.5] + }, + properties: { + name: 'Test Point' + } + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.errors).toHaveLength(0); + expect(parsed.statistics.featureCount).toBe(1); + }); + + it('should validate a valid FeatureCollection', async () => { + const input = { + geojson: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [102.0, 0.5] + }, + properties: { name: 'Point 1' } + }, + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [102.0, 0.0], + [103.0, 1.0] + ] + }, + properties: { name: 'Line 1' } + } + ] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.errors).toHaveLength(0); + expect(parsed.statistics.featureCount).toBe(2); + expect(parsed.statistics.geometryTypes).toContain('Point'); + expect(parsed.statistics.geometryTypes).toContain('LineString'); + }); + + it('should validate a GeometryCollection', async () => { + const input = { + geojson: { + type: 'GeometryCollection', + geometries: [ + { + type: 'Point', + coordinates: [100.0, 0.0] + }, + { + type: 'LineString', + coordinates: [ + [101.0, 0.0], + [102.0, 1.0] + ] + } + ] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.errors).toHaveLength(0); + }); + + it('should accept JSON string input', async () => { + const input = { + geojson: JSON.stringify({ + type: 'Point', + coordinates: [102.0, 0.5] + }) + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + }); + }); + + describe('invalid GeoJSON structure', () => { + it('should detect missing type property', async () => { + const input = { + geojson: JSON.stringify({ + coordinates: [102.0, 0.5] + }) + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors).toHaveLength(1); + expect(parsed.errors[0].message).toContain( + 'Missing required "type" property' + ); + }); + + it('should detect invalid GeoJSON type', async () => { + const input = { + geojson: { + type: 'InvalidType', + coordinates: [102.0, 0.5] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain('Invalid GeoJSON type'); + }); + + it('should detect Feature missing geometry property', async () => { + const input = { + geojson: { + type: 'Feature', + properties: {} + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain( + 'Feature missing "geometry" property' + ); + }); + + it('should warn about Feature missing properties', async () => { + const input = { + geojson: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [102.0, 0.5] + } + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.warnings).toHaveLength(1); + expect(parsed.warnings[0].message).toContain( + 'Feature missing "properties" property' + ); + }); + + it('should detect FeatureCollection missing features array', async () => { + const input = { + geojson: { + type: 'FeatureCollection' + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain( + 'FeatureCollection missing "features" array' + ); + }); + + it('should warn about empty FeatureCollection', async () => { + const input = { + geojson: { + type: 'FeatureCollection', + features: [] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.warnings).toHaveLength(1); + expect(parsed.warnings[0].message).toContain( + 'FeatureCollection has no features' + ); + }); + }); + + describe('coordinate validation', () => { + it('should detect Point with missing coordinates', async () => { + const input = { + geojson: { + type: 'Point' + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain( + 'missing "coordinates" property' + ); + }); + + it('should detect invalid position (not an array)', async () => { + const input = { + geojson: { + type: 'Point', + coordinates: 'invalid' + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain('must be an array'); + }); + + it('should detect position with insufficient coordinates', async () => { + const input = { + geojson: { + type: 'Point', + coordinates: [102.0] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain( + 'must have at least 2 elements' + ); + }); + + it('should detect non-numeric coordinates', async () => { + const input = { + geojson: { + type: 'Point', + coordinates: ['102.0', '0.5'] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain('coordinates must be numbers'); + }); + + it('should warn about longitude out of range', async () => { + const input = { + geojson: { + type: 'Point', + coordinates: [200.0, 0.0] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.warnings).toHaveLength(1); + expect(parsed.warnings[0].message).toContain('Longitude'); + expect(parsed.warnings[0].message).toContain('outside valid range'); + }); + + it('should warn about latitude out of range', async () => { + const input = { + geojson: { + type: 'Point', + coordinates: [0.0, 100.0] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.warnings).toHaveLength(1); + expect(parsed.warnings[0].message).toContain('Latitude'); + expect(parsed.warnings[0].message).toContain('outside valid range'); + }); + + it('should detect LineString with too few positions', async () => { + const input = { + geojson: { + type: 'LineString', + coordinates: [[102.0, 0.0]] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect( + parsed.errors.some((e: any) => + e.message.includes('must have at least 2 positions') + ) + ).toBe(true); + }); + + it('should detect Polygon with too few positions', async () => { + const input = { + geojson: { + type: 'Polygon', + coordinates: [ + [ + [100.0, 0.0], + [101.0, 0.0], + [100.0, 1.0] + ] + ] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain( + 'must have at least 4 positions' + ); + }); + + it('should warn about unclosed Polygon ring', async () => { + const input = { + geojson: { + type: 'Polygon', + coordinates: [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0] + ] + ] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.warnings).toHaveLength(1); + expect(parsed.warnings[0].message).toContain( + 'Polygon ring is not closed' + ); + }); + }); + + describe('error handling', () => { + it('should handle invalid JSON string', async () => { + const input = { + geojson: '{invalid json' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error parsing GeoJSON'); + }); + }); + + describe('statistics calculation', () => { + it('should calculate bounding box correctly', async () => { + const input = { + geojson: { + type: 'LineString', + coordinates: [ + [100.0, 0.0], + [101.0, 1.0], + [99.0, -1.0] + ] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.statistics.bbox).toEqual([99.0, -1.0, 101.0, 1.0]); + }); + + it('should count features correctly', async () => { + const input = { + geojson: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {} + }, + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 1] }, + properties: {} + }, + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [2, 2] }, + properties: {} + } + ] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.statistics.featureCount).toBe(3); + }); + + it('should collect geometry types correctly', async () => { + const input = { + geojson: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {} + }, + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1] + ] + }, + properties: {} + }, + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + [0, 0] + ] + ] + }, + properties: {} + } + ] + } + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.statistics.geometryTypes).toContain('Point'); + expect(parsed.statistics.geometryTypes).toContain('LineString'); + expect(parsed.statistics.geometryTypes).toContain('Polygon'); + }); + }); +});