From ee39601ec6b2757ff9704bc8bd556a1373df537f Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 2 Feb 2026 09:15:29 -0500 Subject: [PATCH 01/16] Add MCP Apps support for static_map_image_tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR implements MCP Apps support for static_map_image_tool, enabling interactive map preview in compatible hosts (VS Code, Claude Code, Goose). - Add @modelcontextprotocol/ext-apps dependency and upgrade SDK to 1.25.3 - Add meta property to BaseTool for MCP Apps metadata (_meta.ui) - Create StaticMapUIResource serving interactive HTML with MCP Apps SDK - Refactor StaticMapImageTool to return URL instead of base64 image - Update index.ts to register UI resources with registerAppResource - Update all tests to expect URL text content instead of image data - No server-side image fetching (URL-based approach) - Interactive visualization in MCP Apps-capable hosts - CSP configuration for api.mapbox.com domains - Maintains backward compatibility with MCP-UI pattern - Consistent with mcp-devkit-server implementation All 597 tests passing ✅ - Companion to mcp-devkit-server PR #62 - Resolves closed PR #107 properly Co-Authored-By: Claude Sonnet 4.5 --- package-lock.json | 101 ++++--- package.json | 62 +--- src/index.ts | 29 +- src/resources/resourceRegistry.ts | 4 +- src/resources/ui-apps/StaticMapUIResource.ts | 223 ++++++++++++++ src/tools/BaseTool.ts | 25 ++ .../StaticMapImageTool.ts | 60 +--- .../StaticMapImageTool.test.ts | 286 +++++++----------- 8 files changed, 475 insertions(+), 315 deletions(-) create mode 100644 src/resources/ui-apps/StaticMapUIResource.ts diff --git a/package-lock.json b/package-lock.json index e14f200..e7216f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "@mapbox/mcp-server", - "version": "0.8.3", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mapbox/mcp-server", - "version": "0.8.3", + "version": "0.8.2", "hasInstallScript": true, "license": "MIT", "dependencies": { "@mcp-ui/server": "^5.13.1", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/ext-apps": "^1.0.1", + "@modelcontextprotocol/sdk": "^1.25.3", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.56.0", "@opentelemetry/exporter-trace-otlp-http": "^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,10 +1914,53 @@ } } }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.0.1.tgz", + "integrity": "sha512-rAPzBbB5GNgYk216paQjGKUgbNXSy/yeR95c0ni6Y4uvhWI2AeF+ztEOqQFLBMQy/MPM+02pbVK1HaQmQjMwYQ==", + "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", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.25.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz", + "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -1927,15 +1971,14 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" @@ -6279,7 +6322,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9140,9 +9182,9 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -9151,7 +9193,7 @@ "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.1", + "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" }, @@ -10816,13 +10858,10 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", - "dependencies": { - "ip-address": "10.0.1" - }, "engines": { "node": ">= 16" }, @@ -11537,6 +11576,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -11862,15 +11902,6 @@ "node": ">=10.13.0" } }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -13686,9 +13717,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/package.json b/package.json index 6d2b02d..ec854d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mapbox/mcp-server", - "version": "0.8.3", + "version": "0.8.2", "description": "Mapbox MCP server.", "mcpName": "io.github.mapbox/mcp-server", "main": "./dist/commonjs/index.js", @@ -11,17 +11,16 @@ }, "scripts": { "build": "npm run prepare && tshy && npm run generate-version && node scripts/add-shebang.cjs", - "changelog:prepare-release": "node scripts/prepare-changelog-release.cjs", - "format": "prettier --check \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\" \"./examples/**/*.{ts,tsx,js,json,md}\"", - "format:fix": "prettier --write \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\" \"./examples/**/*.{ts,tsx,js,json,md}\"", + "format": "prettier --check \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\"", + "format:fix": "prettier --write \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\"", "generate-version": "node scripts/build-helpers.cjs generate-version", "inspect:build": "npm run build && npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" node dist/esm/index.js", "inspect:dev": "npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" npx -y tsx src/index.ts", - "lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" \"./examples/**/*.{ts,tsx}\"", - "lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" \"./examples/**/*.{ts,tsx}\" --fix", + "lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\"", + "lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" --fix", "postinstall": "patch-package || (cd ../../.. && node ./node_modules/patch-package/index.js --patch-dir ./node_modules/@mapbox/mcp-server/patches) || true", "prepare": "node -e \"try { require('fs').accessSync('.husky/setup-hooks.js'); require('child_process').execSync('husky && node .husky/setup-hooks.js', {stdio:'inherit'}) } catch { }\"", - "spellcheck": "cspell \"*.md\" \"src/**/*.ts\" \"test/**/*.ts\" \"examples/**/*.ts\"", + "spellcheck": "cspell \"*.md\" \"src/**/*.ts\" \"test/**/*.ts\"", "sync-manifest": "node scripts/sync-manifest-version.cjs", "test": "vitest", "tracing:jaeger:start": "docker run --rm -d --name jaeger -p 16686:16686 -p 14250:14250 -p 4317:4317 -p 4318:4318 jaegertracing/all-in-one:latest", @@ -77,7 +76,8 @@ ], "dependencies": { "@mcp-ui/server": "^5.13.1", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/ext-apps": "^1.0.1", + "@modelcontextprotocol/sdk": "^1.25.3", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.56.0", "@opentelemetry/exporter-trace-otlp-http": "^0.56.0", @@ -93,11 +93,7 @@ "tshy": { "project": "./tsconfig.src.json", "exports": { - ".": "./src/index.ts", - "./tools": "./src/tools/index.ts", - "./resources": "./src/resources/index.ts", - "./prompts": "./src/prompts/index.ts", - "./utils": "./src/utils/index.ts" + ".": "./src/index.ts" }, "dialects": [ "esm", @@ -115,46 +111,6 @@ "types": "./dist/commonjs/index.d.ts", "default": "./dist/commonjs/index.js" } - }, - "./tools": { - "import": { - "types": "./dist/esm/tools/index.d.ts", - "default": "./dist/esm/tools/index.js" - }, - "require": { - "types": "./dist/commonjs/tools/index.d.ts", - "default": "./dist/commonjs/tools/index.js" - } - }, - "./resources": { - "import": { - "types": "./dist/esm/resources/index.d.ts", - "default": "./dist/esm/resources/index.js" - }, - "require": { - "types": "./dist/commonjs/resources/index.d.ts", - "default": "./dist/commonjs/resources/index.js" - } - }, - "./prompts": { - "import": { - "types": "./dist/esm/prompts/index.d.ts", - "default": "./dist/esm/prompts/index.js" - }, - "require": { - "types": "./dist/commonjs/prompts/index.d.ts", - "default": "./dist/commonjs/prompts/index.js" - } - }, - "./utils": { - "import": { - "types": "./dist/esm/utils/index.d.ts", - "default": "./dist/esm/utils/index.js" - }, - "require": { - "types": "./dist/commonjs/utils/index.d.ts", - "default": "./dist/commonjs/utils/index.js" - } } }, "types": "./dist/commonjs/index.d.ts" diff --git a/src/index.ts b/src/index.ts index 8d654ee..248e16c 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,29 @@ 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, + { mimeType: RESOURCE_MIME_TYPE, description: resource.description }, + 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..70a3db7 --- /dev/null +++ b/src/resources/ui-apps/StaticMapUIResource.ts @@ -0,0 +1,223 @@ +// 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 with embedded iframe for static map visualization + const html = ` + + + + + Static Map Preview + + + +
Loading 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..71263f8 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.ts @@ -15,7 +15,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 +23,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 +109,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}`, 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 >; From 79fd1136f6ee401df45b99a73928de3517a9b68b Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 2 Feb 2026 11:15:48 -0500 Subject: [PATCH 02/16] Enhance static map UI with click-to-zoom and better layout - Add click-to-zoom functionality (toggle fit-to-window vs full size) - Add hover hint showing zoom instructions at bottom - Improve layout with dark theme and centered image - Suggest larger default window size (1200x900) - Remove fullscreen button (doesn't work in embedded views) - Add smooth hover effects and transitions Co-Authored-By: Claude Sonnet 4.5 --- src/resources/ui-apps/StaticMapUIResource.ts | 88 ++++++++++++++++++-- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/src/resources/ui-apps/StaticMapUIResource.ts b/src/resources/ui-apps/StaticMapUIResource.ts index 70a3db7..2d71e3d 100644 --- a/src/resources/ui-apps/StaticMapUIResource.ts +++ b/src/resources/ui-apps/StaticMapUIResource.ts @@ -43,12 +43,52 @@ export class StaticMapUIResource extends BaseResource { body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; overflow: hidden; + background: #000; + display: flex; + flex-direction: column; + height: 100vh; + } + #zoom-hint { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.9); + padding: 8px 16px; + border-radius: 4px; + font-size: 13px; + color: #333; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + opacity: 0; + transition: opacity 0.3s; + pointer-events: none; + } + #zoom-hint.show { + opacity: 1; + } + #image-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; } #preview-image { max-width: 100%; - max-height: 100vh; - display: block; - margin: 0 auto; + max-height: 100%; + width: auto; + height: auto; + display: none; + cursor: zoom-in; + transition: transform 0.2s; + } + #preview-image:hover { + transform: scale(1.02); + } + #preview-image.zoomed { + cursor: zoom-out; + max-width: none; + max-height: none; } #loading { position: absolute; @@ -56,18 +96,26 @@ export class StaticMapUIResource extends BaseResource { left: 50%; transform: translate(-50%, -50%); text-align: center; - color: #666; + color: #fff; + font-size: 16px; } #error { padding: 20px; - color: #cc0000; + color: #ff6b6b; text-align: center; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + max-width: 600px; + margin: 20px auto; }
Loading static map preview...
- +
+ Static Map Preview +
+
Click to view full size