diff --git a/src/index.ts b/src/index.ts index 50d53a7..fa85583 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,6 +101,7 @@ server.server.setRequestHandler(ListPromptsRequestSchema, async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (server.server as any).setRequestHandler( GetPromptRequestSchema, + // eslint-disable-next-line @typescript-eslint/no-explicit-any async (request: any) => { const { name, arguments: args } = request.params; diff --git a/src/prompts/CleanGpsTracePrompt.ts b/src/prompts/CleanGpsTracePrompt.ts new file mode 100644 index 0000000..183595b --- /dev/null +++ b/src/prompts/CleanGpsTracePrompt.ts @@ -0,0 +1,92 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { BasePrompt } from './BasePrompt.js'; +import type { + PromptArgument, + PromptMessage +} from '@modelcontextprotocol/sdk/types.js'; + +/** + * Prompt for cleaning and snapping GPS traces to road networks. + * + * This prompt guides the agent through: + * 1. Processing raw GPS coordinates + * 2. Snapping them to the road network using Map Matching + * 3. Returning a clean, accurate route + * + * Example queries: + * - "Clean up this noisy GPS trace" + * - "Snap these GPS points to roads" + * - "Match my recorded GPS track to the road network" + */ +export class CleanGpsTracePrompt extends BasePrompt { + readonly name = 'clean-gps-trace'; + readonly description = + 'Cleans noisy GPS traces by snapping them to the road network and returning an accurate route'; + + readonly arguments: PromptArgument[] = [ + { + name: 'coordinates', + description: + 'GPS trace as coordinates in format "lng1,lat1;lng2,lat2;..." or as an array', + required: true + }, + { + name: 'mode', + description: + 'Travel mode: driving, walking, or cycling (default: driving)', + required: false + }, + { + name: 'timestamps', + description: + 'Optional Unix timestamps for each coordinate (comma-separated)', + required: false + } + ]; + + getMessages(args: Record): PromptMessage[] { + this.validateArguments(args); + + const { coordinates, mode = 'driving', timestamps } = args; + + const timestampNote = timestamps + ? '\n - Include the provided timestamps to improve matching accuracy' + : ''; + + return [ + { + role: 'user', + content: { + type: 'text', + text: `Clean this GPS trace and snap it to the road network: ${coordinates} + +Please follow these steps: +1. Parse the GPS coordinates (convert to proper coordinate format if needed) +2. Use map_matching_tool to snap the trace to roads: + - Pass the coordinates array in order they were recorded + - Set profile to ${mode}${timestampNote} + - Request annotations for distance, duration, and speed if available +3. Display: + - Map visualization showing: + * Original GPS points (in one color) + * Matched route on roads (in another color) + - Statistics: + * Total matched distance + * Total duration + * Confidence score (if available) + * Number of points matched vs original + - Any anomalies or gaps in the trace + +The map_matching_tool will: +- Snap noisy GPS coordinates to the nearest roads +- Fill in gaps where GPS signal was lost +- Return a clean route that follows the actual road network + +Format the output clearly showing before/after comparison and route quality metrics.` + } + } + ]; + } +} diff --git a/src/prompts/OptimizeDeliveriesPrompt.ts b/src/prompts/OptimizeDeliveriesPrompt.ts new file mode 100644 index 0000000..9bc8673 --- /dev/null +++ b/src/prompts/OptimizeDeliveriesPrompt.ts @@ -0,0 +1,88 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { BasePrompt } from './BasePrompt.js'; +import type { + PromptArgument, + PromptMessage +} from '@modelcontextprotocol/sdk/types.js'; + +/** + * Prompt for optimizing delivery routes using the Optimization API v1. + * + * This prompt guides the agent through: + * 1. Geocoding delivery locations (if needed) + * 2. Using optimization_tool to find optimal route (2-12 locations) + * 3. Presenting the optimized waypoint sequence with trip statistics + * + * Example queries: + * - "Optimize my delivery route for these addresses" + * - "Find the best order to visit these locations" + * - "Plan the most efficient route for my deliveries today" + * + * Note: Limited to 12 coordinates per request (Optimization API v1 constraint) + */ +export class OptimizeDeliveriesPrompt extends BasePrompt { + readonly name = 'optimize-deliveries'; + readonly description = + 'Optimizes delivery routes to minimize travel time and find the best order to visit multiple locations'; + + readonly arguments: PromptArgument[] = [ + { + name: 'locations', + description: + 'Comma-separated list of addresses or coordinates to optimize', + required: true + }, + { + name: 'mode', + description: + 'Travel mode: driving, walking, or cycling (default: driving)', + required: false + }, + { + name: 'start', + description: 'Starting location (defaults to first location in list)', + required: false + } + ]; + + getMessages(args: Record): PromptMessage[] { + this.validateArguments(args); + + const { locations, mode = 'driving', start } = args; + + return [ + { + role: 'user', + content: { + type: 'text', + text: `Optimize a ${mode} route for these locations: ${locations}${start ? ` starting from ${start}` : ''}. + +Please follow these steps: +1. Geocode all locations to get coordinates (if they're addresses) + - IMPORTANT: The Optimization API v1 supports 2-12 coordinates maximum + - If you have more than 12 locations, inform the user and ask which locations to prioritize + +2. Use optimization_tool to find the optimal route: + - Pass coordinates array (2-12 coordinates) + - Set profile to mapbox/${mode} + - Optionally set geometries to "geojson" for map visualization + - Consider using roundtrip:false for one-way trips + - The tool returns results immediately (synchronous) + +3. Display the optimized route: + - Show the waypoints in optimal visiting order (use waypoint_index to show original positions) + - Total distance (from trips[0].distance) and duration (from trips[0].duration) + - Map visualization if geometry was requested + - Individual leg details if relevant + +Format the output to be clear with: +- Numbered list of stops in optimal order (e.g., "1. Stop 3 (original position 2) → 2. Stop 1 (original position 0)") +- Total trip statistics (distance in km, duration in minutes) +- Map showing the complete route if geometry is available` + } + } + ]; + } +} diff --git a/src/prompts/promptRegistry.ts b/src/prompts/promptRegistry.ts index 2371b2c..91f640b 100644 --- a/src/prompts/promptRegistry.ts +++ b/src/prompts/promptRegistry.ts @@ -4,6 +4,8 @@ import { FindPlacesNearbyPrompt } from './FindPlacesNearbyPrompt.js'; import { GetDirectionsPrompt } from './GetDirectionsPrompt.js'; import { ShowReachableAreasPrompt } from './ShowReachableAreasPrompt.js'; +import { OptimizeDeliveriesPrompt } from './OptimizeDeliveriesPrompt.js'; +import { CleanGpsTracePrompt } from './CleanGpsTracePrompt.js'; /** * Central registry of all available prompts. @@ -16,7 +18,9 @@ import { ShowReachableAreasPrompt } from './ShowReachableAreasPrompt.js'; const ALL_PROMPTS = [ new FindPlacesNearbyPrompt(), new GetDirectionsPrompt(), - new ShowReachableAreasPrompt() + new ShowReachableAreasPrompt(), + new OptimizeDeliveriesPrompt(), + new CleanGpsTracePrompt() ] as const; /** diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index 665999e..08db781 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -62,7 +62,13 @@ export abstract class BaseTool< (this.outputSchema as unknown as z.ZodObject).shape; } - return server.registerTool( + // Type assertion to avoid "Type instantiation is excessively deep" error + // This is a known issue in MCP SDK 1.25.1: https://github.com/modelcontextprotocol/typescript-sdk/issues/985 + // TODO: Remove this workaround when SDK fixes their type definitions + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const serverAny = server as any; + + return serverAny.registerTool( this.name, config, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/tools/map-matching-tool/MapMatchingTool.input.schema.ts b/src/tools/map-matching-tool/MapMatchingTool.input.schema.ts new file mode 100644 index 0000000..6ad43df --- /dev/null +++ b/src/tools/map-matching-tool/MapMatchingTool.input.schema.ts @@ -0,0 +1,74 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { coordinateSchema } from '../../schemas/shared.js'; + +export const MapMatchingInputSchema = z.object({ + coordinates: z + .array(coordinateSchema) + .min(2, 'At least two coordinate pairs are required.') + .max(100, 'Up to 100 coordinate pairs are supported.') + .describe( + 'Array of coordinate objects with longitude and latitude properties representing a GPS trace. ' + + 'Must include at least 2 and up to 100 coordinate pairs. ' + + 'Coordinates should be in the order they were recorded.' + ), + profile: z + .enum(['driving', 'driving-traffic', 'walking', 'cycling']) + .default('driving') + .describe( + 'Routing profile for different modes of transport. Options: \n' + + '- driving: automotive based on road network\n' + + '- driving-traffic: automotive with current traffic conditions\n' + + '- walking: pedestrian/hiking\n' + + '- cycling: bicycle' + ), + timestamps: z + .array(z.number().int().positive()) + .optional() + .describe( + 'Array of Unix timestamps (in seconds) corresponding to each coordinate. ' + + 'If provided, must have the same length as coordinates array. ' + + 'Used to improve matching accuracy based on speed.' + ), + radiuses: z + .array(z.number().min(0)) + .optional() + .describe( + 'Array of maximum distances (in meters) each coordinate can snap to the road network. ' + + 'If provided, must have the same length as coordinates array. ' + + 'Default is unlimited. Use smaller values (5-25m) for high-quality GPS, ' + + 'larger values (50-100m) for noisy GPS traces.' + ), + annotations: z + .array(z.enum(['speed', 'distance', 'duration', 'congestion'])) + .optional() + .describe( + 'Additional data to include in the response. Options: \n' + + '- speed: Speed limit per segment (km/h)\n' + + '- distance: Distance per segment (meters)\n' + + '- duration: Duration per segment (seconds)\n' + + '- congestion: Traffic level per segment (low, moderate, heavy, severe)' + ), + overview: z + .enum(['full', 'simplified', 'false']) + .default('full') + .describe( + 'Format of the returned geometry. Options: \n' + + '- full: Returns full geometry with all points\n' + + '- simplified: Returns simplified geometry\n' + + '- false: No geometry returned' + ), + geometries: z + .enum(['geojson', 'polyline', 'polyline6']) + .default('geojson') + .describe( + 'Format of the returned geometry. Options: \n' + + '- geojson: GeoJSON LineString (recommended)\n' + + '- polyline: Polyline with precision 5\n' + + '- polyline6: Polyline with precision 6' + ) +}); + +export type MapMatchingInput = z.infer; diff --git a/src/tools/map-matching-tool/MapMatchingTool.output.schema.ts b/src/tools/map-matching-tool/MapMatchingTool.output.schema.ts new file mode 100644 index 0000000..4288514 --- /dev/null +++ b/src/tools/map-matching-tool/MapMatchingTool.output.schema.ts @@ -0,0 +1,56 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// GeoJSON LineString schema +const GeoJSONLineStringSchema = z.object({ + type: z.literal('LineString'), + coordinates: z.array( + z + .tuple([z.number(), z.number()]) + .or(z.tuple([z.number(), z.number(), z.number()])) + ) +}); + +// Tracepoint schema - represents a snapped coordinate +const TracepointSchema = z.object({ + name: z.string().optional(), + location: z.tuple([z.number(), z.number()]), + waypoint_index: z.number().optional(), + matchings_index: z.number(), + alternatives_count: z.number() +}); + +// Matching schema - represents a matched route +const MatchingSchema = z.object({ + confidence: z.number().min(0).max(1), + distance: z.number(), + duration: z.number(), + geometry: z.union([GeoJSONLineStringSchema, z.string()]), + legs: z + .array( + z.object({ + distance: z.number(), + duration: z.number(), + annotation: z + .object({ + speed: z.array(z.number()).optional(), + distance: z.array(z.number()).optional(), + duration: z.array(z.number()).optional(), + congestion: z.array(z.string()).optional() + }) + .optional() + }) + ) + .optional() +}); + +// Main output schema +export const MapMatchingOutputSchema = z.object({ + code: z.string(), + matchings: z.array(MatchingSchema), + tracepoints: z.array(TracepointSchema.nullable()) +}); + +export type MapMatchingOutput = z.infer; diff --git a/src/tools/map-matching-tool/MapMatchingTool.ts b/src/tools/map-matching-tool/MapMatchingTool.ts new file mode 100644 index 0000000..a5dd95e --- /dev/null +++ b/src/tools/map-matching-tool/MapMatchingTool.ts @@ -0,0 +1,155 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { URLSearchParams } from 'node:url'; +import type { z } from 'zod'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { MapMatchingInputSchema } from './MapMatchingTool.input.schema.js'; +import { + MapMatchingOutputSchema, + type MapMatchingOutput +} from './MapMatchingTool.output.schema.js'; +import type { HttpRequest } from '../../utils/types.js'; + +// Docs: https://docs.mapbox.com/api/navigation/map-matching/ + +export class MapMatchingTool extends MapboxApiBasedTool< + typeof MapMatchingInputSchema, + typeof MapMatchingOutputSchema +> { + name = 'map_matching_tool'; + description = + 'Snap GPS traces to roads using Mapbox Map Matching API. Takes noisy/inaccurate ' + + 'coordinate sequences (2-100 points) and returns clean routes aligned with actual ' + + 'roads, bike paths, or walkways. Useful for analyzing recorded trips, cleaning ' + + 'fleet tracking data, or processing fitness activity traces. Returns confidence ' + + 'scores, matched geometry, and optional traffic/speed annotations.'; + annotations = { + title: 'Map Matching Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: MapMatchingInputSchema, + outputSchema: MapMatchingOutputSchema, + httpRequest: params.httpRequest + }); + } + + protected async execute( + input: z.infer, + accessToken: string + ): Promise { + // Validate timestamps array length matches coordinates + if ( + input.timestamps && + input.timestamps.length !== input.coordinates.length + ) { + return { + content: [ + { + type: 'text', + text: 'The timestamps array must have the same length as the coordinates array' + } + ], + isError: true + }; + } + + // Validate radiuses array length matches coordinates + if (input.radiuses && input.radiuses.length !== input.coordinates.length) { + return { + content: [ + { + type: 'text', + text: 'The radiuses array must have the same length as the coordinates array' + } + ], + isError: true + }; + } + + // Build coordinate string: "lon1,lat1;lon2,lat2;..." + const coordsString = input.coordinates + .map((coord) => `${coord.longitude},${coord.latitude}`) + .join(';'); + + // Build query parameters + const queryParams = new URLSearchParams(); + queryParams.append('access_token', accessToken); + queryParams.append('geometries', input.geometries); + queryParams.append('overview', input.overview); + + // Add timestamps if provided (semicolon-separated) + if (input.timestamps) { + queryParams.append('timestamps', input.timestamps.join(';')); + } + + // Add radiuses if provided (semicolon-separated) + if (input.radiuses) { + queryParams.append('radiuses', input.radiuses.join(';')); + } + + // Add annotations if provided (comma-separated) + if (input.annotations && input.annotations.length > 0) { + queryParams.append('annotations', input.annotations.join(',')); + } + + const url = `${MapboxApiBasedTool.mapboxApiEndpoint}matching/v5/mapbox/${input.profile}/${coordsString}?${queryParams.toString()}`; + + const response = await this.httpRequest(url); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `Request failed with status ${response.status}: ${response.statusText}`; + + try { + const errorJson = JSON.parse(errorText); + if (errorJson.message) { + errorMessage = `${errorMessage} - ${errorJson.message}`; + } + } catch { + if (errorText) { + errorMessage = `${errorMessage} - ${errorText}`; + } + } + + return { + content: [{ type: 'text', text: errorMessage }], + isError: true + }; + } + + const data = (await response.json()) as MapMatchingOutput; + + // Validate the response against our output schema + try { + const validatedData = MapMatchingOutputSchema.parse(data); + + return { + content: [ + { type: 'text', text: JSON.stringify(validatedData, null, 2) } + ], + structuredContent: validatedData, + isError: false + }; + } catch (validationError) { + // If validation fails, return the raw result anyway with a warning + this.log( + 'warning', + `Schema validation warning: ${validationError instanceof Error ? validationError.message : String(validationError)}` + ); + + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: data, + isError: false + }; + } + } +} diff --git a/src/tools/optimization-tool/OptimizationTool.input.schema.ts b/src/tools/optimization-tool/OptimizationTool.input.schema.ts new file mode 100644 index 0000000..c813fe5 --- /dev/null +++ b/src/tools/optimization-tool/OptimizationTool.input.schema.ts @@ -0,0 +1,122 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import z from 'zod'; +import { coordinateSchema } from '../../schemas/shared.js'; + +// Profile schema (driving, walking, cycling, driving-traffic) +const profileSchema = z + .enum([ + 'mapbox/driving', + 'mapbox/walking', + 'mapbox/cycling', + 'mapbox/driving-traffic' + ]) + .describe('Routing profile'); + +/** + * Input schema for OptimizationTool (Mapbox Optimization API v1) + * + * The Optimization API returns a duration-optimized route between the input coordinates. + * This is a simplified traveling salesman problem (TSP) solver. + */ +export const OptimizationInputSchema = z.object({ + coordinates: z + .array(coordinateSchema) + .min(2, 'At least 2 coordinates are required') + .max(12, 'Maximum 12 coordinates allowed') + .describe( + 'Array of {longitude, latitude} coordinate pairs to optimize a route through. ' + + 'Must include at least 2 and at most 12 coordinate pairs.' + ), + profile: profileSchema + .optional() + .default('mapbox/driving') + .describe('Routing profile to use for optimization'), + source: z + .enum(['any', 'first']) + .optional() + .describe( + 'Starting waypoint. "first" (default) uses the first coordinate, "any" allows any coordinate as the start' + ), + destination: z + .enum(['any', 'last']) + .optional() + .describe( + 'Ending waypoint. "last" (default) uses the last coordinate, "any" allows any coordinate as the end' + ), + roundtrip: z + .boolean() + .optional() + .describe( + 'Return to the starting point. Default is true. Set to false for one-way trips.' + ), + annotations: z + .array(z.enum(['duration', 'distance', 'speed'])) + .optional() + .describe( + 'Additional metadata to include for each leg (duration, distance, speed)' + ), + geometries: z + .enum(['geojson', 'polyline', 'polyline6']) + .optional() + .describe( + 'Format for route geometry. Default is "polyline". Use "geojson" for easy visualization.' + ), + overview: z + .enum(['full', 'simplified', 'false']) + .optional() + .describe( + 'Level of detail for route geometry. "full" includes all points, "simplified" reduces points, "false" omits geometry.' + ), + steps: z + .boolean() + .optional() + .describe('Include turn-by-turn instructions. Default is false.'), + radiuses: z + .array(z.union([z.number().nonnegative(), z.literal('unlimited')])) + .optional() + .describe( + 'Maximum distance in meters to snap each coordinate to the road network. Use "unlimited" for no limit.' + ), + approaches: z + .array(z.enum(['unrestricted', 'curb'])) + .optional() + .describe( + 'Side of the road to approach each waypoint from. "unrestricted" allows either side, "curb" uses driving side of region.' + ), + bearings: z + .array( + z.object({ + angle: z + .number() + .int() + .min(0) + .max(360) + .describe('Angle in degrees from true north'), + range: z + .number() + .int() + .min(0) + .max(180) + .describe('Allowed deviation in degrees') + }) + ) + .optional() + .describe( + 'Directional constraints for each coordinate. Influences route selection based on travel direction.' + ), + distributions: z + .array( + z.object({ + pickup: z.number().int().min(0).describe('Index of pickup location'), + dropoff: z.number().int().min(0).describe('Index of dropoff location') + }) + ) + .optional() + .describe( + 'Pickup and dropoff location pairs. Ensures pickup happens before dropoff in the optimized route.' + ) +}); + +export type OptimizationInput = z.infer; diff --git a/src/tools/optimization-tool/OptimizationTool.output.schema.ts b/src/tools/optimization-tool/OptimizationTool.output.schema.ts new file mode 100644 index 0000000..1e05655 --- /dev/null +++ b/src/tools/optimization-tool/OptimizationTool.output.schema.ts @@ -0,0 +1,132 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +/** + * Waypoint schema for optimized trips + */ +const waypointSchema = z + .object({ + name: z.string().optional().describe('Street or location name'), + location: z + .tuple([z.number(), z.number()]) + .describe('Coordinates as [longitude, latitude]'), + waypoint_index: z + .number() + .int() + .describe('Index in the original coordinates array'), + trips_index: z + .number() + .int() + .describe('Index of the trip this waypoint belongs to') + }) + .passthrough(); + +/** + * Leg annotation schema + */ +const annotationSchema = z + .object({ + duration: z + .array(z.number()) + .optional() + .describe('Duration of each segment in seconds'), + distance: z + .array(z.number()) + .optional() + .describe('Distance of each segment in meters'), + speed: z + .array(z.number()) + .optional() + .describe('Speed of each segment in meters per second') + }) + .passthrough(); + +/** + * Step schema for turn-by-turn instructions + */ +const stepSchema = z + .object({ + distance: z.number().describe('Distance for this step in meters'), + duration: z.number().describe('Duration for this step in seconds'), + geometry: z + .union([z.string(), z.object({})]) + .optional() + .describe('Step geometry'), + name: z.string().optional().describe('Street name'), + mode: z.string().optional().describe('Mode of travel'), + maneuver: z + .object({}) + .passthrough() + .optional() + .describe('Maneuver details'), + intersections: z + .array(z.object({}).passthrough()) + .optional() + .describe('Intersection details') + }) + .passthrough(); + +/** + * Leg schema - represents travel between two waypoints + */ +const legSchema = z + .object({ + distance: z.number().describe('Distance for this leg in meters'), + duration: z.number().describe('Duration for this leg in seconds'), + weight: z.number().optional().describe('Weight for this leg'), + weight_name: z.string().optional().describe('Name of the weight metric'), + steps: z.array(stepSchema).optional().describe('Turn-by-turn instructions'), + annotation: annotationSchema + .optional() + .describe('Detailed annotations for this leg') + }) + .passthrough(); + +/** + * Trip schema - represents one complete optimized route + */ +const tripSchema = z + .object({ + geometry: z + .union([z.string(), z.object({}).passthrough()]) + .optional() + .describe('Route geometry (polyline, polyline6, or GeoJSON)'), + legs: z.array(legSchema).describe('Array of legs between waypoints'), + weight: z.number().optional().describe('Total weight for the trip'), + weight_name: z.string().optional().describe('Name of the weight metric'), + duration: z.number().describe('Total duration in seconds'), + distance: z.number().describe('Total distance in meters') + }) + .passthrough(); + +/** + * Output schema for OptimizationTool (Mapbox Optimization API v1) + */ +export const OptimizationOutputSchema = z + .object({ + code: z + .string() + .describe( + 'Response code: "Ok" for success, "NoRoute" if no route found, error codes for failures' + ), + waypoints: z + .array(waypointSchema) + .optional() + .describe('Array of waypoints in the optimized order'), + trips: z + .array(tripSchema) + .optional() + .describe( + 'Array of optimized trips (typically one trip with all waypoints)' + ), + // Error response fields + message: z.string().optional().describe('Error message if code is not "Ok"') + }) + .passthrough(); + +/** + * Type inference for OptimizationOutput + */ +export type OptimizationOutput = z.infer; diff --git a/src/tools/optimization-tool/OptimizationTool.ts b/src/tools/optimization-tool/OptimizationTool.ts new file mode 100644 index 0000000..8b9fc78 --- /dev/null +++ b/src/tools/optimization-tool/OptimizationTool.ts @@ -0,0 +1,217 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { z } from 'zod'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { OptimizationInputSchema } from './OptimizationTool.input.schema.js'; +import { + OptimizationOutputSchema, + type OptimizationOutput +} from './OptimizationTool.output.schema.js'; + +// API documentation: https://docs.mapbox.com/api/navigation/optimization-v1/ + +/** + * OptimizationTool - Solves traveling salesman problems using the Mapbox Optimization API v1. + * Returns a duration-optimized route visiting all input coordinates. + */ +export class OptimizationTool extends MapboxApiBasedTool< + typeof OptimizationInputSchema, + typeof OptimizationOutputSchema +> { + name = 'optimization_tool'; + description = + 'Solves traveling salesman problems using the Mapbox Optimization API v1. ' + + 'Finds the optimal order to visit 2-12 coordinates, minimizing total travel time. ' + + 'Returns waypoints in optimized order with complete route details including distance, duration, and optional geometry.\n\n' + + 'USAGE:\n' + + 'Provide an array of 2-12 coordinates in {longitude, latitude} format and optionally a routing profile. ' + + 'The API will determine the most efficient visiting order and return:\n' + + '- Optimized waypoint sequence\n' + + '- Total trip distance and duration\n' + + '- Route geometry (if requested)\n' + + '- Turn-by-turn instructions (if requested)\n\n' + + 'KEY PARAMETERS:\n' + + '- coordinates: 2-12 location pairs (required)\n' + + '- profile: Routing mode (driving/walking/cycling/driving-traffic, default: driving)\n' + + '- roundtrip: Return to start (default: true)\n' + + '- source: Starting point ("first" or "any")\n' + + '- destination: Ending point ("last" or "any")\n' + + '- geometries: Route geometry format ("geojson" for visualization)\n' + + '- steps: Include turn-by-turn instructions\n' + + '- annotations: Additional metadata (duration, distance, speed)\n\n' + + 'ADVANCED FEATURES:\n' + + '- distributions: Specify pickup/dropoff pairs to ensure pickups happen before dropoffs\n' + + '- radiuses: Control snap distance to roads for each waypoint\n' + + '- bearings: Influence route based on travel direction\n' + + '- approaches: Control which side of the road to approach from\n\n' + + 'OUTPUT FORMAT:\n' + + 'Returns a single optimized trip with:\n' + + '- waypoints: Array of locations in the optimal visiting order with their original indices\n' + + '- trips[0].distance: Total distance in meters\n' + + '- trips[0].duration: Total duration in seconds\n' + + '- trips[0].legs: Array of route segments between consecutive waypoints\n' + + '- trips[0].geometry: Complete route path (if requested)\n\n' + + 'PRESENTING RESULTS:\n' + + 'When sharing results, present: (1) The optimized waypoint order showing original positions ' + + '(e.g., "location-2 → location-0 → location-1"). (2) Total distance and duration. ' + + '(3) Individual leg details if relevant. Use waypoint_index to reference original coordinate positions.\n\n' + + 'LIMITATIONS:\n' + + '- Maximum 12 coordinates per request\n' + + '- Maximum 25 pickup/dropoff pairs in distributions\n' + + '- For larger problems, consider splitting into multiple smaller optimizations\n\n' + + 'IMPORTANT: Coordinates must be {longitude, latitude} objects where longitude comes first.'; + annotations = { + title: 'Optimization Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true + }; + + constructor({ httpRequest }: { httpRequest: HttpRequest }) { + super({ + inputSchema: OptimizationInputSchema, + outputSchema: OptimizationOutputSchema, + httpRequest + }); + } + + /** + * Execute the tool logic + * @param input - Validated input from OptimizationInputSchema + * @param accessToken - Mapbox access token + * @returns CallToolResult with structured output + */ + protected async execute( + input: z.infer, + accessToken: string + ): Promise { + // Build coordinates string for URL path + const coordinatesString = input.coordinates + .map((coord) => `${coord.longitude},${coord.latitude}`) + .join(';'); + + // Extract profile (remove 'mapbox/' prefix for URL) + const profile = input.profile || 'mapbox/driving'; + + // Build query parameters + const params = new URLSearchParams(); + params.append('access_token', accessToken); + + if (input.source) { + params.append('source', input.source); + } + if (input.destination) { + params.append('destination', input.destination); + } + if (input.roundtrip !== undefined) { + params.append('roundtrip', String(input.roundtrip)); + } + if (input.annotations) { + params.append('annotations', input.annotations.join(',')); + } + if (input.geometries) { + params.append('geometries', input.geometries); + } + if (input.overview) { + params.append('overview', input.overview); + } + if (input.steps !== undefined) { + params.append('steps', String(input.steps)); + } + if (input.radiuses) { + params.append( + 'radiuses', + input.radiuses + .map((r) => (r === 'unlimited' ? 'unlimited' : String(r))) + .join(';') + ); + } + if (input.approaches) { + params.append('approaches', input.approaches.join(';')); + } + if (input.bearings) { + params.append( + 'bearings', + input.bearings.map((b) => `${b.angle},${b.range}`).join(';') + ); + } + if (input.distributions) { + params.append( + 'distributions', + input.distributions.map((d) => `${d.pickup},${d.dropoff}`).join(';') + ); + } + + // Build API URL + const url = `${MapboxApiBasedTool.mapboxApiEndpoint}optimized-trips/v1/${profile}/${coordinatesString}?${params.toString()}`; + + // Make request + const response = await this.httpRequest(url); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `Request failed with status ${response.status}: ${response.statusText}`; + + try { + const errorJson = JSON.parse(errorText); + if (errorJson.message) { + errorMessage = `${errorMessage} - ${errorJson.message}`; + } + } catch { + // If parsing fails, use the raw text + if (errorText) { + errorMessage = `${errorMessage} - ${errorText}`; + } + } + + return { + content: [{ type: 'text', text: errorMessage }], + isError: true + }; + } + + const result = (await response.json()) as OptimizationOutput; + + // Check for API-level errors (code !== "Ok") + if (result.code && result.code !== 'Ok') { + return { + content: [ + { + type: 'text', + text: `API Error: ${result.code}${result.message ? ` - ${result.message}` : ''}` + } + ], + isError: true + }; + } + + // Validate the response against our output schema + try { + const validatedData = OptimizationOutputSchema.parse(result); + + return { + content: [ + { type: 'text', text: JSON.stringify(validatedData, null, 2) } + ], + structuredContent: validatedData, + isError: false + }; + } catch (validationError) { + // If validation fails, return the raw result anyway with a warning + this.log( + 'warning', + `Schema validation warning: ${validationError instanceof Error ? validationError.message : String(validationError)}` + ); + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + isError: false + }; + } + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 2d6adcd..3bb828a 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -6,7 +6,9 @@ import { CategoryListTool } from './category-list-tool/CategoryListTool.js'; import { CategorySearchTool } from './category-search-tool/CategorySearchTool.js'; import { DirectionsTool } from './directions-tool/DirectionsTool.js'; import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; +import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; import { MatrixTool } from './matrix-tool/MatrixTool.js'; +import { OptimizationTool } from './optimization-tool/OptimizationTool.js'; import { ResourceReaderTool } from './resource-reader-tool/ResourceReaderTool.js'; import { ReverseGeocodeTool } from './reverse-geocode-tool/ReverseGeocodeTool.js'; import { StaticMapImageTool } from './static-map-image-tool/StaticMapImageTool.js'; @@ -23,7 +25,9 @@ export const ALL_TOOLS = [ new CategorySearchTool({ httpRequest }), new DirectionsTool({ httpRequest }), new IsochroneTool({ httpRequest }), + new MapMatchingTool({ httpRequest }), new MatrixTool({ httpRequest }), + new OptimizationTool({ httpRequest }), new ReverseGeocodeTool({ httpRequest }), new StaticMapImageTool({ httpRequest }), new SearchAndGeocodeTool({ httpRequest }) diff --git a/test/tools/map-matching-tool/MapMatchingTool.test.ts b/test/tools/map-matching-tool/MapMatchingTool.test.ts new file mode 100644 index 0000000..0c403aa --- /dev/null +++ b/test/tools/map-matching-tool/MapMatchingTool.test.ts @@ -0,0 +1,248 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + setupHttpRequest, + assertHeadersSent +} from '../../utils/httpPipelineUtils.js'; +import { MapMatchingTool } from '../../../src/tools/map-matching-tool/MapMatchingTool.js'; + +const sampleMapMatchingResponse = { + code: 'Ok', + matchings: [ + { + confidence: 0.95, + distance: 1234.5, + duration: 123.4, + geometry: { + type: 'LineString' as const, + coordinates: [ + [-122.4194, 37.7749], + [-122.4195, 37.775], + [-122.4197, 37.7751] + ] + }, + legs: [ + { + distance: 617.25, + duration: 61.7, + annotation: { + speed: [50, 45], + distance: [300, 317.25], + duration: [21.6, 40.1] + } + } + ] + } + ], + tracepoints: [ + { + name: 'Market Street', + location: [-122.4194, 37.7749], + waypoint_index: 0, + matchings_index: 0, + alternatives_count: 0 + }, + { + name: 'Market Street', + location: [-122.4195, 37.775], + matchings_index: 0, + alternatives_count: 1 + }, + { + name: 'Valencia Street', + location: [-122.4197, 37.7751], + matchings_index: 0, + alternatives_count: 0 + } + ] +}; + +describe('MapMatchingTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sends custom header', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + + await new MapMatchingTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 } + ], + profile: 'driving' + }); + + assertHeadersSent(mockHttpRequest); + }); + + it('returns structured content for valid coordinates', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => sampleMapMatchingResponse + }); + + const tool = new MapMatchingTool({ httpRequest }); + const result = await tool.run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 }, + { longitude: -122.4197, latitude: 37.7751 } + ], + profile: 'driving' + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent).toMatchObject({ + code: 'Ok', + matchings: expect.arrayContaining([ + expect.objectContaining({ + confidence: expect.any(Number), + distance: expect.any(Number), + duration: expect.any(Number) + }) + ]), + tracepoints: expect.any(Array) + }); + }); + + it('includes timestamps when provided', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => sampleMapMatchingResponse + }); + + const tool = new MapMatchingTool({ httpRequest }); + await tool.run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 } + ], + profile: 'driving', + timestamps: [1234567890, 1234567900] + }); + + const callUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(callUrl).toContain('timestamps=1234567890%3B1234567900'); + }); + + it('includes radiuses when provided', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => sampleMapMatchingResponse + }); + + const tool = new MapMatchingTool({ httpRequest }); + await tool.run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 } + ], + profile: 'driving', + radiuses: [25, 25] + }); + + const callUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(callUrl).toContain('radiuses=25%3B25'); + }); + + it('includes annotations when provided', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => sampleMapMatchingResponse + }); + + const tool = new MapMatchingTool({ httpRequest }); + await tool.run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 } + ], + profile: 'driving', + annotations: ['speed', 'congestion'] + }); + + const callUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(callUrl).toContain('annotations=speed%2Ccongestion'); + }); + + it('returns error when timestamps length does not match coordinates', async () => { + const { httpRequest } = setupHttpRequest(); + + const tool = new MapMatchingTool({ httpRequest }); + const result = await tool.run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 } + ], + profile: 'driving', + timestamps: [1234567890] // Wrong length + }); + + expect(result.isError).toBe(true); + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining( + 'timestamps array must have the same length' + ) + }); + }); + + it('returns error when radiuses length does not match coordinates', async () => { + const { httpRequest } = setupHttpRequest(); + + const tool = new MapMatchingTool({ httpRequest }); + const result = await tool.run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 } + ], + profile: 'driving', + radiuses: [25, 25, 25] // Wrong length + }); + + expect(result.isError).toBe(true); + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('radiuses array must have the same length') + }); + }); + + it('supports different routing profiles', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => sampleMapMatchingResponse + }); + + const tool = new MapMatchingTool({ httpRequest }); + await tool.run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 } + ], + profile: 'cycling' + }); + + const callUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(callUrl).toContain('/matching/v5/mapbox/cycling/'); + }); + + it('uses geojson geometries by default', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => sampleMapMatchingResponse + }); + + const tool = new MapMatchingTool({ httpRequest }); + await tool.run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 } + ], + profile: 'driving' + }); + + const callUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(callUrl).toContain('geometries=geojson'); + }); +}); diff --git a/test/tools/optimization-tool/OptimizationTool.test.ts b/test/tools/optimization-tool/OptimizationTool.test.ts new file mode 100644 index 0000000..0b1f1c8 --- /dev/null +++ b/test/tools/optimization-tool/OptimizationTool.test.ts @@ -0,0 +1,301 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; +import { OptimizationTool } from '../../../src/tools/optimization-tool/OptimizationTool.js'; + +// Sample V1 API response (successful optimization) +const sampleOptimizationResponse = { + code: 'Ok', + waypoints: [ + { + name: 'Main Street', + location: [-122.4194, 37.7749], + waypoint_index: 0, + trips_index: 0 + }, + { + name: 'Broadway', + location: [-122.4089, 37.7895], + waypoint_index: 2, + trips_index: 0 + }, + { + name: 'Market Street', + location: [-122.4135, 37.7749], + waypoint_index: 1, + trips_index: 0 + } + ], + trips: [ + { + geometry: 'encoded_polyline_string', + legs: [ + { + distance: 1500.0, + duration: 300.0, + weight: 300.0, + weight_name: 'routability' + }, + { + distance: 1200.0, + duration: 240.0, + weight: 240.0, + weight_name: 'routability' + } + ], + weight: 540.0, + weight_name: 'routability', + duration: 540.0, + distance: 2700.0 + } + ] +}; + +describe('OptimizationTool (V1 API)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sends custom header', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => sampleOptimizationResponse + }); + + await new OptimizationTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4135, latitude: 37.7749 }, + { longitude: -122.4089, latitude: 37.7895 } + ] + }); + + expect(mockHttpRequest).toHaveBeenCalledWith( + expect.stringContaining('optimized-trips/v1/mapbox/driving/'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': expect.any(String) + }) + }) + ); + }); + + it('works with basic coordinates', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => sampleOptimizationResponse + }); + + const result = await new OptimizationTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4135, latitude: 37.7749 }, + { longitude: -122.4089, latitude: 37.7895 } + ] + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + code: 'Ok', + waypoints: expect.arrayContaining([ + expect.objectContaining({ waypoint_index: expect.any(Number) }) + ]), + trips: expect.arrayContaining([ + expect.objectContaining({ + distance: expect.any(Number), + duration: expect.any(Number) + }) + ]) + }); + + // Verify URL format for V1 API + expect(mockHttpRequest).toHaveBeenCalledWith( + expect.stringMatching( + /optimized-trips\/v1\/mapbox\/driving\/-122\.4194,37\.7749;-122\.4135,37\.7749;-122\.4089,37\.7895/ + ), + expect.any(Object) + ); + }); + + it('uses correct routing profile', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => sampleOptimizationResponse + }); + + await new OptimizationTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4089, latitude: 37.7895 } + ], + profile: 'mapbox/cycling' + }); + + expect(mockHttpRequest).toHaveBeenCalledWith( + expect.stringContaining('optimized-trips/v1/mapbox/cycling/'), + expect.any(Object) + ); + }); + + it('includes optional query parameters', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => sampleOptimizationResponse + }); + + await new OptimizationTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4089, latitude: 37.7895 }, + { longitude: -122.4135, latitude: 37.7749 } + ], + geometries: 'geojson', + roundtrip: false, + source: 'any', + destination: 'last', + annotations: ['duration', 'distance'], + steps: true + }); + + const callUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(callUrl).toContain('geometries=geojson'); + expect(callUrl).toContain('roundtrip=false'); + expect(callUrl).toContain('source=any'); + expect(callUrl).toContain('destination=last'); + expect(callUrl).toContain('annotations=duration%2Cdistance'); + expect(callUrl).toContain('steps=true'); + }); + + it('handles distributions parameter', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => sampleOptimizationResponse + }); + + await new OptimizationTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4089, latitude: 37.7895 }, + { longitude: -122.4135, latitude: 37.7749 } + ], + distributions: [{ pickup: 0, dropoff: 2 }] + }); + + const callUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(callUrl).toContain('distributions=0%2C2'); + }); + + it('handles bearings and approaches parameters', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + json: async () => sampleOptimizationResponse + }); + + await new OptimizationTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4089, latitude: 37.7895 } + ], + bearings: [ + { angle: 90, range: 45 }, + { angle: 180, range: 30 } + ], + approaches: ['curb', 'unrestricted'] + }); + + const callUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(callUrl).toContain('bearings=90%2C45%3B180%2C30'); + expect(callUrl).toContain('approaches=curb%3Bunrestricted'); + }); + + it('handles API error response with code !== Ok', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => ({ + code: 'NoRoute', + message: 'No route found between these locations' + }) + }); + + const result = await new OptimizationTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4089, latitude: 37.7895 } + ] + }); + + expect(result.isError).toBe(true); + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('NoRoute') + }); + }); + + it('handles HTTP error response', async () => { + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => '{"message": "Invalid access token"}' + }); + + const result = await new OptimizationTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4089, latitude: 37.7895 } + ] + }); + + expect(result.isError).toBe(true); + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('401') + }); + }); + + it('includes structured content in successful response', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => sampleOptimizationResponse + }); + + const result = await new OptimizationTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4089, latitude: 37.7895 } + ] + }); + + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent).toHaveProperty('code', 'Ok'); + expect(result.structuredContent).toHaveProperty('waypoints'); + expect(result.structuredContent).toHaveProperty('trips'); + }); + + it('validates coordinate count (minimum 2)', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + + // Schema validation should return error response (not throw) + const result = await new OptimizationTool({ httpRequest }).run({ + coordinates: [{ longitude: -122.4194, latitude: 37.7749 }] + }); + + expect(result.isError).toBe(true); + expect(mockHttpRequest).not.toHaveBeenCalled(); + }); + + it('validates coordinate count (maximum 12)', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + + // Create 13 coordinates (exceeds limit) + const coords = Array.from({ length: 13 }, (_, i) => ({ + longitude: -122.4194 + i * 0.01, + latitude: 37.7749 + })); + + // Schema validation should return error response (not throw) + const result = await new OptimizationTool({ httpRequest }).run({ + coordinates: coords + }); + + expect(result.isError).toBe(true); + expect(mockHttpRequest).not.toHaveBeenCalled(); + }); +});