From ea5cf83068278589e1dae32f56353a023ccc59ab Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 28 Jan 2026 10:04:11 -0500 Subject: [PATCH 1/7] Add MCP Apps support alongside MCP-UI Implement the MCP Apps pattern for all tools with MCP-UI support, maintaining backward compatibility by supporting both patterns simultaneously. Changes: - Add _meta.ui.resourceUri to tool responses for MCP Apps pattern - Create UI resources (ui:// scheme) for each tool: - PreviewStyleUIResource for preview_style_tool - StyleComparisonUIResource for style_comparison_tool - GeojsonPreviewUIResource for geojson_preview_tool - Register UI resources in resourceRegistry.ts The MCP Apps pattern enables broader client compatibility (Claude Code, VS Code) while preserving existing MCP-UI functionality. Tools now return both: 1. MCP-UI resource content via createUIResource() 2. MCP Apps metadata via _meta.ui.resourceUri This graceful degradation allows clients to use whichever pattern they support. Related: https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/ All tests passing (520 tests). --- src/resources/resourceRegistry.ts | 9 +- .../ui-apps/GeojsonPreviewUIResource.ts | 119 +++++++++++++++ .../ui-apps/PreviewStyleUIResource.ts | 135 ++++++++++++++++++ .../ui-apps/StyleComparisonUIResource.ts | 121 ++++++++++++++++ .../GeojsonPreviewTool.ts | 25 +++- .../preview-style-tool/PreviewStyleTool.ts | 15 +- .../StyleComparisonTool.ts | 15 +- 7 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 src/resources/ui-apps/GeojsonPreviewUIResource.ts create mode 100644 src/resources/ui-apps/PreviewStyleUIResource.ts create mode 100644 src/resources/ui-apps/StyleComparisonUIResource.ts diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 5288bd8..4786a97 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -6,6 +6,9 @@ import { MapboxStreetsV8FieldsResource } from './mapbox-streets-v8-fields-resour import { MapboxTokenScopesResource } from './mapbox-token-scopes-resource/MapboxTokenScopesResource.js'; import { MapboxLayerTypeMappingResource } from './mapbox-layer-type-mapping-resource/MapboxLayerTypeMappingResource.js'; import { MapboxDocumentationResource } from './mapbox-documentation-resource/MapboxDocumentationResource.js'; +import { PreviewStyleUIResource } from './ui-apps/PreviewStyleUIResource.js'; +import { StyleComparisonUIResource } from './ui-apps/StyleComparisonUIResource.js'; +import { GeojsonPreviewUIResource } from './ui-apps/GeojsonPreviewUIResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; // Central registry of all resources @@ -14,7 +17,11 @@ export const ALL_RESOURCES = [ new MapboxStreetsV8FieldsResource(), new MapboxTokenScopesResource(), new MapboxLayerTypeMappingResource(), - new MapboxDocumentationResource({ httpRequest }) + new MapboxDocumentationResource({ httpRequest }), + // MCP Apps UI resources (ui:// scheme) + new PreviewStyleUIResource(), + new StyleComparisonUIResource(), + new GeojsonPreviewUIResource() ] as const; export type ResourceInstance = (typeof ALL_RESOURCES)[number]; diff --git a/src/resources/ui-apps/GeojsonPreviewUIResource.ts b/src/resources/ui-apps/GeojsonPreviewUIResource.ts new file mode 100644 index 0000000..06716d7 --- /dev/null +++ b/src/resources/ui-apps/GeojsonPreviewUIResource.ts @@ -0,0 +1,119 @@ +// 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 { BaseResource } from '../BaseResource.js'; + +/** + * Serves UI App HTML for GeoJSON Preview + * Implements MCP Apps pattern with ui:// scheme + */ +export class GeojsonPreviewUIResource extends BaseResource { + readonly name = 'GeoJSON Preview UI'; + readonly uri = 'ui://mapbox/geojson-preview/*'; + readonly description = + 'Interactive UI for previewing GeoJSON data (MCP Apps)'; + readonly mimeType = 'text/html'; + + public async readCallback( + uri: URL, + _extra: RequestHandlerExtra + ): Promise { + // Extract content hash from URI path + // Format: ui://mapbox/geojson-preview/{contentHash} + const pathParts = uri.pathname.split('/').filter((p) => p); + const contentHash = pathParts[2]; + + if (!contentHash) { + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'text/plain', + text: 'Error: Invalid URI format. Expected ui://mapbox/geojson-preview/{contentHash}' + } + ] + }; + } + + // Generate HTML with embedded iframe for GeoJSON visualization + const html = ` + + + + + GeoJSON Preview + + + +
Loading GeoJSON preview...
+ + + + +`; + + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'text/html', + text: html + } + ] + }; + } +} diff --git a/src/resources/ui-apps/PreviewStyleUIResource.ts b/src/resources/ui-apps/PreviewStyleUIResource.ts new file mode 100644 index 0000000..6e67e87 --- /dev/null +++ b/src/resources/ui-apps/PreviewStyleUIResource.ts @@ -0,0 +1,135 @@ +// 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 { BaseResource } from '../BaseResource.js'; + +/** + * Serves UI App HTML for Mapbox Style Preview + * Implements MCP Apps pattern with ui:// scheme + */ +export class PreviewStyleUIResource extends BaseResource { + readonly name = 'Mapbox Style Preview UI'; + readonly uri = 'ui://mapbox/preview-style/*'; + readonly description = + 'Interactive UI for previewing Mapbox styles (MCP Apps)'; + readonly mimeType = 'text/html'; + + public async readCallback( + uri: URL, + _extra: RequestHandlerExtra + ): Promise { + // Extract username and styleId from URI path + // Format: ui://mapbox/preview-style/{username}/{styleId} + const pathParts = uri.pathname.split('/').filter((p) => p); + const username = pathParts[2]; + const styleId = pathParts[3]; + + if (!username || !styleId) { + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'text/plain', + text: 'Error: Invalid URI format. Expected ui://mapbox/preview-style/{username}/{styleId}' + } + ] + }; + } + + // Generate HTML with embedded iframe and MCP Apps SDK + // The iframe URL will be constructed client-side based on parameters + const html = ` + + + + + Mapbox Style Preview + + + +
Loading preview...
+ + + + +`; + + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'text/html', + text: html + } + ] + }; + } +} diff --git a/src/resources/ui-apps/StyleComparisonUIResource.ts b/src/resources/ui-apps/StyleComparisonUIResource.ts new file mode 100644 index 0000000..9a9bb31 --- /dev/null +++ b/src/resources/ui-apps/StyleComparisonUIResource.ts @@ -0,0 +1,121 @@ +// 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 { BaseResource } from '../BaseResource.js'; + +/** + * Serves UI App HTML for Mapbox Style Comparison + * Implements MCP Apps pattern with ui:// scheme + */ +export class StyleComparisonUIResource extends BaseResource { + readonly name = 'Mapbox Style Comparison UI'; + readonly uri = 'ui://mapbox/style-comparison/*'; + readonly description = + 'Interactive UI for comparing Mapbox styles side-by-side (MCP Apps)'; + readonly mimeType = 'text/html'; + + public async readCallback( + uri: URL, + _extra: RequestHandlerExtra + ): Promise { + // Extract before and after styles from URI path + // Format: ui://mapbox/style-comparison/{beforeStyle}/{afterStyle} + const pathParts = uri.pathname.split('/').filter((p) => p); + const beforeStyle = pathParts[2]; + const afterStyle = pathParts[3]; + + if (!beforeStyle || !afterStyle) { + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'text/plain', + text: 'Error: Invalid URI format. Expected ui://mapbox/style-comparison/{beforeStyle}/{afterStyle}' + } + ] + }; + } + + // Generate HTML with embedded iframe for style comparison + const html = ` + + + + + Mapbox Style Comparison + + + +
Loading comparison...
+ + + + +`; + + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'text/html', + text: html + } + ] + }; + } +} diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts index c8ecf6f..e140848 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts @@ -157,10 +157,29 @@ export class GeojsonPreviewTool extends BaseTool { } } - return { - isError: false, - content + // Add MCP Apps metadata (new pattern for broader client compatibility) + const result: CallToolResult = { + content, + isError: false }; + + // Add ui:// resource URI for MCP Apps pattern + // This works alongside MCP-UI for backward compatibility + if (isMcpUiEnabled()) { + // Create content-addressable URI using hash of GeoJSON + const contentHash = createHash('md5') + .update(geojsonString) + .digest('hex') + .substring(0, 16); + + result._meta = { + ui: { + resourceUri: `ui://mapbox/geojson-preview/${contentHash}` + } + }; + } + + return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index cf028cf..b5e317a 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -89,9 +89,22 @@ export class PreviewStyleTool extends BaseTool { content.push(uiResource); } - return { + // Add MCP Apps metadata (new pattern for broader client compatibility) + const result: CallToolResult = { content, isError: false }; + + // Add ui:// resource URI for MCP Apps pattern + // This works alongside MCP-UI for backward compatibility + if (isMcpUiEnabled()) { + result._meta = { + ui: { + resourceUri: `ui://mapbox/preview-style/${userName}/${input.styleId}` + } + }; + } + + return result; } } diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index f9ce238..1f88099 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -125,9 +125,22 @@ export class StyleComparisonTool extends BaseTool< content.push(uiResource); } - return { + // Add MCP Apps metadata (new pattern for broader client compatibility) + const result: CallToolResult = { content, isError: false }; + + // Add ui:// resource URI for MCP Apps pattern + // This works alongside MCP-UI for backward compatibility + if (isMcpUiEnabled()) { + result._meta = { + ui: { + resourceUri: `ui://mapbox/style-comparison/${beforeStyleId}/${afterStyleId}` + } + }; + } + + return result; } } From 634eff2ed76019356aa467a4dccd1f34131cf325 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 28 Jan 2026 10:22:21 -0500 Subject: [PATCH 2/7] Add descriptive text content to MCP-UI tools for better client compatibility Similar to mcp-server PR #104, enhance text content in tool responses to follow progressive enhancement pattern for better MCP spec compliance. Changes: - preview_style_tool: Add descriptive metadata (style ID, options, URL) - style_comparison_tool: Add comparison metadata (before/after styles, view position, URL) - geojson_preview_tool: Add GeoJSON metadata (type, feature count, geometry types, URL) - Update tests to verify new descriptive text format This ensures all MCP clients (including text-only clients) can display meaningful information, not just URLs. The text content includes: - Success confirmation - Key metadata about the operation - The preview/comparison URL Text content appears as first element (progressive enhancement): 1. Text description (for all clients) 2. MCP-UI resource (for MCP-UI clients) 3. MCP Apps metadata (for MCP Apps clients) Related: mcp-server#104 All tests passing (520 tests). --- .../GeojsonPreviewTool.ts | 32 +++++++++++++- .../preview-style-tool/PreviewStyleTool.ts | 18 +++++++- .../StyleComparisonTool.ts | 20 ++++++++- .../GeojsonPreviewTool.test.ts | 42 +++++++++++++++---- 4 files changed, 98 insertions(+), 14 deletions(-) diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts index e140848..1ba3632 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts @@ -105,11 +105,39 @@ export class GeojsonPreviewTool extends BaseTool { const encodedGeoJSON = encodeURIComponent(geojsonString); const geojsonIOUrl = `https://geojson.io/#data=data:application/json,${encodedGeoJSON}`; - // Build content array with URL + // Extract GeoJSON metadata for descriptive text + const geojsonType = (geojsonData as { type: string }).type; + let featureInfo = ''; + if (geojsonType === 'FeatureCollection') { + const fc = geojsonData as { + features: Array<{ geometry: { type: string } }>; + }; + const featureCount = fc.features.length; + const geometryTypes = [ + ...new Set(fc.features.map((f) => f.geometry.type)) + ]; + featureInfo = `Features: ${featureCount} (${geometryTypes.join(', ')})`; + } else if (geojsonType === 'Feature') { + const feature = geojsonData as { geometry: { type: string } }; + featureInfo = `Geometry: ${feature.geometry.type}`; + } else { + featureInfo = `Geometry: ${geojsonType}`; + } + + // Build descriptive text with GeoJSON metadata for better client compatibility + // This ensures all MCP clients can display meaningful information + const textDescription = [ + 'GeoJSON preview generated successfully.', + `Type: ${geojsonType}`, + featureInfo, + `Preview URL: ${geojsonIOUrl}` + ].join('\n'); + + // Build content array with text first (for compatibility) const content: CallToolResult['content'] = [ { type: 'text', - text: geojsonIOUrl + text: textDescription } ]; diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index b5e317a..7a1efe4 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -65,11 +65,25 @@ export class PreviewStyleTool extends BaseTool { const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${userName}/${input.styleId}.html?${params.toString()}${hashFragment}`; - // Build content array with URL + // Build descriptive text with map metadata for better client compatibility + // This ensures all MCP clients can display meaningful information + const textDescription = [ + 'Mapbox style preview generated successfully.', + `Style: ${userName}/${input.styleId}`, + `Preview URL: ${url}`, + input.title !== undefined ? `Title display: ${input.title}` : null, + input.zoomwheel !== undefined + ? `Zoom control: ${input.zoomwheel ? 'enabled' : 'disabled'}` + : null + ] + .filter(Boolean) + .join('\n'); + + // Build content array with text first (for compatibility) const content: CallToolResult['content'] = [ { type: 'text', - text: url + text: textDescription } ]; diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index 1f88099..0e29b83 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -101,11 +101,27 @@ export class StyleComparisonTool extends BaseTool< url += `#${input.zoom}/${input.latitude}/${input.longitude}`; } - // Build content array with URL + // Build descriptive text with comparison metadata for better client compatibility + // This ensures all MCP clients can display meaningful information + const textDescription = [ + 'Mapbox style comparison generated successfully.', + `Before: ${beforeStyleId}`, + `After: ${afterStyleId}`, + input.zoom !== undefined && + input.latitude !== undefined && + input.longitude !== undefined + ? `View: ${input.latitude}, ${input.longitude} @ zoom ${input.zoom}` + : null, + `Comparison URL: ${url}` + ] + .filter(Boolean) + .join('\n'); + + // Build content array with text first (for compatibility) const content: CallToolResult['content'] = [ { type: 'text', - text: url + text: textDescription } ]; diff --git a/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts b/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts index b2cddf3..2b6ce52 100644 --- a/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts +++ b/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts @@ -40,8 +40,14 @@ describe('GeojsonPreviewTool', () => { expect(result.content[0].type).toBe('text'); const content = result.content[0]; if (content.type === 'text') { - expect(content.text).toMatch( - /^https:\/\/geojson\.io\/#data=data:application\/json,/ + // Verify descriptive text includes metadata + expect(content.text).toContain('GeoJSON preview generated successfully'); + expect(content.text).toContain('Type: Point'); + expect(content.text).toContain('Geometry: Point'); + expect(content.text).toContain('Preview URL:'); + // Verify URL is present in the text + expect(content.text).toContain( + 'https://geojson.io/#data=data:application/json,' ); expect(content.text).toContain( encodeURIComponent(JSON.stringify(pointGeoJSON)) @@ -104,8 +110,14 @@ describe('GeojsonPreviewTool', () => { expect(result.isError).toBe(false); const content = result.content[0]; if (content.type === 'text') { - expect(content.text).toMatch( - /^https:\/\/geojson\.io\/#data=data:application\/json,/ + // Verify descriptive text includes metadata + expect(content.text).toContain('GeoJSON preview generated successfully'); + expect(content.text).toContain('Type: Feature'); + expect(content.text).toContain('Geometry: Point'); + expect(content.text).toContain('Preview URL:'); + // Verify URL is present in the text + expect(content.text).toContain( + 'https://geojson.io/#data=data:application/json,' ); expect(content.text).toContain(encodeURIComponent(geoJSONString)); } @@ -142,8 +154,16 @@ describe('GeojsonPreviewTool', () => { expect(result.isError).toBe(false); const content = result.content[0]; if (content.type === 'text') { - expect(content.text).toMatch( - /^https:\/\/geojson\.io\/#data=data:application\/json,/ + // Verify descriptive text includes metadata + expect(content.text).toContain('GeoJSON preview generated successfully'); + expect(content.text).toContain('Type: FeatureCollection'); + expect(content.text).toContain('Features: 2'); + expect(content.text).toContain('Point'); + expect(content.text).toContain('LineString'); + expect(content.text).toContain('Preview URL:'); + // Verify URL is present in the text + expect(content.text).toContain( + 'https://geojson.io/#data=data:application/json,' ); expect(content.text).toContain( encodeURIComponent(JSON.stringify(featureCollection)) @@ -197,8 +217,14 @@ describe('GeojsonPreviewTool', () => { expect(result.isError).toBe(false); const content = result.content[0]; if (content.type === 'text') { - expect(content.text).toMatch( - /^https:\/\/geojson\.io\/#data=data:application\/json,/ + // Verify descriptive text includes metadata + expect(content.text).toContain('GeoJSON preview generated successfully'); + expect(content.text).toContain('Type: Feature'); + expect(content.text).toContain('Geometry: Point'); + expect(content.text).toContain('Preview URL:'); + // Verify URL is present in the text + expect(content.text).toContain( + 'https://geojson.io/#data=data:application/json,' ); // Verify URL contains properly encoded content expect(content.text).toContain('%22'); // Encoded quotes From 30a3ba0705a010783abde1df7b07113302030535 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 28 Jan 2026 10:27:06 -0500 Subject: [PATCH 3/7] Revert text description enhancements - keep URLs simple The enhanced text descriptions were not appropriate for these tools. Unlike static_map_image_tool (PR #104) which returns an IMAGE, these tools return URLs that should be opened by the user. The URL itself is the primary actionable content, and adding metadata made it less clear. LLMs can understand and use URLs directly. Keeping text content simple: - preview_style_tool: Returns URL only - style_comparison_tool: Returns URL only - geojson_preview_tool: Returns URL only All tests passing (520 tests). --- .../GeojsonPreviewTool.ts | 32 +------------- .../preview-style-tool/PreviewStyleTool.ts | 18 +------- .../StyleComparisonTool.ts | 20 +-------- .../GeojsonPreviewTool.test.ts | 42 ++++--------------- 4 files changed, 14 insertions(+), 98 deletions(-) diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts index 1ba3632..e140848 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts @@ -105,39 +105,11 @@ export class GeojsonPreviewTool extends BaseTool { const encodedGeoJSON = encodeURIComponent(geojsonString); const geojsonIOUrl = `https://geojson.io/#data=data:application/json,${encodedGeoJSON}`; - // Extract GeoJSON metadata for descriptive text - const geojsonType = (geojsonData as { type: string }).type; - let featureInfo = ''; - if (geojsonType === 'FeatureCollection') { - const fc = geojsonData as { - features: Array<{ geometry: { type: string } }>; - }; - const featureCount = fc.features.length; - const geometryTypes = [ - ...new Set(fc.features.map((f) => f.geometry.type)) - ]; - featureInfo = `Features: ${featureCount} (${geometryTypes.join(', ')})`; - } else if (geojsonType === 'Feature') { - const feature = geojsonData as { geometry: { type: string } }; - featureInfo = `Geometry: ${feature.geometry.type}`; - } else { - featureInfo = `Geometry: ${geojsonType}`; - } - - // Build descriptive text with GeoJSON metadata for better client compatibility - // This ensures all MCP clients can display meaningful information - const textDescription = [ - 'GeoJSON preview generated successfully.', - `Type: ${geojsonType}`, - featureInfo, - `Preview URL: ${geojsonIOUrl}` - ].join('\n'); - - // Build content array with text first (for compatibility) + // Build content array with URL const content: CallToolResult['content'] = [ { type: 'text', - text: textDescription + text: geojsonIOUrl } ]; diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index 7a1efe4..b5e317a 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -65,25 +65,11 @@ export class PreviewStyleTool extends BaseTool { const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${userName}/${input.styleId}.html?${params.toString()}${hashFragment}`; - // Build descriptive text with map metadata for better client compatibility - // This ensures all MCP clients can display meaningful information - const textDescription = [ - 'Mapbox style preview generated successfully.', - `Style: ${userName}/${input.styleId}`, - `Preview URL: ${url}`, - input.title !== undefined ? `Title display: ${input.title}` : null, - input.zoomwheel !== undefined - ? `Zoom control: ${input.zoomwheel ? 'enabled' : 'disabled'}` - : null - ] - .filter(Boolean) - .join('\n'); - - // Build content array with text first (for compatibility) + // Build content array with URL const content: CallToolResult['content'] = [ { type: 'text', - text: textDescription + text: url } ]; diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index 0e29b83..1f88099 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -101,27 +101,11 @@ export class StyleComparisonTool extends BaseTool< url += `#${input.zoom}/${input.latitude}/${input.longitude}`; } - // Build descriptive text with comparison metadata for better client compatibility - // This ensures all MCP clients can display meaningful information - const textDescription = [ - 'Mapbox style comparison generated successfully.', - `Before: ${beforeStyleId}`, - `After: ${afterStyleId}`, - input.zoom !== undefined && - input.latitude !== undefined && - input.longitude !== undefined - ? `View: ${input.latitude}, ${input.longitude} @ zoom ${input.zoom}` - : null, - `Comparison URL: ${url}` - ] - .filter(Boolean) - .join('\n'); - - // Build content array with text first (for compatibility) + // Build content array with URL const content: CallToolResult['content'] = [ { type: 'text', - text: textDescription + text: url } ]; diff --git a/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts b/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts index 2b6ce52..b2cddf3 100644 --- a/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts +++ b/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts @@ -40,14 +40,8 @@ describe('GeojsonPreviewTool', () => { expect(result.content[0].type).toBe('text'); const content = result.content[0]; if (content.type === 'text') { - // Verify descriptive text includes metadata - expect(content.text).toContain('GeoJSON preview generated successfully'); - expect(content.text).toContain('Type: Point'); - expect(content.text).toContain('Geometry: Point'); - expect(content.text).toContain('Preview URL:'); - // Verify URL is present in the text - expect(content.text).toContain( - 'https://geojson.io/#data=data:application/json,' + expect(content.text).toMatch( + /^https:\/\/geojson\.io\/#data=data:application\/json,/ ); expect(content.text).toContain( encodeURIComponent(JSON.stringify(pointGeoJSON)) @@ -110,14 +104,8 @@ describe('GeojsonPreviewTool', () => { expect(result.isError).toBe(false); const content = result.content[0]; if (content.type === 'text') { - // Verify descriptive text includes metadata - expect(content.text).toContain('GeoJSON preview generated successfully'); - expect(content.text).toContain('Type: Feature'); - expect(content.text).toContain('Geometry: Point'); - expect(content.text).toContain('Preview URL:'); - // Verify URL is present in the text - expect(content.text).toContain( - 'https://geojson.io/#data=data:application/json,' + expect(content.text).toMatch( + /^https:\/\/geojson\.io\/#data=data:application\/json,/ ); expect(content.text).toContain(encodeURIComponent(geoJSONString)); } @@ -154,16 +142,8 @@ describe('GeojsonPreviewTool', () => { expect(result.isError).toBe(false); const content = result.content[0]; if (content.type === 'text') { - // Verify descriptive text includes metadata - expect(content.text).toContain('GeoJSON preview generated successfully'); - expect(content.text).toContain('Type: FeatureCollection'); - expect(content.text).toContain('Features: 2'); - expect(content.text).toContain('Point'); - expect(content.text).toContain('LineString'); - expect(content.text).toContain('Preview URL:'); - // Verify URL is present in the text - expect(content.text).toContain( - 'https://geojson.io/#data=data:application/json,' + expect(content.text).toMatch( + /^https:\/\/geojson\.io\/#data=data:application\/json,/ ); expect(content.text).toContain( encodeURIComponent(JSON.stringify(featureCollection)) @@ -217,14 +197,8 @@ describe('GeojsonPreviewTool', () => { expect(result.isError).toBe(false); const content = result.content[0]; if (content.type === 'text') { - // Verify descriptive text includes metadata - expect(content.text).toContain('GeoJSON preview generated successfully'); - expect(content.text).toContain('Type: Feature'); - expect(content.text).toContain('Geometry: Point'); - expect(content.text).toContain('Preview URL:'); - // Verify URL is present in the text - expect(content.text).toContain( - 'https://geojson.io/#data=data:application/json,' + expect(content.text).toMatch( + /^https:\/\/geojson\.io\/#data=data:application\/json,/ ); // Verify URL contains properly encoded content expect(content.text).toContain('%22'); // Encoded quotes From ca1a49521aae40d4eafb9922c20fb3f0651fcbd7 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 28 Jan 2026 11:10:40 -0500 Subject: [PATCH 4/7] Implement MCP Apps SDK integration for UI resources Properly integrate @modelcontextprotocol/ext-apps SDK in all UI resources: - PreviewStyleUIResource: Use App.onContextUpdate() to receive tool result - StyleComparisonUIResource: Extract URL from tool result and display in iframe - GeojsonPreviewUIResource: Connect to host and render preview dynamically Implementation follows MCP Apps quickstart pattern: 1. Import App SDK from esm.sh CDN 2. Connect to MCP host 3. Use onContextUpdate() to receive tool result containing the URL 4. Extract text content and set as iframe src The tool result structure is: { content: [ { type: 'text', text: 'https://...' }, // URL to display { type: 'resource', ... } // MCP-UI resource (if enabled) ] } All tests passing (520 tests). --- .../ui-apps/GeojsonPreviewUIResource.ts | 46 ++++++++------- .../ui-apps/PreviewStyleUIResource.ts | 56 ++++++++----------- .../ui-apps/StyleComparisonUIResource.ts | 47 +++++++++------- 3 files changed, 76 insertions(+), 73 deletions(-) diff --git a/src/resources/ui-apps/GeojsonPreviewUIResource.ts b/src/resources/ui-apps/GeojsonPreviewUIResource.ts index 06716d7..8f8db3d 100644 --- a/src/resources/ui-apps/GeojsonPreviewUIResource.ts +++ b/src/resources/ui-apps/GeojsonPreviewUIResource.ts @@ -79,29 +79,35 @@ export class GeojsonPreviewUIResource extends BaseResource { `; diff --git a/src/resources/ui-apps/PreviewStyleUIResource.ts b/src/resources/ui-apps/PreviewStyleUIResource.ts index 6e67e87..b08a73c 100644 --- a/src/resources/ui-apps/PreviewStyleUIResource.ts +++ b/src/resources/ui-apps/PreviewStyleUIResource.ts @@ -81,43 +81,35 @@ export class PreviewStyleUIResource extends BaseResource { `; diff --git a/src/resources/ui-apps/StyleComparisonUIResource.ts b/src/resources/ui-apps/StyleComparisonUIResource.ts index 9a9bb31..69f1158 100644 --- a/src/resources/ui-apps/StyleComparisonUIResource.ts +++ b/src/resources/ui-apps/StyleComparisonUIResource.ts @@ -80,30 +80,35 @@ export class StyleComparisonUIResource extends BaseResource { `; From 62fbbf8eaaca76c78075eb262990e753c33e2f36 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 28 Jan 2026 17:18:03 -0500 Subject: [PATCH 5/7] Add MCP Apps support for visualization tools Implements MCP Apps pattern (2026-01-26 spec) for three visualization tools: - geojson_preview_tool - Displays GeoJSON on maps - preview_style_tool - Previews Mapbox styles - style_comparison_tool - Compares two styles side-by-side Key changes: - Added @modelcontextprotocol/ext-apps dependency - Register UI resources with registerAppResource() using RESOURCE_MIME_TYPE - Include CSP metadata in resource contents to allow external domains - Add meta.ui.resourceUri to tool schemas for MCP Apps integration - Maintain backward compatibility by returning both URL and MCP-UI resource Host compatibility: - Goose: Full support (all tools work with CSP configuration) - Claude Desktop & VS Code: Partial (CSP not yet implemented, iframes blocked) CSP configuration allows https://*.mapbox.com domains for: - connectDomains (fetch/XHR) - resourceDomains (images, tiles) - frameDomains (embedded maps) Tests updated to reflect dual-content return (URL + MCP-UI resource). Co-Authored-By: Claude Sonnet 4.5 --- package-lock.json | 427 +++++++++++++++++- package.json | 1 + src/index.ts | 29 +- .../ui-apps/GeojsonPreviewUIResource.ts | 210 +++++++-- .../ui-apps/PreviewStyleUIResource.ts | 165 ++++--- .../ui-apps/StyleComparisonUIResource.ts | 164 ++++--- src/tools/BaseTool.ts | 32 +- .../GeojsonPreviewTool.ts | 105 ++--- .../get-reference-tool/GetReferenceTool.ts | 8 +- .../preview-style-tool/PreviewStyleTool.ts | 55 +-- .../StyleComparisonTool.ts | 55 +-- .../GeojsonPreviewTool.test.ts | 44 +- .../PreviewStyleTool.test.ts | 15 +- .../StyleComparisonTool.test.ts | 13 +- 14 files changed, 993 insertions(+), 330 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8477ac3..dbbaea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@mcp-ui/server": "^5.13.1", + "@modelcontextprotocol/ext-apps": "^1.0.1", "@modelcontextprotocol/sdk": "^1.17.5", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.56.0", @@ -1530,6 +1531,18 @@ "node": ">=6" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, @@ -1780,13 +1793,136 @@ "@modelcontextprotocol/sdk": "*" } }, + "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/ext-apps/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", + "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@modelcontextprotocol/ext-apps/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", + "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@modelcontextprotocol/ext-apps/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", + "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@modelcontextprotocol/ext-apps/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", + "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@modelcontextprotocol/ext-apps/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", + "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/ext-apps/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", + "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.5.tgz", - "integrity": "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg==", + "version": "1.25.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz", + "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==", "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -1794,15 +1930,51 @@ "eventsource-parser": "^3.0.0", "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.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -5633,6 +5805,149 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@oven/bun-darwin-aarch64": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.7.tgz", + "integrity": "sha512-Mh78f4B+vNTOhFpI7RWHRWDqSKTnFXj/MauRx7I/GmNwEfw56sUx98gWRwXyF4lkW+9VNU+33wuw6E+M22W66w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.7.tgz", + "integrity": "sha512-dFfKdSVz6Ois5zjEJboUC7igcYAVd+c//ajotd0L6WUQAKQrHMVq/+6LjOj/0zjC6VPFNGWzeF8erymNo1y0Jw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64-baseline": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.7.tgz", + "integrity": "sha512-bUND1aQoTCfIL+idALT7FWtuX59ltOIRo954c7p/JkESbSIJ01jY06BSNVbkGk8RQM19v/7qiqZZqi4NyO4Utw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-linux-aarch64": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.7.tgz", + "integrity": "sha512-m03OtzEs+/RkWtk6tBf8yw0GW4P8ajfzTXnTt984tQBgkMubGQYUyUnFasWgr3mD2820LhkVjhYeBf1rkz/biQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-aarch64-musl": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.7.tgz", + "integrity": "sha512-QDxrROdUnC1d/uoilXtUeFHaLhYdRN7dRIzw/Iqj/vrrhnkA6VS+HYoCWtyyVvci/K+JrPmDwxOWlSRpmV4INA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.7.tgz", + "integrity": "sha512-uttKQ/eIRVGc4uBtLRqmQqXGf57/dmQaF0AEd37RQNRRRd1P/VYnFMiMcVaot3HJ6IFjHjGtcPO9ekT49LxBYQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-baseline": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.7.tgz", + "integrity": "sha512-Jlb/AcrIFU3QDeR3EL4UVT1CIKqnLJDgbU+R0k/+NaSWMrBEpZV+gJJT5L1cmEKTNhU/d+c7hudxkjtqA7XXqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.7.tgz", + "integrity": "sha512-aK8fvkCosrHRG3CNdVqMom1C8Rj3XkqZp0ZFSBXgaXlKP22RkxlEE9tS7OmSq9yVgEk6euTB3dW4NFo/jlXqeg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl-baseline": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.7.tgz", + "integrity": "sha512-lySQQ7zJJsoa5hQH+PE5bQyQaTI8G2Erszhu4iQuDtsocwy3zSxjB6TxGWTd4HmetPl9aRvg3nb2KR8RVAd7ug==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.7.tgz", + "integrity": "sha512-3QdIGdSn3fkssCq/vPjtPLAQxo+eMUzcwJedn1c5mXDy1AoisjhoxhWnbVl8+uk+wt9N6JUPdISoe0N4OdwXfg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.7.tgz", + "integrity": "sha512-wMgELfW5vFceh4qEOYb5iV5TjrjjnBJzE383ixA3kqGKzaubksSxNc11eZhS0ptcJ5a0UjN5hfbMh6sYoh+cRQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -6676,6 +6991,7 @@ }, "node_modules/ajv": { "version": "6.12.6", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -6688,6 +7004,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "dev": true, @@ -8493,6 +8848,7 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -8500,6 +8856,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "dev": true, @@ -9090,6 +9462,16 @@ "node": ">=0.10.0" } }, + "node_modules/hono": { + "version": "4.11.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" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "dev": true, @@ -9641,6 +10023,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "dev": true, @@ -9684,8 +10075,15 @@ }, "node_modules/json-schema-traverse": { "version": "0.4.1", + "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "dev": true, @@ -11084,6 +11482,7 @@ }, "node_modules/punycode": { "version": "2.3.1", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11187,6 +11586,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-in-the-middle": { "version": "7.5.2", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", @@ -12469,6 +12877,7 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -12959,10 +13368,12 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.24.6", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.25 || ^4" } } } diff --git a/package.json b/package.json index 2565412..07120d8 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ ], "dependencies": { "@mcp-ui/server": "^5.13.1", + "@modelcontextprotocol/ext-apps": "^1.0.1", "@modelcontextprotocol/sdk": "^1.17.5", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.56.0", diff --git a/src/index.ts b/src/index.ts index e0d6150..5808df6 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 { z } from 'zod'; import { parseToolConfigFromArgs, filterTools } from './config/toolConfig.js'; import { getAllTools } from './tools/toolRegistry.js'; @@ -73,13 +77,36 @@ const server = new McpServer( ); // Register enabled tools to the server +// All tools use regular registration (BaseTool.installTo already includes _meta) enabledTools.forEach((tool) => { tool.installTo(server); }); // Register resources to the server const resources = getAllResources(); -resources.forEach((resource) => { + +// Separate MCP Apps UI resources from regular resources +const uiResources = resources.filter((r) => r.uri.startsWith('ui://')); +const regularResources = resources.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( + 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.readCallback(new URL(resource.uri), {} as any); + } + ); +}); + +// Register regular resources using standard registration +regularResources.forEach((resource) => { resource.installTo(server); }); diff --git a/src/resources/ui-apps/GeojsonPreviewUIResource.ts b/src/resources/ui-apps/GeojsonPreviewUIResource.ts index 8f8db3d..67210e2 100644 --- a/src/resources/ui-apps/GeojsonPreviewUIResource.ts +++ b/src/resources/ui-apps/GeojsonPreviewUIResource.ts @@ -7,6 +7,7 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; import { BaseResource } from '../BaseResource.js'; /** @@ -15,32 +16,15 @@ import { BaseResource } from '../BaseResource.js'; */ export class GeojsonPreviewUIResource extends BaseResource { readonly name = 'GeoJSON Preview UI'; - readonly uri = 'ui://mapbox/geojson-preview/*'; + readonly uri = 'ui://mapbox/geojson-preview/index.html'; readonly description = 'Interactive UI for previewing GeoJSON data (MCP Apps)'; - readonly mimeType = 'text/html'; + readonly mimeType = RESOURCE_MIME_TYPE; public async readCallback( - uri: URL, + _uri: URL, _extra: RequestHandlerExtra ): Promise { - // Extract content hash from URI path - // Format: ui://mapbox/geojson-preview/{contentHash} - const pathParts = uri.pathname.split('/').filter((p) => p); - const contentHash = pathParts[2]; - - if (!contentHash) { - return { - contents: [ - { - uri: uri.toString(), - mimeType: 'text/plain', - text: 'Error: Invalid URI format. Expected ui://mapbox/geojson-preview/{contentHash}' - } - ] - }; - } - // Generate HTML with embedded iframe for GeoJSON visualization const html = ` @@ -58,11 +42,11 @@ export class GeojsonPreviewUIResource extends BaseResource { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; overflow: hidden; } - #preview-frame { - width: 100vw; - height: 100vh; - border: none; + #preview-image { + max-width: 100%; + max-height: 100vh; display: block; + margin: 0 auto; } #loading { position: absolute; @@ -72,42 +56,162 @@ export class GeojsonPreviewUIResource extends BaseResource { text-align: center; color: #666; } + #error { + padding: 20px; + color: #cc0000; + text-align: center; + }
Loading GeoJSON preview...
- + + `; @@ -115,9 +219,17 @@ export class GeojsonPreviewUIResource extends BaseResource { return { contents: [ { - uri: uri.toString(), - mimeType: 'text/html', - text: html + 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/resources/ui-apps/PreviewStyleUIResource.ts b/src/resources/ui-apps/PreviewStyleUIResource.ts index b08a73c..61d176a 100644 --- a/src/resources/ui-apps/PreviewStyleUIResource.ts +++ b/src/resources/ui-apps/PreviewStyleUIResource.ts @@ -7,6 +7,7 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; import { BaseResource } from '../BaseResource.js'; /** @@ -15,35 +16,15 @@ import { BaseResource } from '../BaseResource.js'; */ export class PreviewStyleUIResource extends BaseResource { readonly name = 'Mapbox Style Preview UI'; - readonly uri = 'ui://mapbox/preview-style/*'; + readonly uri = 'ui://mapbox/preview-style/index.html'; readonly description = 'Interactive UI for previewing Mapbox styles (MCP Apps)'; - readonly mimeType = 'text/html'; + readonly mimeType = RESOURCE_MIME_TYPE; public async readCallback( - uri: URL, + _uri: URL, _extra: RequestHandlerExtra ): Promise { - // Extract username and styleId from URI path - // Format: ui://mapbox/preview-style/{username}/{styleId} - const pathParts = uri.pathname.split('/').filter((p) => p); - const username = pathParts[2]; - const styleId = pathParts[3]; - - if (!username || !styleId) { - return { - contents: [ - { - uri: uri.toString(), - mimeType: 'text/plain', - text: 'Error: Invalid URI format. Expected ui://mapbox/preview-style/{username}/{styleId}' - } - ] - }; - } - - // Generate HTML with embedded iframe and MCP Apps SDK - // The iframe URL will be constructed client-side based on parameters const html = ` @@ -60,11 +41,11 @@ export class PreviewStyleUIResource extends BaseResource { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; overflow: hidden; } - #preview-frame { - width: 100vw; + #preview-iframe { + width: 100%; height: 100vh; border: none; - display: block; + display: none; } #loading { position: absolute; @@ -74,42 +55,113 @@ export class PreviewStyleUIResource extends BaseResource { text-align: center; color: #666; } + #error { + padding: 20px; + color: #cc0000; + text-align: center; + } -
Loading preview...
- +
Loading style preview...
+ + `; @@ -117,9 +169,18 @@ export class PreviewStyleUIResource extends BaseResource { return { contents: [ { - uri: uri.toString(), - mimeType: 'text/html', - text: html + uri: this.uri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + connectDomains: ['https://*.mapbox.com'], + resourceDomains: ['https://*.mapbox.com'], + frameDomains: ['https://api.mapbox.com'] + } + } + } } ] }; diff --git a/src/resources/ui-apps/StyleComparisonUIResource.ts b/src/resources/ui-apps/StyleComparisonUIResource.ts index 69f1158..0618a78 100644 --- a/src/resources/ui-apps/StyleComparisonUIResource.ts +++ b/src/resources/ui-apps/StyleComparisonUIResource.ts @@ -7,6 +7,7 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; import { BaseResource } from '../BaseResource.js'; /** @@ -15,34 +16,15 @@ import { BaseResource } from '../BaseResource.js'; */ export class StyleComparisonUIResource extends BaseResource { readonly name = 'Mapbox Style Comparison UI'; - readonly uri = 'ui://mapbox/style-comparison/*'; + readonly uri = 'ui://mapbox/style-comparison/index.html'; readonly description = 'Interactive UI for comparing Mapbox styles side-by-side (MCP Apps)'; - readonly mimeType = 'text/html'; + readonly mimeType = RESOURCE_MIME_TYPE; public async readCallback( - uri: URL, + _uri: URL, _extra: RequestHandlerExtra ): Promise { - // Extract before and after styles from URI path - // Format: ui://mapbox/style-comparison/{beforeStyle}/{afterStyle} - const pathParts = uri.pathname.split('/').filter((p) => p); - const beforeStyle = pathParts[2]; - const afterStyle = pathParts[3]; - - if (!beforeStyle || !afterStyle) { - return { - contents: [ - { - uri: uri.toString(), - mimeType: 'text/plain', - text: 'Error: Invalid URI format. Expected ui://mapbox/style-comparison/{beforeStyle}/{afterStyle}' - } - ] - }; - } - - // Generate HTML with embedded iframe for style comparison const html = ` @@ -59,11 +41,11 @@ export class StyleComparisonUIResource extends BaseResource { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; overflow: hidden; } - #comparison-frame { - width: 100vw; + #comparison-iframe { + width: 100%; height: 100vh; border: none; - display: block; + display: none; } #loading { position: absolute; @@ -73,42 +55,113 @@ export class StyleComparisonUIResource extends BaseResource { text-align: center; color: #666; } + #error { + padding: 20px; + color: #cc0000; + text-align: center; + } -
Loading comparison...
- +
Loading style comparison...
+ + `; @@ -116,9 +169,18 @@ export class StyleComparisonUIResource extends BaseResource { return { contents: [ { - uri: uri.toString(), - mimeType: 'text/html', - text: html + uri: this.uri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + connectDomains: ['https://*.mapbox.com'], + resourceDomains: ['https://*.mapbox.com'], + frameDomains: ['https://agent.mapbox.com'] + } + } + } } ] }; diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index d6d9dd6..e91b367 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: { @@ -74,6 +84,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, @@ -91,8 +111,16 @@ export abstract class BaseTool< (this.outputSchema as unknown as z.ZodObject).shape; } - return server.registerTool(this.name, config, (args, extra) => - this.run(args, extra) + // Add _meta for MCP Apps support if provided (includes CSP configuration) + if (this.meta) { + config._meta = this.meta; + } + + return server.registerTool( + this.name, + config, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (args: any, extra: any) => this.run(args, extra) ); } diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts index e140848..f8c66dd 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts @@ -10,7 +10,6 @@ import { GeojsonPreviewSchema, GeojsonPreviewInput } from './GeojsonPreviewTool.input.schema.js'; -import { isMcpUiEnabled } from '../../config/toolConfig.js'; export class GeojsonPreviewTool extends BaseTool { name = 'geojson_preview_tool'; @@ -24,6 +23,16 @@ export class GeojsonPreviewTool extends BaseTool { title: 'Preview GeoJSON Data Tool' }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/geojson-preview/index.html', + csp: { + connectDomains: ['https://api.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; + constructor() { super({ inputSchema: GeojsonPreviewSchema }); } @@ -63,7 +72,6 @@ export class GeojsonPreviewTool extends BaseTool { } // Create a simplified GeoJSON for the overlay - // The Static API requires specific format for GeoJSON overlays const geojsonString = JSON.stringify(geojsonData); const encodedGeoJSON = encodeURIComponent(geojsonString); @@ -105,81 +113,48 @@ export class GeojsonPreviewTool extends BaseTool { const encodedGeoJSON = encodeURIComponent(geojsonString); const geojsonIOUrl = `https://geojson.io/#data=data:application/json,${encodedGeoJSON}`; + // Try to generate a Mapbox Static Image URL + // The MCP App will fetch this and convert to blob URL to work with CSP + const staticImageUrl = this.generateStaticImageUrl(geojsonData); + + // Use static image URL if available (MCP App will handle CSP via blob URL), + // otherwise fall back to geojson.io + const displayUrl = staticImageUrl || geojsonIOUrl; + // Build content array with URL const content: CallToolResult['content'] = [ { type: 'text', - text: geojsonIOUrl + text: displayUrl } ]; - // Conditionally add MCP-UI resource if enabled - if (isMcpUiEnabled()) { - // Create content-addressable URI using hash of GeoJSON - // This enables client-side caching - same GeoJSON = same URI - const contentHash = createHash('md5') - .update(geojsonString) - .digest('hex') - .substring(0, 16); // Use first 16 chars for brevity - - // Try to generate a Mapbox Static Image URL - const staticImageUrl = this.generateStaticImageUrl(geojsonData); - - if (staticImageUrl) { - // Use Mapbox Static Images API - embeds as an image - const uiResource = createUIResource({ - uri: `ui://mapbox/geojson-preview/${contentHash}`, - content: { - type: 'externalUrl', - iframeUrl: staticImageUrl - }, - encoding: 'text', - uiMetadata: { - 'preferred-frame-size': ['1000px', '700px'] - } - }); - content.push(uiResource); - } else { - // Fallback to geojson.io URL (for large GeoJSON or when no token) - // Note: geojson.io may not work in iframes due to X-Frame-Options - const uiResource = createUIResource({ - uri: `ui://mapbox/geojson-preview/${contentHash}`, - content: { - type: 'externalUrl', - iframeUrl: geojsonIOUrl - }, - encoding: 'text', - uiMetadata: { - 'preferred-frame-size': ['1000px', '700px'] - } - }); - content.push(uiResource); + // Add MCP-UI resource (for legacy MCP-UI clients) + // Create content-addressable URI using hash of GeoJSON + // This enables client-side caching - same GeoJSON = same URI + const contentHash = createHash('md5') + .update(geojsonString) + .digest('hex') + .substring(0, 16); // Use first 16 chars for brevity + + // Use the same URL for MCP-UI as we returned in text content + const uiResource = createUIResource({ + uri: `ui://mapbox/geojson-preview/${contentHash}`, + content: { + type: 'externalUrl', + iframeUrl: displayUrl + }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['1000px', '700px'] } - } + }); + content.push(uiResource); - // Add MCP Apps metadata (new pattern for broader client compatibility) - const result: CallToolResult = { + return { content, isError: false }; - - // Add ui:// resource URI for MCP Apps pattern - // This works alongside MCP-UI for backward compatibility - if (isMcpUiEnabled()) { - // Create content-addressable URI using hash of GeoJSON - const contentHash = createHash('md5') - .update(geojsonString) - .digest('hex') - .substring(0, 16); - - result._meta = { - ui: { - resourceUri: `ui://mapbox/geojson-preview/${contentHash}` - } - }; - } - - return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; diff --git a/src/tools/get-reference-tool/GetReferenceTool.ts b/src/tools/get-reference-tool/GetReferenceTool.ts index bfd01b9..a0d9133 100644 --- a/src/tools/get-reference-tool/GetReferenceTool.ts +++ b/src/tools/get-reference-tool/GetReferenceTool.ts @@ -65,11 +65,17 @@ export class GetReferenceTool extends BaseTool { }; } + const firstContent = result.contents[0]; + const text = + 'text' in firstContent + ? firstContent.text + : 'No text content available'; + return { content: [ { type: 'text', - text: result.contents[0].text as string + text } ], isError: false diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index b5e317a..e42cbe0 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -7,7 +7,6 @@ import { PreviewStyleInput } from './PreviewStyleTool.input.schema.js'; import { getUserNameFromToken } from '../../utils/jwtUtils.js'; -import { isMcpUiEnabled } from '../../config/toolConfig.js'; export class PreviewStyleTool extends BaseTool { readonly name = 'preview_style_tool'; @@ -21,6 +20,17 @@ export class PreviewStyleTool extends BaseTool { title: 'Preview Mapbox Style Tool' }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/preview-style/index.html', + csp: { + connectDomains: ['https://*.mapbox.com'], + resourceDomains: ['https://*.mapbox.com'], + frameDomains: ['https://*.mapbox.com'] + } + } + }; + constructor() { super({ inputSchema: PreviewStyleSchema }); } @@ -73,38 +83,23 @@ export class PreviewStyleTool extends BaseTool { } ]; - // Conditionally add MCP-UI resource if enabled - if (isMcpUiEnabled()) { - const uiResource = createUIResource({ - uri: `ui://mapbox/preview-style/${userName}/${input.styleId}`, - content: { - type: 'externalUrl', - iframeUrl: url - }, - encoding: 'text', - uiMetadata: { - 'preferred-frame-size': ['1000px', '700px'] - } - }); - content.push(uiResource); - } + // Add MCP-UI resource (for legacy MCP-UI clients) + const uiResource = createUIResource({ + uri: `ui://mapbox/preview-style/${userName}/${input.styleId}`, + content: { + type: 'externalUrl', + iframeUrl: url + }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['1000px', '700px'] + } + }); + content.push(uiResource); - // Add MCP Apps metadata (new pattern for broader client compatibility) - const result: CallToolResult = { + return { content, isError: false }; - - // Add ui:// resource URI for MCP Apps pattern - // This works alongside MCP-UI for backward compatibility - if (isMcpUiEnabled()) { - result._meta = { - ui: { - resourceUri: `ui://mapbox/preview-style/${userName}/${input.styleId}` - } - }; - } - - return result; } } diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index 1f88099..2edf613 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -9,7 +9,6 @@ import { StyleComparisonInput } from './StyleComparisonTool.schema.js'; import { getUserNameFromToken } from '../../utils/jwtUtils.js'; -import { isMcpUiEnabled } from '../../config/toolConfig.js'; export class StyleComparisonTool extends BaseTool< typeof StyleComparisonSchema @@ -25,6 +24,17 @@ export class StyleComparisonTool extends BaseTool< title: 'Compare Mapbox Styles Tool' }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/style-comparison/index.html', + csp: { + connectDomains: ['https://*.mapbox.com'], + resourceDomains: ['https://*.mapbox.com'], + frameDomains: ['https://*.mapbox.com'] + } + } + }; + constructor() { super({ inputSchema: StyleComparisonSchema }); } @@ -109,38 +119,23 @@ export class StyleComparisonTool extends BaseTool< } ]; - // Conditionally add MCP-UI resource if enabled - if (isMcpUiEnabled()) { - const uiResource = createUIResource({ - uri: `ui://mapbox/style-comparison/${beforeStyleId}/${afterStyleId}`, - content: { - type: 'externalUrl', - iframeUrl: url - }, - encoding: 'text', - uiMetadata: { - 'preferred-frame-size': ['1000px', '700px'] - } - }); - content.push(uiResource); - } + // Add MCP-UI resource (for legacy MCP-UI clients) + const uiResource = createUIResource({ + uri: `ui://mapbox/style-comparison/${beforeStyleId}/${afterStyleId}`, + content: { + type: 'externalUrl', + iframeUrl: url + }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['1000px', '700px'] + } + }); + content.push(uiResource); - // Add MCP Apps metadata (new pattern for broader client compatibility) - const result: CallToolResult = { + return { content, isError: false }; - - // Add ui:// resource URI for MCP Apps pattern - // This works alongside MCP-UI for backward compatibility - if (isMcpUiEnabled()) { - result._meta = { - ui: { - resourceUri: `ui://mapbox/style-comparison/${beforeStyleId}/${afterStyleId}` - } - }; - } - - return result; } } diff --git a/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts b/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts index b2cddf3..f504996 100644 --- a/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts +++ b/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts @@ -40,11 +40,9 @@ describe('GeojsonPreviewTool', () => { expect(result.content[0].type).toBe('text'); const content = result.content[0]; if (content.type === 'text') { + // Should return Mapbox Static Images URL or geojson.io fallback expect(content.text).toMatch( - /^https:\/\/geojson\.io\/#data=data:application\/json,/ - ); - expect(content.text).toContain( - encodeURIComponent(JSON.stringify(pointGeoJSON)) + /^https:\/\/(api\.mapbox\.com\/styles\/|geojson\.io)/ ); } @@ -67,10 +65,7 @@ describe('GeojsonPreviewTool', () => { ).toBe(true); }); - it('returns only URL when MCP-UI is disabled', async () => { - // Disable MCP-UI for this test - process.env.ENABLE_MCP_UI = 'false'; - + it('returns URL and MCP-UI resource for backward compatibility', async () => { const tool = new GeojsonPreviewTool(); const pointGeoJSON = { type: 'Point', @@ -80,11 +75,11 @@ describe('GeojsonPreviewTool', () => { const result = await tool.run({ geojson: JSON.stringify(pointGeoJSON) }); expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); + // Now returns both URL and MCP-UI resource + expect(result.content).toHaveLength(2); expect(result.content[0].type).toBe('text'); - - // Clean up - delete process.env.ENABLE_MCP_UI; + // Second item is MCP-UI resource + expect(result.content[1].type).toBe('resource'); }); it('should handle GeoJSON as string', async () => { @@ -102,12 +97,14 @@ describe('GeojsonPreviewTool', () => { const result = await tool.run({ geojson: geoJSONString }); expect(result.isError).toBe(false); + // Now returns both URL and MCP-UI resource + expect(result.content).toHaveLength(2); const content = result.content[0]; if (content.type === 'text') { + // Should return Mapbox Static Images URL or geojson.io fallback expect(content.text).toMatch( - /^https:\/\/geojson\.io\/#data=data:application\/json,/ + /^https:\/\/(api\.mapbox\.com\/styles\/|geojson\.io)/ ); - expect(content.text).toContain(encodeURIComponent(geoJSONString)); } }); @@ -140,13 +137,13 @@ describe('GeojsonPreviewTool', () => { }); expect(result.isError).toBe(false); + // Now returns both URL and MCP-UI resource + expect(result.content).toHaveLength(2); const content = result.content[0]; if (content.type === 'text') { + // Should return Mapbox Static Images URL or geojson.io fallback expect(content.text).toMatch( - /^https:\/\/geojson\.io\/#data=data:application\/json,/ - ); - expect(content.text).toContain( - encodeURIComponent(JSON.stringify(featureCollection)) + /^https:\/\/(api\.mapbox\.com\/styles\/|geojson\.io)/ ); } }); @@ -195,16 +192,13 @@ describe('GeojsonPreviewTool', () => { const result = await tool.run({ geojson: JSON.stringify(geoJSON) }); expect(result.isError).toBe(false); + // Now returns both URL and MCP-UI resource + expect(result.content).toHaveLength(2); const content = result.content[0]; if (content.type === 'text') { + // Should return Mapbox Static Images URL or geojson.io fallback expect(content.text).toMatch( - /^https:\/\/geojson\.io\/#data=data:application\/json,/ - ); - // Verify URL contains properly encoded content - expect(content.text).toContain('%22'); // Encoded quotes - expect(content.text).toContain('%26'); // Encoded ampersand - expect(content.text).toContain( - encodeURIComponent('Test & Special Characters!') + /^https:\/\/(api\.mapbox\.com\/styles\/|geojson\.io)/ ); } }); diff --git a/test/tools/preview-style-tool/PreviewStyleTool.test.ts b/test/tools/preview-style-tool/PreviewStyleTool.test.ts index e8315c0..87feef8 100644 --- a/test/tools/preview-style-tool/PreviewStyleTool.test.ts +++ b/test/tools/preview-style-tool/PreviewStyleTool.test.ts @@ -172,10 +172,7 @@ describe('PreviewStyleTool', () => { }); }); - it('returns only URL when MCP-UI is disabled', async () => { - // Disable MCP-UI for this test - process.env.ENABLE_MCP_UI = 'false'; - + it('returns URL and MCP-UI resource for backward compatibility', async () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', accessToken: TEST_ACCESS_TOKEN, @@ -184,15 +181,17 @@ describe('PreviewStyleTool', () => { }); expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); + // Now returns both URL (for text) and MCP-UI resource (for backward compat) + expect(result.content).toHaveLength(2); expect(result.content[0]).toMatchObject({ type: 'text', text: expect.stringContaining( 'https://api.mapbox.com/styles/v1/test-user/test-style.html?access_token=pk.' ) }); - - // Clean up - delete process.env.ENABLE_MCP_UI; + // Second item is MCP-UI resource + expect(result.content[1]).toMatchObject({ + type: 'resource' + }); }); }); diff --git a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts index b90ecfa..ee7013a 100644 --- a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts +++ b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -214,10 +214,7 @@ describe('StyleComparisonTool', () => { expect(url2).not.toContain('#'); }); - it('should return only URL when MCP-UI is disabled', async () => { - // Disable MCP-UI for this test - process.env.ENABLE_MCP_UI = 'false'; - + it('should return URL and MCP-UI resource for backward compatibility', async () => { const input = { before: 'mapbox/streets-v12', after: 'mapbox/outdoors-v12', @@ -227,11 +224,11 @@ describe('StyleComparisonTool', () => { const result = await tool.run(input); expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); + // Now returns both URL (for text) and MCP-UI resource (for backward compat) + expect(result.content).toHaveLength(2); expect(result.content[0].type).toBe('text'); - - // Clean up - delete process.env.ENABLE_MCP_UI; + // Second item is MCP-UI resource + expect(result.content[1].type).toBe('resource'); }); }); From 96f6fc1b7ff7f9ed03670b72df1386b413f504d9 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 28 Jan 2026 17:47:50 -0500 Subject: [PATCH 6/7] Switch GeoJSON preview to light basemap for better visibility Changes mapbox/streets-v12 to mapbox/light-v11 for cleaner GeoJSON visualization. The @2x retina flag was already in place for high-resolution displays. Co-Authored-By: Claude Sonnet 4.5 --- src/tools/geojson-preview-tool/GeojsonPreviewTool.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts index f8c66dd..02f0e22 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts @@ -75,10 +75,10 @@ export class GeojsonPreviewTool extends BaseTool { const geojsonString = JSON.stringify(geojsonData); const encodedGeoJSON = encodeURIComponent(geojsonString); - // Use Mapbox Streets style with auto-bounds fitting + // Use Mapbox Light basemap style with auto-bounds fitting and retina display (@2x) // Format: /styles/v1/{username}/{style_id}/static/geojson({geojson})/auto/{width}x{height}@2x const staticImageUrl = - `https://api.mapbox.com/styles/v1/mapbox/streets-v12/static/` + + `https://api.mapbox.com/styles/v1/mapbox/light-v11/static/` + `geojson(${encodedGeoJSON})/auto/1000x700@2x` + `?access_token=${accessToken}`; From 8ea0d78d7a36d5f38233fa8e2033b72493392b0b Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 2 Feb 2026 11:19:38 -0500 Subject: [PATCH 7/7] Enhance GeoJSON preview 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) - Add smooth hover effects and transitions - Matches enhancements made to mcp-server static_map_image_tool Co-Authored-By: Claude Sonnet 4.5 --- .../ui-apps/GeojsonPreviewUIResource.ts | 88 +++++++++++++++++-- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/src/resources/ui-apps/GeojsonPreviewUIResource.ts b/src/resources/ui-apps/GeojsonPreviewUIResource.ts index 67210e2..197c3f5 100644 --- a/src/resources/ui-apps/GeojsonPreviewUIResource.ts +++ b/src/resources/ui-apps/GeojsonPreviewUIResource.ts @@ -41,12 +41,52 @@ export class GeojsonPreviewUIResource 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; @@ -54,18 +94,26 @@ export class GeojsonPreviewUIResource 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 GeoJSON preview...
- +
+ GeoJSON Preview +
+
Click to view full size