Skip to content

Commit 60be8a0

Browse files
authored
Fix issue 104 - add text content to StaticMapImageTool response for better MCP client compatibility (#104)
* Issue 104 - fix * Update documentation - LibreChat embeds images correctly * create changelog and bump version to 0.8.2
1 parent ed45f5f commit 60be8a0

File tree

8 files changed

+172
-26
lines changed

8 files changed

+172
-26
lines changed

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## 0.8.2
2+
3+
### Bug Fixes
4+
5+
- **StaticMapImageTool**: Added text content to response for better MCP client compatibility (#103)
6+
- Tool now returns structured content array with text description, image, and optional MCP-UI resource
7+
- Text content includes map metadata (center, zoom, size, style, overlay count)
8+
- Follows MCP specification for tool results with multiple content items
9+
110
## 0.8.0
211

312
### Bug Fixes
@@ -9,7 +18,6 @@
918
### Features Added
1019

1120
- **MCP Resources Support**: Added native MCP resource API support
12-
1321
- Introduced `CategoryListResource` exposing category lists as `mapbox://categories` resource
1422
- Supports localized category lists via URI pattern `mapbox://categories/{language}` (e.g., `mapbox://categories/ja` for Japanese)
1523
- Created base resource infrastructure (`BaseResource`, `MapboxApiBasedResource`) for future resource implementations

docs/mcp-ui.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,26 +70,35 @@ When MCP-UI is enabled, this tool returns:
7070
│ │ static_map_image_tool │ │
7171
│ └────────┬───────────────┘ │
7272
│ │ │
73+
│ ├─► Text description│
74+
│ │ (always) │
7375
│ ├─► Base64 image │
74-
│ │
76+
│ │ (always)
7577
│ └─► UIResource │
7678
│ (if enabled) │
7779
└─────────────────────────────┘
7880
```
7981

8082
### Response Format
8183

82-
When MCP-UI is enabled, the `static_map_image_tool` returns a response with multiple content items:
84+
The `static_map_image_tool` returns a response with multiple content items, following the progressive enhancement pattern:
8385

8486
```typescript
8587
{
8688
content: [
8789
{
90+
// Text description with map metadata
91+
type: 'text',
92+
text: 'Static map image generated successfully.\nCenter: 37.7749, -122.4194\nZoom: 13\nSize: 800x600\nStyle: mapbox/streets-v12'
93+
},
94+
{
95+
// Base64-encoded image
8896
type: 'image',
8997
data: '<base64-encoded-image>',
9098
mimeType: 'image/png'
9199
},
92100
{
101+
// MCP-UI resource for interactive iframes (only when enabled)
93102
type: 'resource',
94103
resource: {
95104
uri: 'ui://mapbox/static-map/...',
@@ -104,9 +113,9 @@ When MCP-UI is enabled, the `static_map_image_tool` returns a response with mult
104113
}
105114
```
106115

107-
**Non-MCP-UI clients** simply ignore the second content item and display the base64 image.
116+
**Standard clients** (Claude Desktop, LibreChat, VS Code, Cursor) render the base64 image. The text content provides additional metadata about the generated map.
108117

109-
**MCP-UI clients** can choose to render the iframe resource instead of (or in addition to) the static image.
118+
**MCP-UI clients** (like Goose) can render the interactive iframe resource for the richest experience.
110119

111120
## Configuration
112121

@@ -203,8 +212,21 @@ The Mapbox MCP Server uses the `@mcp-ui/server` package (v5.13.1+) to create UIR
203212
import { createUIResource } from '@mcp-ui/server';
204213
import { isMcpUiEnabled } from '../../config/toolConfig.js';
205214

206-
// Build content array with image data
215+
// Build descriptive text with map metadata
216+
const textDescription = [
217+
'Static map image generated successfully.',
218+
`Center: ${lat}, ${lng}`,
219+
`Zoom: ${input.zoom}`,
220+
`Size: ${width}x${height}`,
221+
`Style: ${input.style}`
222+
].join('\n');
223+
224+
// Build content array with text first, then image
207225
const content: CallToolResult['content'] = [
226+
{
227+
type: 'text',
228+
text: textDescription
229+
},
208230
{
209231
type: 'image',
210232
data: base64Data,

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"dxt_version": "0.1",
33
"display_name": "Mapbox MCP Server",
44
"name": "@mapbox/mcp-server",
5-
"version": "0.8.1",
5+
"version": "0.8.2",
66
"description": "Mapbox MCP server.",
77
"author": {
88
"name": "Mapbox, Inc."

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mapbox/mcp-server",
3-
"version": "0.8.1",
3+
"version": "0.8.2",
44
"description": "Mapbox MCP server.",
55
"mcpName": "io.github.mapbox/mcp-server",
66
"main": "./dist/commonjs/index.js",

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
"url": "https://github.com/mapbox/mcp-server",
77
"source": "github"
88
},
9-
"version": "0.8.1",
9+
"version": "0.8.2",
1010
"packages": [
1111
{
1212
"registryType": "npm",
1313
"registryBaseUrl": "https://registry.npmjs.org",
1414
"runtimeHint": "npx",
15-
"version": "0.8.1",
15+
"version": "0.8.2",
1616
"identifier": "@mapbox/mcp-server",
1717
"transport": {
1818
"type": "stdio"

src/tools/static-map-image-tool/StaticMapImageTool.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,26 @@ export class StaticMapImageTool extends MapboxApiBasedTool<
123123
const isRasterStyle = input.style.includes('satellite');
124124
const mimeType = isRasterStyle ? 'image/jpeg' : 'image/png';
125125

126-
// Build content array with image data
126+
// Build descriptive text with map metadata (Issue #103)
127+
// Text content provides additional context alongside the image
128+
const textDescription = [
129+
'Static map image generated successfully.',
130+
`Center: ${lat}, ${lng}`,
131+
`Zoom: ${input.zoom}`,
132+
`Size: ${width}x${height}${input.highDensity ? ' @2x' : ''}`,
133+
`Style: ${input.style}`,
134+
input.overlays?.length ? `Overlays: ${input.overlays.length}` : null
135+
]
136+
.filter(Boolean)
137+
.join('\n');
138+
139+
// Build content array with text first, then image
140+
// Per MCP spec, content array can have multiple items of different types
127141
const content: CallToolResult['content'] = [
142+
{
143+
type: 'text',
144+
text: textDescription
145+
},
128146
{
129147
type: 'image',
130148
data: base64Data,

test/tools/static-map-image-tool/StaticMapImageTool.test.ts

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,11 @@ describe('StaticMapImageTool', () => {
5454
});
5555

5656
expect(result.isError).toBe(false);
57-
expect(result.content).toHaveLength(1);
58-
expect(result.content[0]).toMatchObject({
57+
expect(result.content).toHaveLength(2); // text + image
58+
// First content should be text description
59+
expect(result.content[0].type).toBe('text');
60+
// Second content should be image
61+
expect(result.content[1]).toMatchObject({
5962
type: 'image',
6063
data: mockImageBuffer.toString('base64'),
6164
mimeType: 'image/jpeg' // satellite is a raster style
@@ -70,6 +73,98 @@ describe('StaticMapImageTool', () => {
7073
}
7174
});
7275

76+
it('returns text content with map details as first item', async () => {
77+
// Disable MCP-UI for this test to focus on text + image
78+
const originalEnv = process.env.ENABLE_MCP_UI;
79+
process.env.ENABLE_MCP_UI = 'false';
80+
81+
try {
82+
const mockImageBuffer = Buffer.from('fake-image-data');
83+
const mockArrayBuffer = mockImageBuffer.buffer.slice(
84+
mockImageBuffer.byteOffset,
85+
mockImageBuffer.byteOffset + mockImageBuffer.byteLength
86+
);
87+
88+
const { httpRequest } = setupHttpRequest({
89+
arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer)
90+
});
91+
92+
const result = await new StaticMapImageTool({ httpRequest }).run({
93+
center: { longitude: -74.006, latitude: 40.7128 },
94+
zoom: 12,
95+
size: { width: 600, height: 400 },
96+
style: 'mapbox/streets-v12'
97+
});
98+
99+
expect(result.isError).toBe(false);
100+
expect(result.content[0].type).toBe('text');
101+
102+
const textContent = result.content[0] as { type: 'text'; text: string };
103+
expect(textContent.text).toContain('Static map image generated');
104+
expect(textContent.text).toContain('40.7128'); // latitude
105+
expect(textContent.text).toContain('-74.006'); // longitude
106+
expect(textContent.text).toContain('12'); // zoom
107+
expect(textContent.text).toContain('600x400'); // size
108+
expect(textContent.text).toContain('mapbox/streets-v12'); // style
109+
} finally {
110+
if (originalEnv !== undefined) {
111+
process.env.ENABLE_MCP_UI = originalEnv;
112+
} else {
113+
delete process.env.ENABLE_MCP_UI;
114+
}
115+
}
116+
});
117+
118+
it('text content includes overlay count when overlays present', async () => {
119+
const originalEnv = process.env.ENABLE_MCP_UI;
120+
process.env.ENABLE_MCP_UI = 'false';
121+
122+
try {
123+
const mockImageBuffer = Buffer.from('fake-image-data');
124+
const mockArrayBuffer = mockImageBuffer.buffer.slice(
125+
mockImageBuffer.byteOffset,
126+
mockImageBuffer.byteOffset + mockImageBuffer.byteLength
127+
);
128+
129+
const { httpRequest } = setupHttpRequest({
130+
arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer)
131+
});
132+
133+
const result = await new StaticMapImageTool({ httpRequest }).run({
134+
center: { longitude: -74.006, latitude: 40.7128 },
135+
zoom: 12,
136+
size: { width: 600, height: 400 },
137+
style: 'mapbox/streets-v12',
138+
overlays: [
139+
{
140+
type: 'marker',
141+
longitude: -74.006,
142+
latitude: 40.7128,
143+
size: 'large',
144+
color: 'ff0000'
145+
},
146+
{
147+
type: 'marker',
148+
longitude: -74.01,
149+
latitude: 40.71,
150+
size: 'small',
151+
color: '00ff00'
152+
}
153+
]
154+
});
155+
156+
expect(result.isError).toBe(false);
157+
const textContent = result.content[0] as { type: 'text'; text: string };
158+
expect(textContent.text).toContain('Overlays: 2');
159+
} finally {
160+
if (originalEnv !== undefined) {
161+
process.env.ENABLE_MCP_UI = originalEnv;
162+
} else {
163+
delete process.env.ENABLE_MCP_UI;
164+
}
165+
}
166+
});
167+
73168
it('constructs correct Mapbox Static API URL', async () => {
74169
const { httpRequest, mockHttpRequest } = setupHttpRequest();
75170

@@ -573,11 +668,12 @@ describe('StaticMapImageTool', () => {
573668
});
574669

575670
expect(result.isError).toBe(false);
576-
expect(result.content).toHaveLength(2); // image + UIResource
577-
expect(result.content[0].type).toBe('image');
578-
expect(result.content[1].type).toBe('resource');
579-
if (result.content[1].type === 'resource') {
580-
expect(result.content[1].resource.uri).toMatch(
671+
expect(result.content).toHaveLength(3); // text + image + UIResource
672+
expect(result.content[0].type).toBe('text');
673+
expect(result.content[1].type).toBe('image');
674+
expect(result.content[2].type).toBe('resource');
675+
if (result.content[2].type === 'resource') {
676+
expect(result.content[2].resource.uri).toMatch(
581677
/^ui:\/\/mapbox\/static-map\//
582678
);
583679
}
@@ -608,8 +704,9 @@ describe('StaticMapImageTool', () => {
608704
});
609705

610706
expect(result.isError).toBe(false);
611-
expect(result.content).toHaveLength(1); // image only, no UIResource
612-
expect(result.content[0].type).toBe('image');
707+
expect(result.content).toHaveLength(2); // text + image, no UIResource
708+
expect(result.content[0].type).toBe('text');
709+
expect(result.content[1].type).toBe('image');
613710
} finally {
614711
// Restore environment variable
615712
if (originalEnv !== undefined) {
@@ -640,13 +737,14 @@ describe('StaticMapImageTool', () => {
640737
});
641738

642739
expect(result.isError).toBe(false);
643-
if (result.content[1]?.type === 'resource') {
644-
expect(result.content[1].resource.uri).toContain(
740+
// UIResource is now at index 2 (after text and image)
741+
if (result.content[2]?.type === 'resource') {
742+
expect(result.content[2].resource.uri).toContain(
645743
'-122.4194,37.7749,13'
646744
);
647745
// Check that UIMetadata has preferred dimensions
648-
if ('uiMetadata' in result.content[1].resource) {
649-
const metadata = result.content[1].resource.uiMetadata as Record<
746+
if ('uiMetadata' in result.content[2].resource) {
747+
const metadata = result.content[2].resource.uiMetadata as Record<
650748
string,
651749
unknown
652750
>;

0 commit comments

Comments
 (0)