diff --git a/src/tools/__snapshots__/tool-naming-convention.test.ts.snap b/src/tools/__snapshots__/tool-naming-convention.test.ts.snap index 03dfbd1..a1edcc3 100644 --- a/src/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/src/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -62,6 +62,11 @@ exports[`Tool Naming Convention should maintain consistent tool list (snapshot t "description": "Generate a comparison URL for comparing two Mapbox styles side-by-side", "toolName": "style_comparison_tool", }, + { + "className": "TilequeryTool", + "description": "Query vector and raster data from Mapbox tilesets at geographic coordinates", + "toolName": "tilequery_tool", + }, { "className": "UpdateStyleTool", "description": "Update an existing Mapbox style", diff --git a/src/tools/tilequery-tool/TilequeryTool.schema.ts b/src/tools/tilequery-tool/TilequeryTool.schema.ts new file mode 100644 index 0000000..b50a264 --- /dev/null +++ b/src/tools/tilequery-tool/TilequeryTool.schema.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +export const TilequerySchema = z.object({ + tilesetId: z + .string() + .optional() + .default('mapbox.mapbox-streets-v8') + .describe('Tileset ID to query (default: mapbox.mapbox-streets-v8)'), + longitude: z + .number() + .min(-180) + .max(180) + .describe('Longitude coordinate to query'), + latitude: z + .number() + .min(-90) + .max(90) + .describe('Latitude coordinate to query'), + radius: z + .number() + .min(0) + .optional() + .default(0) + .describe('Radius in meters to search for features (default: 0)'), + limit: z + .number() + .min(1) + .max(50) + .optional() + .default(5) + .describe('Number of features to return (1-50, default: 5)'), + dedupe: z + .boolean() + .optional() + .default(true) + .describe('Whether to deduplicate identical features (default: true)'), + geometry: z + .enum(['polygon', 'linestring', 'point']) + .optional() + .describe('Filter results by geometry type'), + layers: z + .array(z.string()) + .optional() + .describe('Specific layer names to query from the tileset'), + bands: z + .array(z.string()) + .optional() + .describe('Specific band names to query (for rasterarray tilesets)') +}); + +export type TilequeryInput = z.infer; diff --git a/src/tools/tilequery-tool/TilequeryTool.test.ts b/src/tools/tilequery-tool/TilequeryTool.test.ts new file mode 100644 index 0000000..faf6c85 --- /dev/null +++ b/src/tools/tilequery-tool/TilequeryTool.test.ts @@ -0,0 +1,96 @@ +import { TilequeryTool } from './TilequeryTool.js'; +import { TilequeryInput } from './TilequeryTool.schema.js'; + +describe('TilequeryTool', () => { + let tool: TilequeryTool; + + beforeEach(() => { + tool = new TilequeryTool(); + }); + + describe('constructor', () => { + it('should initialize with correct name and description', () => { + expect(tool.name).toBe('tilequery_tool'); + expect(tool.description).toBe( + 'Query vector and raster data from Mapbox tilesets at geographic coordinates' + ); + }); + }); + + describe('schema validation', () => { + it('should validate minimal valid input', () => { + const input = { + longitude: -122.4194, + latitude: 37.7749 + }; + + const result = tool.inputSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tilesetId).toBe('mapbox.mapbox-streets-v8'); + expect(result.data.radius).toBe(0); + expect(result.data.limit).toBe(5); + expect(result.data.dedupe).toBe(true); + } + }); + + it('should validate complete input with all optional parameters', () => { + const input: TilequeryInput = { + tilesetId: 'custom.tileset', + longitude: -122.4194, + latitude: 37.7749, + radius: 100, + limit: 10, + dedupe: false, + geometry: 'polygon', + layers: ['buildings', 'roads'], + bands: ['band1', 'band2'] + }; + + const result = tool.inputSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it('should reject invalid longitude', () => { + const input = { + longitude: 181, // Invalid: > 180 + latitude: 37.7749 + }; + + const result = tool.inputSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('should reject invalid latitude', () => { + const input = { + longitude: -122.4194, + latitude: 91 // Invalid: > 90 + }; + + const result = tool.inputSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('should reject limit outside valid range', () => { + const input = { + longitude: -122.4194, + latitude: 37.7749, + limit: 51 // Invalid: > 50 + }; + + const result = tool.inputSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('should reject invalid geometry type', () => { + const input = { + longitude: -122.4194, + latitude: 37.7749, + geometry: 'invalid' as 'polygon' + }; + + const result = tool.inputSchema.safeParse(input); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/src/tools/tilequery-tool/TilequeryTool.ts b/src/tools/tilequery-tool/TilequeryTool.ts new file mode 100644 index 0000000..9042dc3 --- /dev/null +++ b/src/tools/tilequery-tool/TilequeryTool.ts @@ -0,0 +1,60 @@ +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import { TilequerySchema, TilequeryInput } from './TilequeryTool.schema.js'; + +export class TilequeryTool extends MapboxApiBasedTool { + name = 'tilequery_tool'; + description = + 'Query vector and raster data from Mapbox tilesets at geographic coordinates'; + + constructor() { + super({ inputSchema: TilequerySchema }); + } + + protected async execute( + input: TilequeryInput, + accessToken?: string + ): Promise { + const { tilesetId, longitude, latitude, ...queryParams } = input; + const url = new URL( + `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}v4/${tilesetId}/tilequery/${longitude},${latitude}.json` + ); + + if (queryParams.radius !== undefined) { + url.searchParams.set('radius', queryParams.radius.toString()); + } + + if (queryParams.limit !== undefined) { + url.searchParams.set('limit', queryParams.limit.toString()); + } + + if (queryParams.dedupe !== undefined) { + url.searchParams.set('dedupe', queryParams.dedupe.toString()); + } + + if (queryParams.geometry) { + url.searchParams.set('geometry', queryParams.geometry); + } + + if (queryParams.layers && queryParams.layers.length > 0) { + url.searchParams.set('layers', queryParams.layers.join(',')); + } + + if (queryParams.bands && queryParams.bands.length > 0) { + url.searchParams.set('bands', queryParams.bands.join(',')); + } + + url.searchParams.set('access_token', accessToken || ''); + + const response = await fetch(url.toString()); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Tilequery request failed: ${response.status} ${response.statusText}. ${errorText}` + ); + } + + const data = await response.json(); + return data; + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index d13881f..17b0875 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -10,6 +10,7 @@ import { ListTokensTool } from './list-tokens-tool/ListTokensTool.js'; import { PreviewStyleTool } from './preview-style-tool/PreviewStyleTool.js'; import { RetrieveStyleTool } from './retrieve-style-tool/RetrieveStyleTool.js'; import { StyleComparisonTool } from './style-comparison-tool/StyleComparisonTool.js'; +import { TilequeryTool } from './tilequery-tool/TilequeryTool.js'; import { UpdateStyleTool } from './update-style-tool/UpdateStyleTool.js'; // Central registry of all tools @@ -26,7 +27,8 @@ export const ALL_TOOLS = [ new BoundingBoxTool(), new CountryBoundingBoxTool(), new CoordinateConversionTool(), - new StyleComparisonTool() + new StyleComparisonTool(), + new TilequeryTool() ] as const; export type ToolInstance = (typeof ALL_TOOLS)[number];