diff --git a/CHANGELOG.md b/CHANGELOG.md index a5de29c5..d44e3459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/resources/tool-response/ToolResponseResource.ts b/src/resources/tool-response/ToolResponseResource.ts index d95fd043..bd874bf7 100644 --- a/src/resources/tool-response/ToolResponseResource.ts +++ b/src/resources/tool-response/ToolResponseResource.ts @@ -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 }) { @@ -35,6 +41,31 @@ export class ToolResponseResource extends MapboxApiBasedResource { ): Promise { 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 | null = null; diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.ts b/src/tools/static-map-image-tool/StaticMapImageTool.ts index 37729d21..6f42c90e 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.ts @@ -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-(lon,lat)`, -// `geojson()`), 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 > { @@ -53,79 +39,15 @@ export class StaticMapImageTool extends MapboxApiBasedTool< }); } - private encodeOverlay(overlay: z.infer): 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, accessToken: string ): Promise { - 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); @@ -137,8 +59,7 @@ 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'] = [ @@ -146,21 +67,13 @@ export class StaticMapImageTool extends MapboxApiBasedTool< ]; 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 diff --git a/src/tools/static-map-image-tool/buildStaticMapRequestUrl.ts b/src/tools/static-map-image-tool/buildStaticMapRequestUrl.ts new file mode 100644 index 00000000..3071665e --- /dev/null +++ b/src/tools/static-map-image-tool/buildStaticMapRequestUrl.ts @@ -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-(lon,lat)`, +// `geojson()`), 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): 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; + 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 }; +} diff --git a/src/utils/responseRef.ts b/src/utils/responseRef.ts index 79c50f2d..d1927f10 100644 --- a/src/utils/responseRef.ts +++ b/src/utils/responseRef.ts @@ -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 { diff --git a/test/integration/hostedMultiTaskFollowupStaticMap.test.ts b/test/integration/hostedMultiTaskFollowupStaticMap.test.ts new file mode 100644 index 00000000..8c0640e9 --- /dev/null +++ b/test/integration/hostedMultiTaskFollowupStaticMap.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer, type Server } from 'node:http'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { Client } from '@modelcontextprotocol/client'; + +/** + * Same failure mode as hostedMultiTaskFollowup.test.ts, for + * `static_map_image_tool`'s large-image fallback: a response over the 700KB + * inline threshold used to be stored via `temporaryResourceManager` (an + * in-process `Map`) and returned as a `mapbox://temp/static-map-{id}` ref. + * On the hosted deployment's stateless, multi-ECS-task setup, a follow-up + * `resources/read` landing on a different task than the one that fetched + * the image found nothing. + * + * This proves the fix (a `mapbox://response/` ref carrying the request's + * inputs, which any process renders again on read — see responseRef.ts) + * survives exactly that: the ref is produced by one real, + * spawned server process, that process is killed outright, and a second, + * completely independent process re-fetches the image from the ref's own + * request params via a real `resources/read` call — no bytes, store, or + * memory of any kind carried over from the original process. + */ + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SERVER_ENTRY = join(__dirname, '..', '..', 'dist', 'esm', 'index.js'); + +const DUMMY_TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + +// Larger than the tool's 700KB inline threshold, and filled with a +// non-trivial byte pattern so a truncation or off-by-one bug in the +// encode/decode path would be caught by an exact equality check. +const BIG_IMAGE = Buffer.alloc(750 * 1024); +for (let i = 0; i < BIG_IMAGE.length; i++) { + BIG_IMAGE[i] = i % 256; +} + +let mockStaticApi: Server; +let mockApiEndpoint: string; + +beforeAll(async () => { + mockStaticApi = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'image/png' }); + res.end(BIG_IMAGE); + }); + await new Promise((resolve) => { + mockStaticApi.listen(0, '127.0.0.1', resolve); + }); + const address = mockStaticApi.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to bind mock Static Images API server'); + } + mockApiEndpoint = `http://127.0.0.1:${address.port}/`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + mockStaticApi.close((err) => (err ? reject(err) : resolve())); + }); +}); + +async function spawnFreshServerClient(): Promise { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [SERVER_ENTRY], + env: { + MAPBOX_ACCESS_TOKEN: DUMMY_TOKEN, + MAPBOX_API_ENDPOINT: mockApiEndpoint, + PATH: process.env.PATH ?? '' + } + }); + const client = new Client({ + name: 'hosted-multi-task-followup-static-map-test', + version: '1.0.0' + }); + await client.connect(transport); + return client; +} + +async function closeClient(client: Client): Promise { + await client.close().catch(() => { + // Already closed or the process exited on its own. + }); +} + +describe.skipIf(!existsSync(SERVER_ENTRY))( + 'hosted stateless follow-up for static_map_image_tool (real server process, real MCP protocol, simulated two-task deployment)', + () => { + it('reads a large static_map_image_tool response ref from a completely independent process, with zero shared state', async () => { + const taskA = await spawnFreshServerClient(); + let resourceUri: string | undefined; + + try { + const result = await taskA.callTool({ + name: 'static_map_image_tool', + arguments: { + center: { longitude: -74.006, latitude: 40.7128 }, + zoom: 12, + size: { width: 1280, height: 900 }, + style: 'mapbox/streets-v12' + } + }); + expect(result.isError).toBeFalsy(); + + const textBlock = ( + result.content as Array<{ type: string; text?: string }> + ).find( + (c) => c.type === 'text' && c.text?.includes('mapbox://response/') + ); + expect(textBlock?.text).toContain('too large to return inline'); + + resourceUri = textBlock!.text!.match(/mapbox:\/\/response\/\S+/)?.[0]; + expect(resourceUri).toBeTruthy(); + } finally { + // Kill task A entirely — no shared memory, no shared temp-file + // store, nothing carried over except the ref string itself. + await closeClient(taskA); + } + + // Process B: a completely independent process/task that never saw + // the original call, standing in for the ALB routing the follow-up + // resources/read to a different ECS task. + const taskB = await spawnFreshServerClient(); + try { + const readResult = await taskB.readResource({ uri: resourceUri! }); + const contentItem = ( + readResult.contents as Array<{ mimeType?: string; blob?: string }> + )[0]; + expect(contentItem?.mimeType).toBe('image/png'); + expect(Buffer.from(contentItem!.blob as string, 'base64')).toEqual( + BIG_IMAGE + ); + } finally { + await closeClient(taskB); + } + }); + } +); diff --git a/test/resources/tool-response/ToolResponseResource.test.ts b/test/resources/tool-response/ToolResponseResource.test.ts index 0d32c145..29ab208a 100644 --- a/test/resources/tool-response/ToolResponseResource.test.ts +++ b/test/resources/tool-response/ToolResponseResource.test.ts @@ -126,6 +126,10 @@ describe('ToolResponseResource', () => { input: { profile: 'mapbox/walking', coordinates: coordinates[0] } }) ], + [ + 'a static map with no center', + buildResponseRef({ tool: 'static-map', input: { zoom: 12 } }) + ], ['a malformed ref', 'mapbox://response/directions?data=%%%'] ])('never calls the API for %s', async (_label, uri) => { const { httpRequest, resource } = setup(); @@ -144,4 +148,43 @@ describe('ToolResponseResource', () => { expect(result.contents[0]).toMatchObject({ mimeType: 'text/plain' }); expect((result.contents[0] as { text: string }).text).toContain('422'); }); + + describe('static-map', () => { + const input = { + center: { longitude: -74.006, latitude: 40.7128 }, + zoom: 12, + size: { width: 1280, height: 900 }, + style: 'mapbox/satellite-v9' + }; + + it('renders the image again and returns its exact bytes', async () => { + const bytes = Buffer.from( + Array.from({ length: 2048 }, (_, i) => i % 256) + ); + const { httpRequest, resource } = setup( + new Response(bytes, { headers: { 'content-type': 'image/jpeg' } }) + ); + + const result = await resource.read( + buildResponseRef({ tool: 'static-map', input }) + ); + + const url = new URL(httpRequest.mock.calls[0][0]); + expect(url.pathname).toBe( + '/styles/v1/mapbox/satellite-v9/static/-74.006,40.7128,12/1280x900' + ); + const content = result.contents[0] as { mimeType: string; blob: string }; + expect(content.mimeType).toBe('image/jpeg'); + expect(Buffer.from(content.blob, 'base64')).toEqual(bytes); + }); + + it('reports an API error as text', async () => { + const { resource } = setup(new Response('nope', { status: 403 })); + const result = await resource.read( + buildResponseRef({ tool: 'static-map', input }) + ); + expect(result.contents[0]).toMatchObject({ mimeType: 'text/plain' }); + expect((result.contents[0] as { text: string }).text).toContain('403'); + }); + }); }); diff --git a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts index 6e9a7e37..5abc4c1c 100644 --- a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts +++ b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts @@ -8,7 +8,8 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; import { CustomMarkerOverlaySchema } from '../../../src/tools/static-map-image-tool/StaticMapImageTool.input.schema.js'; import { StaticMapImageTool } from '../../../src/tools/static-map-image-tool/StaticMapImageTool.js'; -import { temporaryResourceManager } from '../../../src/utils/temporaryResourceManager.js'; +import { textOf } from '../../utils/resultTestUtils.js'; +import { resolveResponseRef } from '../../../src/utils/responseRef.js'; describe('StaticMapImageTool', () => { afterEach(() => { @@ -245,15 +246,13 @@ describe('StaticMapImageTool', () => { }); }); - it('stores large images as temporary resources instead of inlining base64', async () => { + it('returns a mapbox://response/ ref for large images instead of inlining base64', async () => { // Simulate a response larger than the 700KB inline threshold const largeBuffer = new ArrayBuffer(750 * 1024); const { httpRequest } = setupHttpRequest({ arrayBuffer: async () => largeBuffer } as Partial); - temporaryResourceManager.clear(); - const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, @@ -262,19 +261,22 @@ describe('StaticMapImageTool', () => { }); expect(result.isError).toBe(false); - // Should not contain an inline image expect(result.content.some((c) => c.type === 'image')).toBe(false); - // Should contain a text item with the resource URI - const resourceText = result.content.find( - (c) => - c.type === 'text' && - (c as { type: 'text'; text: string }).text.includes( - 'mapbox://temp/static-map-' - ) - ); - expect(resourceText).toBeDefined(); - // Temp resource should be registered - expect(temporaryResourceManager.count()).toBe(1); + + // The ref carries the request's inputs, not the image, so it stays + // small however big the image is. + const uri = textOf(result, 1).match(/mapbox:\/\/response\/\S+/)?.[0]; + expect(uri).toBeTruthy(); + expect(uri!.length).toBeLessThan(1000); + expect(resolveResponseRef(uri!)).toMatchObject({ + tool: 'static-map', + input: { + center: { longitude: -74.006, latitude: 40.7128 }, + zoom: 12, + size: { width: 1280, height: 900 }, + style: 'mapbox/streets-v12' + } + }); }); it('returns error when Mapbox API returns non-2xx response', async () => {