Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).

### Changed

- **Removed dead MCP-UI code.** `--disable-mcp-ui` no longer appears in `--help` (it stopped doing anything once MCP-UI support was removed) but is still silently accepted so an existing launch config that passes it doesn't hard-fail on "Unknown option". Deleted the orphaned `StaticMapUIResource` (`ui://mapbox/static-map/index.html`) — it was registered but no tool had referenced it since `static_map_image_tool` stopped declaring an MCP Apps UI resource. No behavior change: MCP-UI support was already fully gone from the codebase; this just removes the code that referenced it.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ For detailed setup instructions for different integrations, refer to the followi
- [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
- **[`render_map_tool` Guide](./docs/render-map-tool.md)** - The map visualization primitive: full payload schema and how to render your own data standalone, without any other Mapbox tool
- **[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

Expand Down
61 changes: 19 additions & 42 deletions docs/elicitations.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 15 additions & 1 deletion src/resources/ui-apps/mapAppHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.');
Expand Down
126 changes: 124 additions & 2 deletions src/tools/directions-tool/DirectionsTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<number | undefined> {
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<typeof DirectionsInputSchema>,
accessToken: string
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down
Loading
Loading