Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

### Bug Fixes

- **`static_map_image_tool`: large images work on the hosted server (#258).** Images over the 700 KB inline limit were stored in one server process's memory behind a `mapbox://temp/static-map-...` ref, so on the hosted deployment a follow-up read that landed on another task found nothing. They now get a `mapbox://response/static-map?data=...` ref like the large `directions_tool` and `isochrone_tool` responses from #257: the ref carries the request's inputs, and reading it renders the same image again with the reader's token, so any process can answer it. The ref stays a few hundred bytes however big the image is. The Static Images URL building moved to `buildStaticMapRequestUrl.ts`, shared by the tool and `ToolResponseResource`.
- **`directions_tool`: `geometries="geojson"` now always returns coordinates directly, regardless of trip length (#255).** Previously, every geojson request used `overview=full` (maximum-precision geometry) — fine for a short trip, but for a long one (reported: London to a point in Scotland) the response exceeded the 50KB context limit and the tool silently swapped to a completely different contract: geometry and legs stripped out, replaced by a `mapbox://temp/...` resource URI (a `mapbox://response/...` ref since #257) that has to be read through the MCP resources API rather than being usable directly — not even a fetchable HTTP URL. An agent that asked for coordinates because it needed to draw the route itself (e.g. in a generated app) had no way to know in advance which contract it would get, since the split was driven by trip length, not by anything the caller controlled.
- New optional `overview` input (`"full" | "simplified"`) now defaults to `"simplified"` whenever `geometries="geojson"` (and to `"full"`, unchanged, whenever `geometries="none"`, since no geometry is returned there either way). A simplified route is still a fully accurate, drawable line — confirmed live against the real Directions API, a real London→Edinburgh route (~420 miles) drops from 10,057 coordinate pairs / ~700KB down to 46 coordinate pairs / ~4.7KB, comfortably inside the response limit. `overview="full"` is still available for callers who explicitly need maximum-precision geometry, at the cost of the same large-response behavior as before (now a deliberate, documented opt-in rather than a surprise past a size cliff).
- Tradeoff: the Mapbox Directions API rejects the `congestion` annotation unless `overview=full` (`422: Overview option must be full for congestion`, confirmed live) — `distance`/`speed` annotations have no such restriction and stay accurate at any overview level. So `congestion_information` (the per-segment traffic breakdown) is only present when the effective `overview` is `"full"`; it's correctly **omitted** (not reported as a misleading all-zero breakdown, which is what the code did previously whenever congestion happened to be unavailable) when it wasn't requested. `average_speed_kph`, `distance`, `duration`, and turn-by-turn summaries are unaffected at any overview level.
Expand Down
41 changes: 36 additions & 5 deletions src/resources/tool-response/ToolResponseResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,24 @@ import { cleanResponseData } from '../../tools/directions-tool/cleanResponseData
import type { DirectionsResponse } from '../../tools/directions-tool/DirectionsTool.output.schema.js';
import { IsochroneInputSchema } from '../../tools/isochrone-tool/IsochroneTool.input.schema.js';
import { buildIsochroneRequestUrl } from '../../tools/isochrone-tool/buildIsochroneRequestUrl.js';
import { StaticMapImageInputSchema } from '../../tools/static-map-image-tool/StaticMapImageTool.input.schema.js';
import {
buildStaticMapRequestUrl,
getStaticMapMimeType
} from '../../tools/static-map-image-tool/buildStaticMapRequestUrl.js';

/**
* Full `directions_tool` / `isochrone_tool` responses that were too large
* to return inline. The `mapbox://response/{tool}?data=...` ref carries the
* call's inputs (see responseRef.ts), and reading it runs the same request
* again with the reader's token, so any server process can answer it.
* Full `directions_tool` / `isochrone_tool` responses and
* `static_map_image_tool` images that were too large to return inline. The
* `mapbox://response/{tool}?data=...` ref carries the call's inputs (see
* responseRef.ts), and reading it runs the same request again with the
* reader's token, so any server process can answer it.
*/
export class ToolResponseResource extends MapboxApiBasedResource {
readonly name = 'Full Tool Response';
readonly uri = 'mapbox://response/{spec}';
readonly description =
'The full response of a directions_tool or isochrone_tool call that was too large to return inline, fetched again from its ref.';
'The full response of a directions_tool or isochrone_tool call, or a static_map_image_tool image, that was too large to return inline, fetched again from its ref.';
readonly mimeType = 'application/json';

constructor(params: { httpRequest: HttpRequest }) {
Expand All @@ -35,6 +41,31 @@ export class ToolResponseResource extends MapboxApiBasedResource {
): Promise<ReadResourceResult> {
const spec = resolveResponseRef(uri);
const apiEndpoint = MapboxApiBasedResource.mapboxApiEndpoint;

if (spec?.tool === 'static-map') {
const parsed = StaticMapImageInputSchema.safeParse(spec.input);
if (!parsed.success) {
return text(uri, 'Response ref was malformed or unsupported.');
}
const { url } = buildStaticMapRequestUrl({
input: parsed.data,
accessToken,
apiEndpoint
});
const response = await this.httpRequest(url);
if (!response.ok) {
return text(
uri,
`Fetching the image failed: ${response.status} ${response.statusText}`
);
}
const blob = Buffer.from(await response.arrayBuffer()).toString('base64');
return {
contents: [
{ uri, mimeType: getStaticMapMimeType(parsed.data.style), blob }
]
};
}
let url: string | null = null;
let directionsInput: ReturnType<typeof DirectionsInputSchema.parse> | null =
null;
Expand Down
123 changes: 18 additions & 105 deletions src/tools/static-map-image-tool/StaticMapImageTool.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,23 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { randomUUID, randomBytes } from 'node:crypto';
import { randomUUID } from 'node:crypto';
import type { z } from 'zod';
import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js';
import type { HttpRequest } from '../../utils/types.js';
import { StaticMapImageInputSchema } from './StaticMapImageTool.input.schema.js';
import type { OverlaySchema } from './StaticMapImageTool.input.schema.js';
import type { CallToolResult } from '@modelcontextprotocol/server';
import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js';
import { getOwnerKeyFromToken } from '../../utils/jwtUtils.js';
import { buildResponseRef } from '../../utils/responseRef.js';
import {
buildStaticMapRequestUrl,
getStaticMapMimeType
} from './buildStaticMapRequestUrl.js';

// Images larger than this threshold are stored as temporary resources instead
// Images larger than this threshold get a `mapbox://response/` ref instead
// of being inlined as base64, to avoid exceeding Claude Desktop's 1MB tool
// result limit. base64 adds ~33% overhead, so 700KB raw ≈ 933KB encoded.
const IMAGE_INLINE_THRESHOLD = 700 * 1024; // 700KB

// encodeURIComponent leaves (, ), !, ', and * unescaped (they're valid in a
// URI component per RFC 3986's "unreserved" carve-out from the older
// escape() behaviour). Every overlay value below is embedded inside a
// path segment delimited by literal parentheses (e.g. `url-<value>(lon,lat)`,
// `geojson(<value>)`), so a raw `)` in the value can terminate that segment
// early from the Static Images API's own overlay-syntax parser's point of
// view, even though this value already passed URL/JSON validation on our
// side. Escape those characters explicitly so both parsers agree on where
// the value actually ends.
function encodeOverlayComponent(value: string): string {
return encodeURIComponent(value).replace(
/[()!'*]/g,
(c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()
);
}

export class StaticMapImageTool extends MapboxApiBasedTool<
typeof StaticMapImageInputSchema
> {
Expand All @@ -53,79 +39,15 @@ export class StaticMapImageTool extends MapboxApiBasedTool<
});
}

private encodeOverlay(overlay: z.infer<typeof OverlaySchema>): string {
switch (overlay.type) {
case 'marker': {
const size = overlay.size === 'large' ? 'pin-l' : 'pin-s';
let marker = size;

if (overlay.label) {
marker += `-${overlay.label}`;
}

if (overlay.color) {
marker += `+${overlay.color}`;
}

return `${marker}(${overlay.longitude},${overlay.latitude})`;
}

case 'custom-marker': {
const encodedUrl = encodeOverlayComponent(overlay.url);
return `url-${encodedUrl}(${overlay.longitude},${overlay.latitude})`;
}

case 'path': {
let path = `path-${overlay.strokeWidth}`;

if (overlay.strokeColor) {
path += `+${overlay.strokeColor}`;
if (overlay.strokeOpacity !== undefined) {
path += `-${overlay.strokeOpacity}`;
}
}

if (overlay.fillColor) {
path += `+${overlay.fillColor}`;
if (overlay.fillOpacity !== undefined) {
path += `-${overlay.fillOpacity}`;
}
}

// URL encode the polyline to handle special characters
return `${path}(${encodeOverlayComponent(overlay.encodedPolyline)})`;
}

case 'geojson': {
const geojsonString = JSON.stringify(overlay.data);
return `geojson(${encodeOverlayComponent(geojsonString)})`;
}
}
}

protected async execute(
input: z.infer<typeof StaticMapImageInputSchema>,
accessToken: string
): Promise<CallToolResult> {
const { longitude: lng, latitude: lat } = input.center;
const { width, height } = input.size;

// Build overlay string
let overlayString = '';
if (input.overlays && input.overlays.length > 0) {
const encodedOverlays = input.overlays.map((overlay) => {
return this.encodeOverlay(overlay);
});
overlayString = encodedOverlays.join(',') + '/';
}

const density = input.highDensity ? '@2x' : '';
const encodedStyle = input.style
.split('/')
.map(encodeURIComponent)
.join('/');
const publicUrl = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodedStyle}/static/${overlayString}${lng},${lat},${input.zoom}/${width}x${height}${density}`;
const url = `${publicUrl}?access_token=${accessToken}`;
const { publicUrl, url } = buildStaticMapRequestUrl({
input,
accessToken,
apiEndpoint: MapboxApiBasedTool.mapboxApiEndpoint
});

// Fetch image
const response = await this.httpRequest(url);
Expand All @@ -137,30 +59,21 @@ export class StaticMapImageTool extends MapboxApiBasedTool<
};
}
const buffer = await response.arrayBuffer();
const isRasterStyle = input.style.includes('satellite');
const mimeType = isRasterStyle ? 'image/jpeg' : 'image/png';
const mimeType = getStaticMapMimeType(input.style);

// Use public URL (without credentials) to avoid leaking the access token
const content: CallToolResult['content'] = [
{ type: 'text', text: publicUrl }
];

if (buffer.byteLength > IMAGE_INLINE_THRESHOLD) {
// Image is too large to inline safely — store as temporary resource
const resourceId = randomBytes(16).toString('hex');
const resourceUri = `mapbox://temp/static-map-${resourceId}`;
const base64Data = Buffer.from(buffer).toString('base64');
temporaryResourceManager.create({
id: resourceId,
uri: resourceUri,
data: base64Data,
metadata: { toolName: this.name, size: buffer.byteLength },
mimeType,
owner: getOwnerKeyFromToken(accessToken)
});
// Image is too large to inline safely. The ref carries the request's
// inputs, not the image, so any server process can answer a later
// read by rendering it again (see responseRef.ts).
const resourceUri = buildResponseRef({ tool: 'static-map', input });
content.push({
type: 'text',
text: `⚠️ Image (${Math.round(buffer.byteLength / 1024)}KB) stored as temporary resource.\nResource URI: ${resourceUri}\nTTL: 30 minutes`
text: `⚠️ Image (${Math.round(buffer.byteLength / 1024)}KB) is too large to return inline.\nRead ${resourceUri} with the MCP resource API to get it. Reading it renders the same image again.`
});
} else {
// Image is small enough to inline as base64
Expand Down
115 changes: 115 additions & 0 deletions src/tools/static-map-image-tool/buildStaticMapRequestUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import type { z } from 'zod';
import type {
StaticMapImageInputSchema,
OverlaySchema
} from './StaticMapImageTool.input.schema.js';

// encodeURIComponent leaves (, ), !, ', and * unescaped (they're valid in a
// URI component per RFC 3986's "unreserved" carve-out from the older
// escape() behaviour). Every overlay value below is embedded inside a
// path segment delimited by literal parentheses (e.g. `url-<value>(lon,lat)`,
// `geojson(<value>)`), so a raw `)` in the value can terminate that segment
// early from the Static Images API's own overlay-syntax parser's point of
// view, even though this value already passed URL/JSON validation on our
// side. Escape those characters explicitly so both parsers agree on where
// the value actually ends.
export function encodeOverlayComponent(value: string): string {
return encodeURIComponent(value).replace(
/[()!'*]/g,
(c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()
);
}

export function encodeOverlay(overlay: z.infer<typeof OverlaySchema>): string {
switch (overlay.type) {
case 'marker': {
const size = overlay.size === 'large' ? 'pin-l' : 'pin-s';
let marker = size;

if (overlay.label) {
marker += `-${overlay.label}`;
}

if (overlay.color) {
marker += `+${overlay.color}`;
}

return `${marker}(${overlay.longitude},${overlay.latitude})`;
}

case 'custom-marker': {
const encodedUrl = encodeOverlayComponent(overlay.url);
return `url-${encodedUrl}(${overlay.longitude},${overlay.latitude})`;
}

case 'path': {
let path = `path-${overlay.strokeWidth}`;

if (overlay.strokeColor) {
path += `+${overlay.strokeColor}`;
if (overlay.strokeOpacity !== undefined) {
path += `-${overlay.strokeOpacity}`;
}
}

if (overlay.fillColor) {
path += `+${overlay.fillColor}`;
if (overlay.fillOpacity !== undefined) {
path += `-${overlay.fillOpacity}`;
}
}

// URL encode the polyline to handle special characters
return `${path}(${encodeOverlayComponent(overlay.encodedPolyline)})`;
}

case 'geojson': {
const geojsonString = JSON.stringify(overlay.data);
return `geojson(${encodeOverlayComponent(geojsonString)})`;
}
}
}

export function getStaticMapMimeType(
style: string
): 'image/jpeg' | 'image/png' {
return style.includes('satellite') ? 'image/jpeg' : 'image/png';
}

export interface StaticMapRequestUrls {
/** The request URL without the access token, safe to show the model. */
publicUrl: string;
/** The same URL with the access token, for the request itself. */
url: string;
}

/**
* Builds the Static Images API request for a given input. Shared by
* StaticMapImageTool and ToolResponseResource, which fetches a large image
* again from its `mapbox://response/` ref. The API renders the same image
* for the same parameters, so the two requests return the same bytes.
*/
export function buildStaticMapRequestUrl(params: {
input: z.infer<typeof StaticMapImageInputSchema>;
accessToken: string;
apiEndpoint: string;
}): StaticMapRequestUrls {
const { input, accessToken, apiEndpoint } = params;
const { longitude: lng, latitude: lat } = input.center;
const { width, height } = input.size;

let overlayString = '';
if (input.overlays && input.overlays.length > 0) {
overlayString = input.overlays.map(encodeOverlay).join(',') + '/';
}

const density = input.highDensity ? '@2x' : '';
const encodedStyle = input.style.split('/').map(encodeURIComponent).join('/');
const publicUrl = `${apiEndpoint}styles/v1/${encodedStyle}/static/${overlayString}${lng},${lat},${input.zoom}/${width}x${height}${density}`;
const url = `${publicUrl}?access_token=${accessToken}`;

return { publicUrl, url };
}
2 changes: 1 addition & 1 deletion src/utils/responseRef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
const RESPONSE_URI_PREFIX = 'mapbox://response/';

/** Tools whose full response can be read back through a response ref. */
const RESPONSE_TOOLS = ['directions', 'isochrone'] as const;
const RESPONSE_TOOLS = ['directions', 'isochrone', 'static-map'] as const;
export type ResponseTool = (typeof RESPONSE_TOOLS)[number];

export interface ResponseRefSpec {
Expand Down
Loading
Loading