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
8 changes: 1 addition & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down
17 changes: 14 additions & 3 deletions src/tools/static-map-image-tool/StaticMapImageTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
];

Expand Down
29 changes: 15 additions & 14 deletions test/tools/static-map-image-tool/StaticMapImageTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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');
});
Expand All @@ -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(
Expand Down Expand Up @@ -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\//
);
}
Expand All @@ -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
Expand All @@ -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
>;
Expand Down