From c320692ce82f6c5c5462127cf2f1b2f97c0a6d69 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Thu, 30 Jul 2026 11:49:22 -0400 Subject: [PATCH] feat: directions_tool route selection elicitation Directions routes have no stable ID across separate API calls, unlike search results (mapbox_id), so the chosen route is threaded through the self-fetch ref as a positional selectedRouteIndex, with a graceful fallback to routes[0] if it's out of range on re-fetch. Also corrects docs/elicitations.md, which described a two-stage directions_tool flow and automatic client-capability detection that were never actually implemented. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 5 + README.md | 1 + docs/elicitations.md | 61 ++----- src/resources/ui-apps/mapAppHtml.ts | 16 +- src/tools/directions-tool/DirectionsTool.ts | 126 ++++++++++++- .../ui-apps/mapAppHtml.script.test.ts | 150 ++++++++++++++++ .../directions-tool/DirectionsTool.test.ts | 168 ++++++++++++++++++ 7 files changed, 482 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea83e61..0f7d5439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## Unreleased +### New Features + +- **`directions_tool` — route selection elicitation.** When the Directions API returns two or more route alternatives, the tool now asks the user to pick one via `server.elicitInput(...)` (MCP elicitations), presenting each option's duration, distance, primary roads, traffic conditions, and incident count. Only the selected route is returned, and the choice is threaded through to the map preview's self-fetch so re-rendering shows the same route rather than defaulting back to the first one. If the client doesn't support elicitations, the user declines, or the call errors, the tool falls back to returning all route alternatives, matching prior behavior. +- Corrected `docs/elicitations.md`, which had described a two-stage `directions_tool` elicitation flow (routing preferences before the API call, plus automatic client-capability detection) that was never implemented. The doc now accurately describes the single-stage route-selection elicitation and the actual fallback mechanism (a `try`/`catch` around each `elicitInput` call, with no capability pre-check). + ## 0.13.0 - 2026-07-30 ### Security diff --git a/README.md b/README.md index c3d7032f..b0d3c0e6 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ For detailed setup instructions for different integrations, refer to the followi - [Cursor AI IDE Setup](./docs/cursor-setup.md) - Setting up a development environment in Cursor AI IDE - [Smolagents Integration](./docs/using-mcp-with-smolagents/README.md) - Example showing how to connect Smolagents AI agents to Mapbox's tools - **[Importing Tools Directly](./docs/importing-tools.md)** - Use Mapbox tools in your own applications without running the MCP server +- **[Elicitations](./docs/elicitations.md)** - How `search_and_geocode_tool` and `directions_tool` ask the user to disambiguate results or pick a route, and how they fall back gracefully when the client doesn't support it ## Example Prompts diff --git a/docs/elicitations.md b/docs/elicitations.md index 16126309..0b1b696a 100644 --- a/docs/elicitations.md +++ b/docs/elicitations.md @@ -1,61 +1,38 @@ # Elicitations -Some tools in this server use the MCP [elicitations](https://modelcontextprotocol.io/docs/concepts/elicitation) feature to ask the user for input during tool execution. This enables a more interactive experience — for example, letting the user choose between multiple routes or disambiguate between search results — rather than having the AI model make that choice unilaterally. +Some tools in this server use the MCP [elicitations](https://modelcontextprotocol.io/docs/concepts/elicitation) feature to ask the user for input during tool execution — for example, letting the user pick between multiple search results or route options, rather than having the model choose unilaterally. -## Client Support +## How it works -Elicitations require the MCP client to support the feature. The server detects this automatically at connection time by checking `clientCapabilities.elicitation`. +Each tool that uses elicitations calls `server.elicitInput(...)` directly and wraps the call in a `try`/`catch`. There is no capability pre-check or connection-time gating — every tool that can use elicitation is registered unconditionally, and the `catch` block is what makes an individual call fall back gracefully when the connected client doesn't support elicitation or the call otherwise fails. Support is therefore determined entirely by how the specific client you're using implements the MCP elicitation spec; check your client's own documentation for its current support status rather than relying on any list here, since that can change independently of this server. -**Clients with known elicitation support:** +If the user **declines** an elicitation prompt (as opposed to the client not supporting elicitation at all), the tool also falls back gracefully — see [Fallback Behavior](#fallback-behavior). -- Claude Desktop -- Claude Code +## Tools that use elicitations -**Clients without elicitation support** receive the standard tool responses with no interactive prompts (see [Fallback Behavior](#fallback-behavior) below). - -## Tools That Use Elicitations - -### `directions_tool` - -The directions tool uses a two-stage elicitation pattern. +### `search_and_geocode_tool` -**Stage 1 — Routing preferences (before the API call)** +When a search returns between 2 and 10 results, the tool asks the user to select the correct location before returning it. Each option is labeled with the place name and formatted address. -When the request is a simple A→B route with no exclusions already specified, the tool asks the user to choose routing preferences before making the API call: +If the user selects one, the tool also threads that choice into `render_map_tool`'s self-fetch ref (`selectedMapboxId`) so the map preview shows just that result — see [render-map-tool.md](./render-map-tool.md). -- Fastest route (tolls permitted) -- Avoid tolls -- Avoid highways -- Avoid ferries +### `directions_tool` -This ensures the API call is made with the right parameters from the start, rather than requiring a second call if the user's preferences aren't met. +When the Directions API returns two or more route alternatives, the tool asks the user to pick one before returning a result. Each option is labeled with duration, distance, primary roads, a rough traffic-conditions indicator, and any incident count. -> Stage 1 only triggers for two-waypoint routes with no `exclude` parameter already set. +If the user selects one, the tool returns only that route, and threads the selected route's position in the original alternatives array (`selectedRouteIndex`) into the self-fetch ref, so the map preview draws the same route rather than whichever the API happens to return first on the client's independent re-fetch. -**Stage 2 — Route selection (after the API call)** +> Route identity isn't stable across separate API calls the way a search result's `mapbox_id` is — there's no unique ID to key off. `selectedRouteIndex` is a best-effort position-based match: if the map preview's own re-fetch (which can return different alternatives, e.g. under `driving-traffic` as live conditions shift) doesn't have that many routes anymore, it falls back to the first one rather than erroring. -When the API returns two or more route alternatives, the tool presents each option with its duration, distance, primary roads, traffic conditions, and any incidents, and asks the user to pick one. Only the selected route is returned. +## Fallback behavior -> Stage 2 only triggers when two or more routes are returned. +| Tool | Client doesn't support elicitation, or the call errors | User declines the prompt | +| ------------------------- | ------------------------------------------------------ | ------------------------------ | +| `search_and_geocode_tool` | Returns all matching results | Returns all matching results | +| `directions_tool` | Returns all route alternatives | Returns all route alternatives | -**Fallback behavior:** If elicitations are unavailable or the user declines, Stage 1 uses the default routing parameters and Stage 2 returns all available routes for the AI model to evaluate. +Either way, the tool always completes with a usable, non-error result — elicitation is a UX enhancement layered on top of the normal response, not a requirement for the tool to function. Elicitation failures are logged at `warning` level (or silently swallowed where a client's own UI treats any logged notification during a tool call as a visible failure) rather than surfaced as tool errors. --- -### `search_and_geocode_tool` - -When a search returns between 2 and 10 results, the tool asks the user to select the correct location before returning it. Each option is labeled with the place name and formatted address. - -**Fallback behavior:** If elicitations are unavailable or the user declines, all results are returned for the AI model to evaluate. - -## Fallback Behavior - -When a client does not support elicitations, or when the user declines an elicitation prompt, the tools fall back gracefully: - -| Tool | Without elicitations | -| ------------------------- | ------------------------------- | -| `directions_tool` Stage 1 | Uses default routing parameters | -| `directions_tool` Stage 2 | Returns all route alternatives | -| `search_and_geocode_tool` | Returns all matching results | - -Elicitation failures (e.g., network or protocol errors) are caught and logged at `warning` level — the tool always completes with a usable result. +For questions or issues, please [open an issue](https://github.com/mapbox/mcp-server/issues) on GitHub. diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index 9357ce0a..ab90cb82 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -636,6 +636,11 @@ ${initialDataScript} if (typeof params.exclude !== 'string') return false; if (!/^[A-Za-z0-9_,.() -]+$/.test(params.exclude)) return false; } + if (params.selectedRouteIndex !== undefined && params.selectedRouteIndex !== null) { + if (typeof params.selectedRouteIndex !== 'number') return false; + if (!isFinite(params.selectedRouteIndex) || params.selectedRouteIndex < 0) return false; + if (Math.floor(params.selectedRouteIndex) !== params.selectedRouteIndex) return false; + } return true; } @@ -757,7 +762,16 @@ ${initialDataScript} return res.json(); }) .then(function(data) { - var route = data && data.routes && data.routes[0]; + var routes = (data && data.routes) || []; + // If elicitation resolved to a specific route server-side, draw that + // same one out of the freshly re-fetched alternatives — falling back + // to the first route if the index is out of range (e.g. the API + // returned fewer alternatives this time around), same fallback + // philosophy as selectedMapboxId in the search self-fetch. + var route = + (typeof params.selectedRouteIndex === 'number' && + routes[params.selectedRouteIndex]) || + routes[0]; var coords = route ? pickRouteGeometryClient(route) : null; if (!coords) { showError('Directions API returned no route.'); diff --git a/src/tools/directions-tool/DirectionsTool.ts b/src/tools/directions-tool/DirectionsTool.ts index d08ac613..6e82ef33 100644 --- a/src/tools/directions-tool/DirectionsTool.ts +++ b/src/tools/directions-tool/DirectionsTool.ts @@ -10,7 +10,8 @@ import { buildDirectionsRequestUrl } from './buildDirectionsRequestUrl.js'; import { DirectionsInputSchema } from './DirectionsTool.input.schema.js'; import { DirectionsResponseSchema, - type DirectionsResponse + type DirectionsResponse, + type Route } from './DirectionsTool.output.schema.js'; import type { HttpRequest } from '../..//utils/types.js'; import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js'; @@ -46,6 +47,105 @@ export class DirectionsTool extends MapboxApiBasedTool< httpRequest: params.httpRequest }); } + + private formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.round((seconds % 3600) / 60); + return hours > 0 ? `${hours}h ${minutes}min` : `${minutes}min`; + } + + private formatDistance(meters: number): string { + const km = (meters / 1000).toFixed(1); + const miles = (meters / 1609.34).toFixed(1); + return `${km}km (${miles}mi)`; + } + + /** One-line label for a route option in the Stage 2 elicitation form. */ + private describeRoute(route: Route): string { + const duration = this.formatDuration(route.duration); + const distance = this.formatDistance(route.distance); + const roads = route.leg_summaries?.[0] || 'Route'; + + let traffic = ''; + const c = route.congestion_information; + if (c) { + const total = + c.length_low + c.length_moderate + c.length_heavy + c.length_severe; + const heavyPct = + total > 0 + ? Math.round(((c.length_heavy + c.length_severe) / total) * 100) + : 0; + traffic = + heavyPct > 20 + ? ` ⚠️ Heavy traffic (${heavyPct}%)` + : c.length_moderate > 0 + ? ' 🟡 Moderate traffic' + : ' ✅ Light traffic'; + } + + const incidentCount = route.incidents_summary?.length ?? 0; + const incidents = + incidentCount > 0 ? ` • ${incidentCount} incident(s)` : ''; + + return `${duration} via ${roads} • ${distance}${traffic}${incidents}`; + } + + /** + * When multiple route alternatives come back, let the user pick one via + * MCP elicitation rather than handing all of them to the model. Returns + * the selected route's index (to thread through the self-fetch ref so the + * map preview draws the same route) and mutates nothing — the caller + * applies the filtered `routes` array itself. + * + * Mirrors search_and_geocode_tool's disambiguation elicitation: silent, + * best-effort fallback to "return everything" on decline, on an + * unsupported client, or on any elicitation error. + */ + private async elicitRouteSelection( + routes: Route[] + ): Promise { + if (!this.server || routes.length < 2) return undefined; + + try { + const options = routes.map((route, index) => ({ + value: String(index), + label: this.describeRoute(route) + })); + + const result = await this.server.server.elicitInput({ + mode: 'form', + message: `Found ${routes.length} routes. Choose your preferred route:`, + requestedSchema: { + type: 'object', + properties: { + selectedIndex: { + type: 'string', + title: 'Select Route', + description: 'Choose the route that best fits your needs', + enum: options.map((o) => o.value), + enumNames: options.map((o) => o.label) + } + }, + required: ['selectedIndex'] + } + }); + + if (result.action === 'accept' && result.content?.selectedIndex) { + const index = parseInt(String(result.content.selectedIndex), 10); + if (Number.isInteger(index) && index >= 0 && index < routes.length) { + return index; + } + } + } catch { + // Elicitation isn't supported by every MCP client (Claude Desktop + // doesn't, for example). Falling back to "return all routes" is the + // expected behavior — silent, since Claude Desktop's UI flags tool + // calls that emit notifications/message at any level as visually + // failed even when the JSON-RPC response is isError: false. + } + return undefined; + } + protected async execute( input: z.infer, accessToken: string @@ -217,6 +317,24 @@ export class DirectionsTool extends MapboxApiBasedTool< validatedData = cleanedData as DirectionsResponse; } + // When the API returns multiple route alternatives, let the user pick + // one via elicitation rather than handing every option to the model. + // selectedRouteIndex (into the *original* routes array) is threaded + // through the self-fetch ref below so the map preview draws the same + // route the user picked, not just whichever the API returns first. + let selectedRouteIndex: number | undefined; + if (validatedData.routes && validatedData.routes.length >= 2) { + selectedRouteIndex = await this.elicitRouteSelection( + validatedData.routes + ); + if (selectedRouteIndex !== undefined) { + validatedData = { + ...validatedData, + routes: [validatedData.routes[selectedRouteIndex]] + }; + } + } + // Check response size and conditionally create temporary resource const RESPONSE_SIZE_THRESHOLD = 50 * 1024; // 50KB const responseText = JSON.stringify(validatedData, null, 2); @@ -229,10 +347,14 @@ export class DirectionsTool extends MapboxApiBasedTool< // (already visible to the LLM), so unlike a mapbox://temp/ ref there's // nothing server-side that a restart or a rehydrated conversation could // invalidate. Works regardless of input.geometries since the self-fetch - // always forces geometries=geojson itself. + // always forces geometries=geojson itself. selectedRouteIndex (when + // elicitation resolved to one route) tells the client which of the + // freshly re-fetched alternatives to draw — see selectedMapboxId in + // search_and_geocode_tool for the equivalent pattern. const selfFetchRef = buildSelfFetchRef('directions', { coordinates: input.coordinates, routing_profile: input.routing_profile, + selectedRouteIndex, alternatives: input.alternatives, exclude: input.exclude, depart_at: input.depart_at, diff --git a/test/resources/ui-apps/mapAppHtml.script.test.ts b/test/resources/ui-apps/mapAppHtml.script.test.ts index 0aaf4336..7a16821c 100644 --- a/test/resources/ui-apps/mapAppHtml.script.test.ts +++ b/test/resources/ui-apps/mapAppHtml.script.test.ts @@ -421,6 +421,156 @@ describe('mapAppHtml directions self-fetch', () => { expect(fetchSpy).not.toHaveBeenCalled(); expect(errorEl?.textContent).toContain('Could not fetch route'); }); + + it('draws the route at selectedRouteIndex, not just the first one, when multiple routes come back', async () => { + const { sendToolResult, setFetchImpl, map, summaryEl } = + loadScriptSandbox(); + const addLayerSpy = vi.fn(); + map.addLayer = addLayerSpy; + + const fetchSpy = vi.fn(async () => ({ + ok: true, + json: async () => ({ + routes: [ + { + geometry: { + type: 'LineString', + coordinates: [ + [-77, 38], + [-76, 39] + ] + }, + distance: 10000, + duration: 600 + }, + { + geometry: { + type: 'LineString', + coordinates: [ + [-77, 38], + [-76.5, 38.2], + [-76, 39] + ] + }, + distance: 20000, + duration: 1200 + } + ] + }) + })); + setFetchImpl(fetchSpy); + + sendToolResult({ + structuredContent: { + mapboxRender: { + ref: 'mapbox://selffetch/directions?data=abc', + layers: [], + selfFetch: [ + { + tool: 'directions', + params: { + coordinates: [ + { longitude: -77, latitude: 38 }, + { longitude: -76, latitude: 39 } + ], + selectedRouteIndex: 1 + } + } + ] + } + } + }); + for (let i = 0; i < 6; i++) await Promise.resolve(); + + // Summary is derived from whichever route got drawn — routes[1]'s + // distance/duration (20000m / 1609.34 ≈ 12.4mi), not routes[0]'s. + expect(summaryEl?.textContent).toContain('12.4 mi'); + expect(summaryEl?.textContent).toContain('20 min'); + }); + + it('falls back to the first route when selectedRouteIndex is out of range', async () => { + const { sendToolResult, setFetchImpl, summaryEl } = loadScriptSandbox(); + + const fetchSpy = vi.fn(async () => ({ + ok: true, + json: async () => ({ + routes: [ + { + geometry: { + type: 'LineString', + coordinates: [ + [-77, 38], + [-76, 39] + ] + }, + distance: 10000, + duration: 600 + } + ] + }) + })); + setFetchImpl(fetchSpy); + + sendToolResult({ + structuredContent: { + mapboxRender: { + ref: 'mapbox://selffetch/directions?data=abc', + layers: [], + selfFetch: [ + { + tool: 'directions', + params: { + coordinates: [ + { longitude: -77, latitude: 38 }, + { longitude: -76, latitude: 39 } + ], + // The fresh re-fetch only returned 1 route this time. + selectedRouteIndex: 3 + } + } + ] + } + } + }); + for (let i = 0; i < 6; i++) await Promise.resolve(); + + expect(summaryEl?.textContent).toContain('6.2 mi'); + }); + + it('never fetches when selectedRouteIndex is not a valid non-negative integer', async () => { + const { sendToolResult, setFetchImpl, errorEl } = loadScriptSandbox(); + const fetchSpy = vi.fn(async () => ({ + ok: false, + status: 599, + json: async () => ({}) + })); + setFetchImpl(fetchSpy); + + sendToolResult({ + structuredContent: { + mapboxRender: { + ref: 'mapbox://selffetch/directions?data=abc', + layers: [], + selfFetch: [ + { + tool: 'directions', + params: { + coordinates: [ + { longitude: -77, latitude: 38 }, + { longitude: -76, latitude: 39 } + ], + selectedRouteIndex: -1 + } + } + ] + } + } + }); + await Promise.resolve(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(errorEl?.textContent).toContain('Could not fetch route'); + }); }); describe('mapAppHtml isochrone self-fetch', () => { diff --git a/test/tools/directions-tool/DirectionsTool.test.ts b/test/tools/directions-tool/DirectionsTool.test.ts index 0bf1a005..58529e02 100644 --- a/test/tools/directions-tool/DirectionsTool.test.ts +++ b/test/tools/directions-tool/DirectionsTool.test.ts @@ -1310,6 +1310,174 @@ describe('DirectionsTool', () => { ); }); }); + + describe('Route selection elicitation', () => { + const twoCoords = [ + { longitude: -74.006, latitude: 40.7128 }, + { longitude: -71.0589, latitude: 42.3601 } + ]; + + function multiRouteResponse(count: number) { + return { + code: 'Ok', + routes: Array.from({ length: count }, (_, i) => ({ + distance: 100000 + i * 1000, + duration: 3600 + i * 60, + legs: [] + })), + waypoints: [ + { location: [-74.006, 40.7128], name: '' }, + { location: [-71.0589, 42.3601], name: '' } + ] + }; + } + + function createMockServer(elicitResponse?: { + action: 'accept' | 'decline'; + content?: Record; + }) { + return { + server: { + elicitInput: vi.fn().mockResolvedValue( + elicitResponse ?? { + action: 'accept', + content: { selectedIndex: '0' } + } + ), + sendLoggingMessage: vi.fn() + }, + registerTool: vi.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + + it('triggers elicitation when 2+ routes are returned', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => multiRouteResponse(3) + }); + const tool = new DirectionsTool({ httpRequest }); + const mockServer = createMockServer(); + tool.installTo(mockServer); + + await tool.run({ coordinates: twoCoords }); + + expect(mockServer.server.elicitInput).toHaveBeenCalledOnce(); + const call = mockServer.server.elicitInput.mock.calls[0][0]; + expect(call.mode).toBe('form'); + expect(call.requestedSchema.properties.selectedIndex.enum).toEqual([ + '0', + '1', + '2' + ]); + }); + + it('does not trigger elicitation when only one route is returned', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => multiRouteResponse(1) + }); + const tool = new DirectionsTool({ httpRequest }); + const mockServer = createMockServer(); + tool.installTo(mockServer); + + const result = await tool.run({ coordinates: twoCoords }); + + expect(mockServer.server.elicitInput).not.toHaveBeenCalled(); + const sc = result.structuredContent as { routes?: unknown[] }; + expect(sc.routes).toHaveLength(1); + }); + + it('filters to the selected route and threads selectedRouteIndex into the self-fetch ref', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => multiRouteResponse(3) + }); + const tool = new DirectionsTool({ httpRequest }); + const mockServer = createMockServer({ + action: 'accept', + content: { selectedIndex: '2' } + }); + tool.installTo(mockServer); + + const result = await tool.run({ coordinates: twoCoords }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { + routes?: Array<{ distance: number }>; + mapboxRender?: { ref?: string }; + }; + expect(sc.routes).toHaveLength(1); + expect(sc.routes?.[0].distance).toBe(102000); // routes[2] + + const { resolveSelfFetchRef } = + await import('../../../src/utils/selfFetchRef.js'); + const payload = resolveSelfFetchRef(sc.mapboxRender!.ref!); + expect(payload?.selfFetch?.[0]).toMatchObject({ + tool: 'directions', + params: expect.objectContaining({ selectedRouteIndex: 2 }) + }); + }); + + it('returns all routes and no selectedRouteIndex when the user declines', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => multiRouteResponse(3) + }); + const tool = new DirectionsTool({ httpRequest }); + const mockServer = createMockServer({ action: 'decline' }); + tool.installTo(mockServer); + + const result = await tool.run({ coordinates: twoCoords }); + + const sc = result.structuredContent as { + routes?: unknown[]; + mapboxRender?: { ref?: string }; + }; + expect(sc.routes).toHaveLength(3); + + const { resolveSelfFetchRef } = + await import('../../../src/utils/selfFetchRef.js'); + const payload = resolveSelfFetchRef(sc.mapboxRender!.ref!); + expect(payload?.selfFetch?.[0]?.params).not.toHaveProperty( + 'selectedRouteIndex' + ); + }); + + it('falls back to all routes when elicitation is unsupported or errors', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => multiRouteResponse(3) + }); + const tool = new DirectionsTool({ httpRequest }); + const mockServer = { + server: { + elicitInput: vi + .fn() + .mockRejectedValue(new Error('Elicitation not supported')), + sendLoggingMessage: vi.fn() + }, + registerTool: vi.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + tool.installTo(mockServer); + + const result = await tool.run({ coordinates: twoCoords }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { routes?: unknown[] }; + expect(sc.routes).toHaveLength(3); + }); + + it('skips elicitation entirely when no server is installed', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => multiRouteResponse(3) + }); + const tool = new DirectionsTool({ httpRequest }); + // Not installed to any server — tool.server is undefined. + + const result = await tool.run({ coordinates: twoCoords }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { routes?: unknown[] }; + expect(sc.routes).toHaveLength(3); + }); + }); }); describe('DirectionsTool — temporary resource ownership', () => {