diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e59bc3..185a9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,25 @@ ## 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) + - Added interactive map preview in compatible MCP clients (VS Code, Claude Code, Goose) + - Implemented `StaticMapUIResource` serving interactive HTML with inline MCP Apps SDK + - Added `@modelcontextprotocol/ext-apps@^1.0.1` dependency + - Enhanced `BaseTool` with `meta` property for MCP Apps metadata + - Configured CSP for `api.mapbox.com` domains + - 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 + ### Security - **CVE-2026-0621**: Updated `@modelcontextprotocol/sdk` to 1.25.3 to fix ReDoS vulnerability in UriTemplate regex patterns @@ -47,6 +66,7 @@ ### Dependencies +- Added `@modelcontextprotocol/ext-apps@^1.0.1` - Updated `@modelcontextprotocol/sdk` from 1.17.5 to 1.25.3 ## 0.8.2 diff --git a/package-lock.json b/package-lock.json index e14f200..5a18b42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "@mcp-ui/server": "^5.13.1", + "@modelcontextprotocol/ext-apps": "^1.1.0", "@modelcontextprotocol/sdk": "^1.26.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.56.0", @@ -1868,7 +1869,7 @@ "@modelcontextprotocol/sdk": "^1.25.1" } }, - "node_modules/@modelcontextprotocol/ext-apps": { + "node_modules/@mcp-ui/server/node_modules/@modelcontextprotocol/ext-apps": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-0.2.2.tgz", "integrity": "sha512-h8sN3QIBLqMsRXjKL76M5VmBQf3N0I1G1DiDiSYAgtdynYQctHqCs79WEo1d5wClyZVYBWXdRcxgiR/WBfSOqw==", @@ -1913,6 +1914,49 @@ } } }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.1.0.tgz", + "integrity": "sha512-o65O+sHHKP4h1kWMEz9sFKVIzF1BYZj/Oi5WM34g3IIqbwGxGZEailWiMqv360TWyL4HkdgLL6cXDtXebddaDQ==", + "hasInstallScript": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.2.21", + "@oven/bun-darwin-x64": "^1.2.21", + "@oven/bun-darwin-x64-baseline": "^1.2.21", + "@oven/bun-linux-aarch64": "^1.2.21", + "@oven/bun-linux-aarch64-musl": "^1.2.21", + "@oven/bun-linux-x64": "^1.2.21", + "@oven/bun-linux-x64-baseline": "^1.2.21", + "@oven/bun-linux-x64-musl": "^1.2.21", + "@oven/bun-linux-x64-musl-baseline": "^1.2.21", + "@oven/bun-windows-x64": "^1.2.21", + "@oven/bun-windows-x64-baseline": "^1.2.21", + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-win32-arm64-msvc": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -6279,7 +6323,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 6d2b02d..16b66ef 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ ], "dependencies": { "@mcp-ui/server": "^5.13.1", + "@modelcontextprotocol/ext-apps": "^1.1.0", "@modelcontextprotocol/sdk": "^1.26.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.56.0", diff --git a/src/index.ts b/src/index.ts index 8d654ee..2127806 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,10 @@ import { SpanStatusCode } from '@opentelemetry/api'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + registerAppResource, + RESOURCE_MIME_TYPE +} from '@modelcontextprotocol/ext-apps/server'; import { ListPromptsRequestSchema, GetPromptRequestSchema @@ -109,8 +113,30 @@ enabledCoreTools.forEach((tool) => { tool.installTo(server); }); -// Register all resources to the server -allResources.forEach((resource) => { +// Separate MCP Apps UI resources from regular resources +const uiResources = allResources.filter((r) => r.uri.startsWith('ui://')); +const regularResources = allResources.filter((r) => !r.uri.startsWith('ui://')); + +// Register MCP Apps UI resources using registerAppResource +// IMPORTANT: Use RESOURCE_MIME_TYPE which is "text/html;profile=mcp-app" +// This tells clients (like Claude Desktop) that this is an MCP App +uiResources.forEach((resource) => { + registerAppResource( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + server as any, + resource.name, + resource.uri, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { mimeType: RESOURCE_MIME_TYPE, description: resource.description } as any, + async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await resource.read(resource.uri, {} as any); + } + ); +}); + +// Register regular resources using standard registration +regularResources.forEach((resource) => { resource.installTo(server); }); diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index fc62306..0b9d59a 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -4,13 +4,15 @@ // INSERT NEW RESOURCE IMPORT HERE import { CategoryListResource } from './category-list/CategoryListResource.js'; import { TemporaryDataResource } from './temporary/TemporaryDataResource.js'; +import { StaticMapUIResource } from './ui-apps/StaticMapUIResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; // Central registry of all resources export const ALL_RESOURCES = [ // INSERT NEW RESOURCE INSTANCE HERE new CategoryListResource({ httpRequest }), - new TemporaryDataResource() + new TemporaryDataResource(), + new StaticMapUIResource() ] as const; export type ResourceInstance = (typeof ALL_RESOURCES)[number]; diff --git a/src/resources/ui-apps/StaticMapUIResource.ts b/src/resources/ui-apps/StaticMapUIResource.ts new file mode 100644 index 0000000..4325251 --- /dev/null +++ b/src/resources/ui-apps/StaticMapUIResource.ts @@ -0,0 +1,269 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { + ReadResourceResult, + ServerNotification, + ServerRequest +} from '@modelcontextprotocol/sdk/types.js'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; +import { BaseResource } from '../BaseResource.js'; + +/** + * Serves UI App HTML for Static Map Preview + * Implements MCP Apps pattern with ui:// scheme + */ +export class StaticMapUIResource extends BaseResource { + readonly name = 'Static Map Preview UI'; + readonly uri = 'ui://mapbox/static-map/index.html'; + readonly description = + 'Interactive UI for previewing static map images (MCP Apps)'; + readonly mimeType = RESOURCE_MIME_TYPE; + + async read( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _uri: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _extra?: RequestHandlerExtra + ): Promise { + // Generate HTML for static map visualization with MCP Apps protocol support + const html = ` + + + + + Static Map Preview + + + +
+ +
+
+
Loading static map preview...
+ Static Map Preview + +
+ + + +`; + + return { + contents: [ + { + uri: this.uri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + connectDomains: ['https://api.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + } + } + ] + }; + } +} diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index 665999e..6da3bf9 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -23,6 +23,16 @@ export abstract class BaseTool< readonly inputSchema: InputSchema; readonly outputSchema?: OutputSchema; + readonly meta?: { + ui?: { + resourceUri?: string; + csp?: { + connectDomains?: string[]; + resourceDomains?: string[]; + frameDomains?: string[]; + }; + }; + }; protected server: McpServer | null = null; constructor(params: { @@ -47,6 +57,16 @@ export abstract class BaseTool< // eslint-disable-next-line @typescript-eslint/no-explicit-any outputSchema?: any; annotations?: ToolAnnotations; + _meta?: { + ui?: { + resourceUri?: string; + csp?: { + connectDomains?: string[]; + resourceDomains?: string[]; + frameDomains?: string[]; + }; + }; + }; } = { title: this.annotations.title, description: this.description, @@ -62,6 +82,11 @@ export abstract class BaseTool< (this.outputSchema as unknown as z.ZodObject).shape; } + // Add _meta for MCP Apps support if provided (includes CSP configuration) + if (this.meta) { + config._meta = this.meta; + } + return server.registerTool( this.name, config, diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.ts b/src/tools/static-map-image-tool/StaticMapImageTool.ts index 7a9123c..a39eab8 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.ts @@ -1,6 +1,7 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. +import { randomUUID } from 'node:crypto'; import type { z } from 'zod'; import { createUIResource } from '@mcp-ui/server'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; @@ -15,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 PNG for vector styles, JPEG for raster-only styles.'; + '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.'; annotations = { title: 'Static Map Image Tool', readOnlyHint: true, @@ -23,6 +24,15 @@ export class StaticMapImageTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/static-map/index.html', + csp: { + connectDomains: ['https://api.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; constructor(params: { httpRequest: HttpRequest }) { super({ @@ -100,57 +110,16 @@ 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}`; - const response = await this.httpRequest(url); - - if (!response.ok) { - const errorMessage = await this.getErrorMessage(response); - return { - content: [ - { - type: 'text', - text: `Static Map API error: ${errorMessage}` - } - ], - isError: true - }; - } - - const buffer = await response.arrayBuffer(); - - const base64Data = Buffer.from(buffer).toString('base64'); - - // Determine MIME type based on style (raster-only styles return JPEG) - const isRasterStyle = input.style.includes('satellite'); - const mimeType = isRasterStyle ? 'image/jpeg' : 'image/png'; - - // Build descriptive text with map metadata (Issue #103) - // Text content provides additional context alongside the image - const textDescription = [ - 'Static map image generated successfully.', - `Center: ${lat}, ${lng}`, - `Zoom: ${input.zoom}`, - `Size: ${width}x${height}${input.highDensity ? ' @2x' : ''}`, - `Style: ${input.style}`, - input.overlays?.length ? `Overlays: ${input.overlays.length}` : null - ] - .filter(Boolean) - .join('\n'); - - // Build content array with text first, then image - // Per MCP spec, content array can have multiple items of different types + // Return the URL directly instead of fetching and encoding + // This enables MCP Apps to display the image with proper CSP const content: CallToolResult['content'] = [ { type: 'text', - text: textDescription - }, - { - type: 'image', - data: base64Data, - mimeType + text: url } ]; - // Conditionally add MCP-UI resource if enabled + // Conditionally add MCP-UI resource if enabled (backward compatibility) if (isMcpUiEnabled()) { const uiResource = createUIResource({ uri: `ui://mapbox/static-map/${input.style}/${lng},${lat},${input.zoom}`, @@ -168,7 +137,10 @@ export class StaticMapImageTool extends MapboxApiBasedTool< return { content, - isError: false + isError: false, + _meta: { + viewUUID: randomUUID() + } }; } } diff --git a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts index 2439efb..ea7531e 100644 --- a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts +++ b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts @@ -5,10 +5,7 @@ process.env.MAPBOX_ACCESS_TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; import { describe, it, expect, afterEach, vi } from 'vitest'; -import { - setupHttpRequest, - assertHeadersSent -} from '../../utils/httpPipelineUtils.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; import { StaticMapImageTool } from '../../../src/tools/static-map-image-tool/StaticMapImageTool.js'; describe('StaticMapImageTool', () => { @@ -16,35 +13,29 @@ describe('StaticMapImageTool', () => { vi.restoreAllMocks(); }); - it('sends custom header', async () => { + it('generates valid URL without fetching', async () => { const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, style: 'mapbox/streets-v12' }); - assertHeadersSent(mockHttpRequest); + // Should not call httpRequest since we're just generating URL + expect(mockHttpRequest).toHaveBeenCalledTimes(0); + expect(result.isError).toBe(false); + expect(result.content[0].type).toBe('text'); }); - it('returns image content with base64 data', async () => { - // Disable MCP-UI for this test to focus on image data only + it('returns URL as text content', async () => { + // Disable MCP-UI for this test to focus on URL only const originalEnv = process.env.ENABLE_MCP_UI; process.env.ENABLE_MCP_UI = 'false'; try { - // Mock image buffer - const mockImageBuffer = Buffer.from('fake-image-data'); - const mockArrayBuffer = mockImageBuffer.buffer.slice( - mockImageBuffer.byteOffset, - mockImageBuffer.byteOffset + mockImageBuffer.byteLength - ); - - const { httpRequest } = setupHttpRequest({ - arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer) - }); + const { httpRequest } = setupHttpRequest(); const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, @@ -54,15 +45,15 @@ describe('StaticMapImageTool', () => { }); expect(result.isError).toBe(false); - expect(result.content).toHaveLength(2); // text + image - // First content should be text description + expect(result.content).toHaveLength(1); // URL only expect(result.content[0].type).toBe('text'); - // Second content should be image - expect(result.content[1]).toMatchObject({ - type: 'image', - data: mockImageBuffer.toString('base64'), - mimeType: 'image/jpeg' // satellite is a raster style - }); + const textContent = result.content[0] as { type: 'text'; text: string }; + expect(textContent.text).toContain( + 'api.mapbox.com/styles/v1/mapbox/satellite-v9/static/' + ); + expect(textContent.text).toContain('-74.006,40.7128,10'); + expect(textContent.text).toContain('800x600'); + expect(textContent.text).toContain('access_token='); } finally { // Restore environment variable if (originalEnv !== undefined) { @@ -73,21 +64,13 @@ describe('StaticMapImageTool', () => { } }); - it('returns text content with map details as first item', async () => { - // Disable MCP-UI for this test to focus on text + image + it('returns URL containing map details', async () => { + // Disable MCP-UI for this test to focus on URL only const originalEnv = process.env.ENABLE_MCP_UI; process.env.ENABLE_MCP_UI = 'false'; try { - const mockImageBuffer = Buffer.from('fake-image-data'); - const mockArrayBuffer = mockImageBuffer.buffer.slice( - mockImageBuffer.byteOffset, - mockImageBuffer.byteOffset + mockImageBuffer.byteLength - ); - - const { httpRequest } = setupHttpRequest({ - arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer) - }); + const { httpRequest } = setupHttpRequest(); const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, @@ -100,12 +83,9 @@ describe('StaticMapImageTool', () => { expect(result.content[0].type).toBe('text'); const textContent = result.content[0] as { type: 'text'; text: string }; - expect(textContent.text).toContain('Static map image generated'); - expect(textContent.text).toContain('40.7128'); // latitude - expect(textContent.text).toContain('-74.006'); // longitude - expect(textContent.text).toContain('12'); // zoom - expect(textContent.text).toContain('600x400'); // size - expect(textContent.text).toContain('mapbox/streets-v12'); // style + expect(textContent.text).toContain('mapbox/streets-v12/static/'); + expect(textContent.text).toContain('-74.006,40.7128,12'); + expect(textContent.text).toContain('600x400'); } finally { if (originalEnv !== undefined) { process.env.ENABLE_MCP_UI = originalEnv; @@ -115,20 +95,12 @@ describe('StaticMapImageTool', () => { } }); - it('text content includes overlay count when overlays present', async () => { + it('URL includes overlay markers when overlays present', async () => { const originalEnv = process.env.ENABLE_MCP_UI; process.env.ENABLE_MCP_UI = 'false'; try { - const mockImageBuffer = Buffer.from('fake-image-data'); - const mockArrayBuffer = mockImageBuffer.buffer.slice( - mockImageBuffer.byteOffset, - mockImageBuffer.byteOffset + mockImageBuffer.byteLength - ); - - const { httpRequest } = setupHttpRequest({ - arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer) - }); + const { httpRequest } = setupHttpRequest(); const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, @@ -155,7 +127,8 @@ describe('StaticMapImageTool', () => { expect(result.isError).toBe(false); const textContent = result.content[0] as { type: 'text'; text: string }; - expect(textContent.text).toContain('Overlays: 2'); + expect(textContent.text).toContain('pin-l+ff0000(-74.006,40.7128)'); + expect(textContent.text).toContain('pin-s+00ff00(-74.01,40.71)'); } finally { if (originalEnv !== undefined) { process.env.ENABLE_MCP_UI = originalEnv; @@ -166,53 +139,33 @@ describe('StaticMapImageTool', () => { }); it('constructs correct Mapbox Static API URL', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -122.4194, latitude: 37.7749 }, zoom: 15, size: { width: 1024, height: 768 }, style: 'mapbox/dark-v10' }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain('styles/v1/mapbox/dark-v10/static/'); - expect(calledUrl).toContain('-122.4194,37.7749,15'); - expect(calledUrl).toContain('1024x768'); - expect(calledUrl).toContain('access_token='); + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain('styles/v1/mapbox/dark-v10/static/'); + expect(url).toContain('-122.4194,37.7749,15'); + expect(url).toContain('1024x768'); + expect(url).toContain('access_token='); }); it('uses default style when not specified', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: 0, latitude: 0 }, zoom: 1, size: { width: 300, height: 200 } }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain('styles/v1/mapbox/streets-v12/static/'); - }); - - it('handles fetch errors gracefully', async () => { - const { httpRequest } = setupHttpRequest({ - ok: false, - status: 404, - statusText: 'Not Found' - }); - - const result = await new StaticMapImageTool({ httpRequest }).run({ - center: { longitude: -74.006, latitude: 40.7128 }, - zoom: 12, - size: { width: 600, height: 400 } - }); - - expect(result.isError).toBe(true); - expect(result.content[0]).toMatchObject({ - type: 'text', - text: 'Static Map API error: 404: Not Found' - }); + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain('styles/v1/mapbox/streets-v12/static/'); }); it('validates coordinate constraints', async () => { @@ -270,24 +223,24 @@ describe('StaticMapImageTool', () => { }); it('supports high density parameter', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, highDensity: true }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain('600x400@2x'); + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain('600x400@2x'); }); describe('overlay support', () => { it('adds marker overlay to URL', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -303,14 +256,14 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain('pin-l-a+ff0000(-74.006,40.7128)/'); + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain('pin-l-a+ff0000(-74.006,40.7128)/'); }); it('adds custom marker overlay to URL', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -324,16 +277,16 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain( + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain( `url-${encodeURIComponent('https://example.com/marker.png')}(-74.006,40.7128)/` ); }); it('adds path overlay to URL', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -350,21 +303,21 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain( + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain( `path-3+0000ff-0.8+ff0000-0.5(${encodeURIComponent('u{~vFvyys@fS]')})/` ); }); it('adds GeoJSON overlay to URL', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); const geoJsonData = { type: 'Point', coordinates: [-74.006, 40.7128] }; - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -376,16 +329,16 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain( + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain( `geojson(${encodeURIComponent(JSON.stringify(geoJsonData))})/` ); }); it('supports multiple overlays in order', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -408,30 +361,31 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain( + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain( 'pin-s+00ff00(-74.01,40.71),pin-l-b+ff0000(-74.002,40.715)/' ); }); it('works without overlays', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 } }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; + const calledUrl = (result.content[0] as { type: 'text'; text: string }) + .text; // Should not have overlay string before coordinates expect(calledUrl).toMatch(/static\/-74\.006,40\.7128,12/); }); it('transforms uppercase labels to lowercase', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -447,15 +401,16 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; + const calledUrl = (result.content[0] as { type: 'text'; text: string }) + .text; // Should contain lowercase 'z' even though 'Z' was provided expect(calledUrl).toContain('pin-s-z+0000ff(-74.006,40.7128)/'); }); it('supports Maki icon names as labels', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -471,14 +426,14 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain('pin-l-embassy+ff0000(-74.006,40.7128)/'); + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain('pin-l-embassy+ff0000(-74.006,40.7128)/'); }); it('transforms uppercase Maki icon names to lowercase', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -494,15 +449,16 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; + const calledUrl = (result.content[0] as { type: 'text'; text: string }) + .text; // Should contain lowercase 'airport' even though 'AIRPORT' was provided expect(calledUrl).toContain('pin-s-airport+00ff00(-74.01,40.71)/'); }); it('supports numeric labels', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -518,14 +474,14 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain('pin-l-42+0000ff(-74.006,40.7128)/'); + const url = (result.content[0] as { type: 'text'; text: string }).text; + expect(url).toContain('pin-l-42+0000ff(-74.006,40.7128)/'); }); it('handles complex overlay combination with paths and markers', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -80.278, latitude: 25.796 }, zoom: 15, size: { width: 800, height: 600 }, @@ -557,7 +513,8 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; + const calledUrl = (result.content[0] as { type: 'text'; text: string }) + .text; // Check that markers have lowercase labels expect(calledUrl).toContain('pin-l-a+ff0000(-80.2793529,25.7950805)'); expect(calledUrl).toContain( @@ -572,9 +529,9 @@ describe('StaticMapImageTool', () => { }); it('truncates non-Maki multi-character labels to first character', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -598,16 +555,17 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; + const calledUrl = (result.content[0] as { type: 'text'; text: string }) + .text; // Should contain only the first character in lowercase expect(calledUrl).toContain('pin-s-h+0000ff(-74.006,40.7128)'); expect(calledUrl).toContain('pin-l-x+ff0000(-74.01,40.71)'); }); it('preserves full Maki icon names that are in the supported list', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const { httpRequest } = setupHttpRequest(); - await new StaticMapImageTool({ httpRequest }).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -639,7 +597,8 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; + const calledUrl = (result.content[0] as { type: 'text'; text: string }) + .text; // Should preserve the full Maki icon names expect(calledUrl).toContain('pin-s-restaurant+0000ff(-74.006,40.7128)'); expect(calledUrl).toContain('pin-l-hospital+ff0000(-74.01,40.71)'); @@ -649,16 +608,7 @@ describe('StaticMapImageTool', () => { describe('MCP-UI support', () => { it('includes UIResource when MCP-UI is enabled (default)', async () => { - // Mock image buffer - const mockImageBuffer = Buffer.from('fake-image-data'); - const mockArrayBuffer = mockImageBuffer.buffer.slice( - mockImageBuffer.byteOffset, - mockImageBuffer.byteOffset + mockImageBuffer.byteLength - ); - - const { httpRequest } = setupHttpRequest({ - arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer) - }); + const { httpRequest } = setupHttpRequest(); const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, @@ -668,12 +618,11 @@ describe('StaticMapImageTool', () => { }); expect(result.isError).toBe(false); - expect(result.content).toHaveLength(3); // text + image + UIResource + expect(result.content).toHaveLength(2); // URL + UIResource expect(result.content[0].type).toBe('text'); - 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( + expect(result.content[1].type).toBe('resource'); + if (result.content[1].type === 'resource') { + expect(result.content[1].resource.uri).toMatch( /^ui:\/\/mapbox\/static-map\// ); } @@ -685,16 +634,7 @@ describe('StaticMapImageTool', () => { process.env.ENABLE_MCP_UI = 'false'; try { - // Mock image buffer - const mockImageBuffer = Buffer.from('fake-image-data'); - const mockArrayBuffer = mockImageBuffer.buffer.slice( - mockImageBuffer.byteOffset, - mockImageBuffer.byteOffset + mockImageBuffer.byteLength - ); - - const { httpRequest } = setupHttpRequest({ - arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer) - }); + const { httpRequest } = setupHttpRequest(); const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, @@ -704,9 +644,8 @@ describe('StaticMapImageTool', () => { }); expect(result.isError).toBe(false); - expect(result.content).toHaveLength(2); // text + image, no UIResource + expect(result.content).toHaveLength(1); // URL only, no UIResource expect(result.content[0].type).toBe('text'); - expect(result.content[1].type).toBe('image'); } finally { // Restore environment variable if (originalEnv !== undefined) { @@ -718,16 +657,7 @@ describe('StaticMapImageTool', () => { }); it('UIResource includes correct iframe URL and dimensions', async () => { - // Mock image buffer - const mockImageBuffer = Buffer.from('fake-image-data'); - const mockArrayBuffer = mockImageBuffer.buffer.slice( - mockImageBuffer.byteOffset, - mockImageBuffer.byteOffset + mockImageBuffer.byteLength - ); - - const { httpRequest } = setupHttpRequest({ - arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer) - }); + const { httpRequest } = setupHttpRequest(); const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -122.4194, latitude: 37.7749 }, @@ -737,14 +667,14 @@ describe('StaticMapImageTool', () => { }); expect(result.isError).toBe(false); - // UIResource is now at index 2 (after text and image) - if (result.content[2]?.type === 'resource') { - expect(result.content[2].resource.uri).toContain( + // UIResource is now at index 1 (after URL text) + if (result.content[1]?.type === 'resource') { + expect(result.content[1].resource.uri).toContain( '-122.4194,37.7749,13' ); // Check that UIMetadata has preferred dimensions - if ('uiMetadata' in result.content[2].resource) { - const metadata = result.content[2].resource.uiMetadata as Record< + if ('uiMetadata' in result.content[1].resource) { + const metadata = result.content[1].resource.uiMetadata as Record< string, unknown >;