From c21386ff0ca9afb1958bbf22ac891424abb0044b Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 23 Feb 2026 14:27:22 -0500 Subject: [PATCH] Restore base64 image output in StaticMapImageTool - Restores fetching and base64-encoding the image so non-MCP-Apps clients (Goose, plain Claude Desktop, OpenAI, etc.) receive an actual image content block rather than just a URL - Keeps the URL as the first text content item so the StaticMapUIResource HTML can still find it via content.find(c => c.type === 'text') - MCP Apps UIResource remains in content[2] when MCP-UI is enabled - Removes the Breaking Changes section from CHANGELOG since the original base64 behaviour is preserved - Updates all affected tests (25 passing) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 8 +---- .../StaticMapImageTool.ts | 17 +++++++++-- .../StaticMapImageTool.test.ts | 29 ++++++++++--------- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f1ae54..599ba2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,13 +57,6 @@ ## 0.8.3 -### Breaking Changes - -- **`static_map_image_tool`**: Tool now returns the map URL as `text` content instead of a base64-encoded image - - Before: `content: [{ type: "image", data: "...base64...", mimeType: "image/png" }]` - - After: `content: [{ type: "text", text: "https://api.mapbox.com/styles/v1/..." }]` - - In MCP Apps-capable hosts the image is rendered interactively via `StaticMapUIResource`; in other hosts the model receives the URL and can present or use it directly - ### Features Added - **MCP Apps Support for StaticMapImageTool** (#109) @@ -75,6 +68,7 @@ - Sends `ui/notifications/size-changed` to fit panel to rendered image height - Fullscreen toggle using `ui/request-display-mode` - Uses proper `RESOURCE_MIME_TYPE` ("text/html;profile=mcp-app") per MCP Apps specification + - Tool response now includes: URL text (first, for MCP Apps), base64 image (for non-MCP-Apps clients), and optional UIResource (when MCP-UI enabled) ### Security diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.ts b/src/tools/static-map-image-tool/StaticMapImageTool.ts index a39eab8..f3cb70b 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.ts @@ -16,7 +16,7 @@ export class StaticMapImageTool extends MapboxApiBasedTool< > { name = 'static_map_image_tool'; description = - 'Generates a static map image from Mapbox Static Images API. Supports center coordinates, zoom level (0-22), image size (up to 1280x1280), various Mapbox styles, and overlays (markers, paths, GeoJSON). Returns the Static Maps API URL for visualization.'; + 'Generates a static map image from Mapbox Static Images API. Supports center coordinates, zoom level (0-22), image size (up to 1280x1280), various Mapbox styles, and overlays (markers, paths, GeoJSON). Returns PNG for vector styles, JPEG for raster-only styles.'; annotations = { title: 'Static Map Image Tool', readOnlyHint: true, @@ -110,12 +110,23 @@ export class StaticMapImageTool extends MapboxApiBasedTool< const density = input.highDensity ? '@2x' : ''; const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${input.style}/static/${overlayString}${lng},${lat},${input.zoom}/${width}x${height}${density}?access_token=${accessToken}`; - // Return the URL directly instead of fetching and encoding - // This enables MCP Apps to display the image with proper CSP + // Fetch and encode image as base64 for clients without MCP Apps support + const response = await this.httpRequest(url); + const buffer = await response.arrayBuffer(); + const base64Data = Buffer.from(buffer).toString('base64'); + const isRasterStyle = input.style.includes('satellite'); + const mimeType = isRasterStyle ? 'image/jpeg' : 'image/png'; + + // content[0] MUST be the URL text — MCP Apps UI finds it via content.find(c => c.type === 'text') const content: CallToolResult['content'] = [ { type: 'text', text: url + }, + { + type: 'image', + data: base64Data, + mimeType } ]; diff --git a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts index ea7531e..6ea3802 100644 --- a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts +++ b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts @@ -13,7 +13,7 @@ describe('StaticMapImageTool', () => { vi.restoreAllMocks(); }); - it('generates valid URL without fetching', async () => { + it('generates valid URL and fetches image', async () => { const { httpRequest, mockHttpRequest } = setupHttpRequest(); const result = await new StaticMapImageTool({ httpRequest }).run({ @@ -23,8 +23,8 @@ describe('StaticMapImageTool', () => { style: 'mapbox/streets-v12' }); - // Should not call httpRequest since we're just generating URL - expect(mockHttpRequest).toHaveBeenCalledTimes(0); + // Should call httpRequest once to fetch the image + expect(mockHttpRequest).toHaveBeenCalledTimes(1); expect(result.isError).toBe(false); expect(result.content[0].type).toBe('text'); }); @@ -45,7 +45,7 @@ describe('StaticMapImageTool', () => { }); expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); // URL only + expect(result.content).toHaveLength(2); // URL text + image expect(result.content[0].type).toBe('text'); const textContent = result.content[0] as { type: 'text'; text: string }; expect(textContent.text).toContain( @@ -618,11 +618,12 @@ describe('StaticMapImageTool', () => { }); expect(result.isError).toBe(false); - expect(result.content).toHaveLength(2); // URL + UIResource + expect(result.content).toHaveLength(3); // URL + image + UIResource expect(result.content[0].type).toBe('text'); - expect(result.content[1].type).toBe('resource'); - if (result.content[1].type === 'resource') { - expect(result.content[1].resource.uri).toMatch( + expect(result.content[1].type).toBe('image'); + expect(result.content[2].type).toBe('resource'); + if (result.content[2].type === 'resource') { + expect(result.content[2].resource.uri).toMatch( /^ui:\/\/mapbox\/static-map\// ); } @@ -644,7 +645,7 @@ describe('StaticMapImageTool', () => { }); expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); // URL only, no UIResource + expect(result.content).toHaveLength(2); // URL + image, no UIResource expect(result.content[0].type).toBe('text'); } finally { // Restore environment variable @@ -667,14 +668,14 @@ describe('StaticMapImageTool', () => { }); expect(result.isError).toBe(false); - // UIResource is now at index 1 (after URL text) - if (result.content[1]?.type === 'resource') { - expect(result.content[1].resource.uri).toContain( + // UIResource is at index 2 (after URL text and base64 image) + if (result.content[2]?.type === 'resource') { + expect(result.content[2].resource.uri).toContain( '-122.4194,37.7749,13' ); // Check that UIMetadata has preferred dimensions - if ('uiMetadata' in result.content[1].resource) { - const metadata = result.content[1].resource.uiMetadata as Record< + if ('uiMetadata' in result.content[2].resource) { + const metadata = result.content[2].resource.uiMetadata as Record< string, unknown >;