From 23b90d01a66de1f01274744a637a9496578cde67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Romuald=20Cz=C5=82onkowski?= <56956555+czlonkowski@users.noreply.github.com> Date: Sat, 7 Feb 2026 05:16:15 +0100 Subject: [PATCH] fix: align MCP Apps with official ext-apps spec (#574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add MCP Apps with rich HTML UIs for tool results Add MCP Apps infrastructure that allows MCP hosts like Claude Desktop to render rich HTML UIs alongside tool results via `_meta.ui` and the MCP resources protocol. - Server-side UI module (src/mcp/ui/) with UIAppRegistry, tool-to-UI mapping, and _meta.ui injection into tool responses - React + Vite build pipeline (ui-apps/) producing self-contained HTML per app using vite-plugin-singlefile - Operation Result UI for workflow CRUD tools (create, update, delete, test, autofix, deploy) - Validation Summary UI for validation tools (validate_node, validate_workflow, n8n_validate_workflow) - Shared component library (Card, Badge, Expandable) with n8n dark theme - MCP resources protocol support (ListResources, ReadResource handlers) - Graceful degradation when ui-apps/dist/ is not built - 22 unit tests across 3 test files Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 * test: improve MCP Apps test coverage and add security hardening - Expand test suite from 22 to 57 tests across 3 test files - Add UIAppRegistry.reset() for proper test isolation between tests - Replace some fs mocks with real temp directory tests in registry - Add edge case coverage: empty strings, pre-load state, double load, malformed URIs, duplicate tool patterns, empty HTML files - Add regression tests for specific tool-to-UI mappings - Add URI format consistency validation across all configs - Improve _meta.ui injection tests with structuredContent coexistence - Coverage: statements 79.4% -> 80%, lines 79.4% -> 80% Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 * fix: align MCP Apps with official ext-apps spec Update URI scheme from n8n-mcp://ui/ to ui://n8n-mcp/ per MCP spec. Move _meta.ui.resourceUri to tool definitions (tools/list) instead of tool call responses. Rewrite UI apps hook to use @modelcontextprotocol/ext-apps App class instead of window.__MCP_DATA__. Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 15 ++ package.json | 2 +- src/mcp/server.ts | 11 +- src/mcp/ui/app-configs.ts | 4 +- src/mcp/ui/registry.ts | 15 ++ src/mcp/ui/types.ts | 2 +- src/types/index.ts | 5 + tests/unit/mcp/ui/app-configs.test.ts | 8 +- tests/unit/mcp/ui/meta-injection.test.ts | 211 ++++++++--------------- tests/unit/mcp/ui/registry.test.ts | 69 ++++++++ ui-apps/package.json | 1 + ui-apps/src/shared/hooks/useToolData.ts | 42 +++-- 12 files changed, 210 insertions(+), 175 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e788aa..740ef9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.34.1] - 2026-02-07 + +### Changed + +- **MCP Apps: Align with official ext-apps spec** for Claude Desktop/web compatibility + - URI scheme changed from `n8n-mcp://ui/{id}` to `ui://n8n-mcp/{id}` per MCP ext-apps spec + - `_meta.ui.resourceUri` now set on tool definitions (`tools/list`) instead of tool call responses + - `UIMetadata.ui.app` renamed to `UIMetadata.ui.resourceUri` + - Added `_meta` field to `ToolDefinition` type + - Added `UIAppRegistry.injectToolMeta()` method for enriching tool definitions + - UI apps now use `@modelcontextprotocol/ext-apps` `App` class instead of `window.__MCP_DATA__` + - Updated `ReadResource` URI parser to match new `ui://` scheme + +Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en + ## [2.34.0] - 2026-02-07 ### Added diff --git a/package.json b/package.json index 4e20ab4..9c01500 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.34.0", + "version": "2.34.1", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5fb9430..f35df2b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -651,6 +651,7 @@ export class N8NDocumentationMCPServer { }); }); + UIAppRegistry.injectToolMeta(tools); return { tools }; }); @@ -781,12 +782,6 @@ export class N8NDocumentationMCPServer { mcpResponse.structuredContent = structuredContent; } - // Inject UI app metadata if available - const uiApp = UIAppRegistry.getAppForTool(name); - if (uiApp && uiApp.html) { - mcpResponse._meta = { ui: { app: uiApp.config.uri } }; - } - return mcpResponse; } catch (error) { logger.error(`Error executing tool ${name}`, error); @@ -857,8 +852,8 @@ export class N8NDocumentationMCPServer { // Handle ReadResource for UI apps this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; - // Parse n8n-mcp://ui/{id} pattern - const match = uri.match(/^n8n-mcp:\/\/ui\/(.+)$/); + // Parse ui://n8n-mcp/{id} pattern + const match = uri.match(/^ui:\/\/n8n-mcp\/(.+)$/); if (!match) { throw new Error(`Unknown resource URI: ${uri}`); } diff --git a/src/mcp/ui/app-configs.ts b/src/mcp/ui/app-configs.ts index dfa2054..71e11fd 100644 --- a/src/mcp/ui/app-configs.ts +++ b/src/mcp/ui/app-configs.ts @@ -5,7 +5,7 @@ export const UI_APP_CONFIGS: UIAppConfig[] = [ id: 'operation-result', displayName: 'Operation Result', description: 'Visual summary of workflow operations (create, update, delete, test)', - uri: 'n8n-mcp://ui/operation-result', + uri: 'ui://n8n-mcp/operation-result', mimeType: 'text/html', toolPatterns: [ 'n8n_create_workflow', @@ -21,7 +21,7 @@ export const UI_APP_CONFIGS: UIAppConfig[] = [ id: 'validation-summary', displayName: 'Validation Summary', description: 'Visual summary of node and workflow validation results', - uri: 'n8n-mcp://ui/validation-summary', + uri: 'ui://n8n-mcp/validation-summary', mimeType: 'text/html', toolPatterns: [ 'validate_node', diff --git a/src/mcp/ui/registry.ts b/src/mcp/ui/registry.ts index 4bab257..846891b 100644 --- a/src/mcp/ui/registry.ts +++ b/src/mcp/ui/registry.ts @@ -60,6 +60,21 @@ export class UIAppRegistry { return Array.from(this.entries.values()); } + /** + * Enrich tool definitions with _meta.ui.resourceUri for tools that have + * a matching UI app. Per MCP ext-apps spec, this goes on the tool + * definition (tools/list), not the tool call response. + */ + static injectToolMeta(tools: Array<{ name: string; [key: string]: any }>): void { + if (!this.loaded) return; + for (const tool of tools) { + const entry = this.toolIndex.get(tool.name); + if (entry && entry.html) { + tool._meta = { ui: { resourceUri: entry.config.uri } }; + } + } + } + /** Reset registry state. Intended for testing only. */ static reset(): void { this.entries.clear(); diff --git a/src/mcp/ui/types.ts b/src/mcp/ui/types.ts index f19aa8c..204f598 100644 --- a/src/mcp/ui/types.ts +++ b/src/mcp/ui/types.ts @@ -13,7 +13,7 @@ export interface UIAppConfig { export interface UIMetadata { ui: { - app: string; + resourceUri: string; }; } diff --git a/src/types/index.ts b/src/types/index.ts index c7c4d22..56f11f1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -44,6 +44,11 @@ export interface ToolDefinition { }; /** Tool behavior hints for AI assistants */ annotations?: ToolAnnotations; + _meta?: { + ui?: { + resourceUri?: string; + }; + }; } export interface ResourceDefinition { diff --git a/tests/unit/mcp/ui/app-configs.test.ts b/tests/unit/mcp/ui/app-configs.test.ts index 7bec87f..e17a401 100644 --- a/tests/unit/mcp/ui/app-configs.test.ts +++ b/tests/unit/mcp/ui/app-configs.test.ts @@ -27,9 +27,9 @@ describe('UI_APP_CONFIGS', () => { } }); - it('should have URIs following n8n-mcp://ui/{id} pattern', () => { + it('should have URIs following ui://n8n-mcp/{id} pattern', () => { for (const config of UI_APP_CONFIGS) { - expect(config.uri).toBe(`n8n-mcp://ui/${config.id}`); + expect(config.uri).toBe(`ui://n8n-mcp/${config.id}`); } }); @@ -71,9 +71,9 @@ describe('UI_APP_CONFIGS', () => { } }); - it('should have URIs that start with the n8n-mcp://ui/ scheme', () => { + it('should have URIs that start with the ui://n8n-mcp/ scheme', () => { for (const config of UI_APP_CONFIGS) { - expect(config.uri).toMatch(/^n8n-mcp:\/\/ui\//); + expect(config.uri).toMatch(/^ui:\/\/n8n-mcp\//); } }); diff --git a/tests/unit/mcp/ui/meta-injection.test.ts b/tests/unit/mcp/ui/meta-injection.test.ts index ea7da96..54956ee 100644 --- a/tests/unit/mcp/ui/meta-injection.test.ts +++ b/tests/unit/mcp/ui/meta-injection.test.ts @@ -11,7 +11,7 @@ import { existsSync, readFileSync } from 'fs'; const mockExistsSync = vi.mocked(existsSync); const mockReadFileSync = vi.mocked(readFileSync); -describe('UI Meta Injection Logic', () => { +describe('UI Meta Injection on Tool Definitions', () => { beforeEach(() => { vi.clearAllMocks(); UIAppRegistry.reset(); @@ -24,74 +24,69 @@ describe('UI Meta Injection Logic', () => { UIAppRegistry.load(); }); - it('should add _meta.ui for matching tools', () => { - const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow'); - expect(uiApp).not.toBeNull(); - expect(uiApp!.html).not.toBeNull(); + it('should add _meta.ui.resourceUri to matching tool definitions', () => { + const tools: any[] = [ + { name: 'n8n_create_workflow', description: 'Create workflow', inputSchema: { type: 'object', properties: {} } }, + ]; - // Simulate the injection logic from server.ts - const mcpResponse: any = { - content: [{ type: 'text', text: 'result' }], - }; + UIAppRegistry.injectToolMeta(tools); - if (uiApp && uiApp.html) { - mcpResponse._meta = { ui: { app: uiApp.config.uri } }; - } - - expect(mcpResponse._meta).toBeDefined(); - expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result'); + expect(tools[0]._meta).toBeDefined(); + expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result'); }); - it('should add _meta.ui for validation tools', () => { - const uiApp = UIAppRegistry.getAppForTool('validate_workflow'); - expect(uiApp).not.toBeNull(); + it('should add _meta.ui.resourceUri to validation tool definitions', () => { + const tools: any[] = [ + { name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } }, + ]; - const mcpResponse: any = { - content: [{ type: 'text', text: 'validation result' }], - }; + UIAppRegistry.injectToolMeta(tools); - if (uiApp && uiApp.html) { - mcpResponse._meta = { ui: { app: uiApp.config.uri } }; - } - - expect(mcpResponse._meta).toBeDefined(); - expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/validation-summary'); + expect(tools[0]._meta).toBeDefined(); + expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary'); }); - it('should NOT add _meta.ui for non-matching tools', () => { - const uiApp = UIAppRegistry.getAppForTool('get_node_info'); - expect(uiApp).toBeNull(); + it('should NOT add _meta to non-matching tool definitions', () => { + const tools: any[] = [ + { name: 'get_node_info', description: 'Get info', inputSchema: { type: 'object', properties: {} } }, + ]; - const mcpResponse: any = { - content: [{ type: 'text', text: 'node info' }], - }; + UIAppRegistry.injectToolMeta(tools); - if (uiApp && uiApp.html) { - mcpResponse._meta = { ui: { app: uiApp.config.uri } }; - } - - expect(mcpResponse._meta).toBeUndefined(); + expect(tools[0]._meta).toBeUndefined(); }); - it('should produce _meta with exact shape { ui: { app: string } }', () => { - const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow')!; - const meta = { ui: { app: uiApp.config.uri } }; + it('should inject _meta on matching tools and skip non-matching in a mixed list', () => { + const tools: any[] = [ + { name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } }, + { name: 'get_node_info', description: 'Info', inputSchema: { type: 'object', properties: {} } }, + { name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } }, + ]; - expect(meta).toEqual({ + UIAppRegistry.injectToolMeta(tools); + + expect(tools[0]._meta).toBeDefined(); + expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result'); + expect(tools[1]._meta).toBeUndefined(); + expect(tools[2]._meta).toBeDefined(); + expect(tools[2]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary'); + }); + + it('should produce _meta with exact shape { ui: { resourceUri: string } }', () => { + const tools: any[] = [ + { name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } }, + ]; + + UIAppRegistry.injectToolMeta(tools); + + expect(tools[0]._meta).toEqual({ ui: { - app: 'n8n-mcp://ui/operation-result', + resourceUri: 'ui://n8n-mcp/operation-result', }, }); - expect(Object.keys(meta)).toEqual(['ui']); - expect(Object.keys(meta.ui)).toEqual(['app']); - expect(typeof meta.ui.app).toBe('string'); - }); - - it('should produce _meta.ui.app that matches the config uri', () => { - const uiApp = UIAppRegistry.getAppForTool('validate_node')!; - const meta = { ui: { app: uiApp.config.uri } }; - expect(meta.ui.app).toBe(uiApp.config.uri); - expect(meta.ui.app).toBe('n8n-mcp://ui/validation-summary'); + expect(Object.keys(tools[0]._meta)).toEqual(['ui']); + expect(Object.keys(tools[0]._meta.ui)).toEqual(['resourceUri']); + expect(typeof tools[0]._meta.ui.resourceUri).toBe('string'); }); }); @@ -101,114 +96,50 @@ describe('UI Meta Injection Logic', () => { UIAppRegistry.load(); }); - it('should NOT add _meta.ui even for matching tools', () => { - const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow'); - expect(uiApp).not.toBeNull(); - expect(uiApp!.html).toBeNull(); + it('should NOT add _meta even for matching tools', () => { + const tools: any[] = [ + { name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } }, + ]; - const mcpResponse: any = { - content: [{ type: 'text', text: 'result' }], - }; + UIAppRegistry.injectToolMeta(tools); - if (uiApp && uiApp.html) { - mcpResponse._meta = { ui: { app: uiApp.config.uri } }; - } - - expect(mcpResponse._meta).toBeUndefined(); + expect(tools[0]._meta).toBeUndefined(); }); - it('should NOT add _meta.ui for validation tools without HTML', () => { - const uiApp = UIAppRegistry.getAppForTool('validate_node'); - expect(uiApp).not.toBeNull(); - expect(uiApp!.html).toBeNull(); + it('should NOT add _meta for validation tools without HTML', () => { + const tools: any[] = [ + { name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } }, + ]; - const mcpResponse: any = { - content: [{ type: 'text', text: 'result' }], - }; + UIAppRegistry.injectToolMeta(tools); - if (uiApp && uiApp.html) { - mcpResponse._meta = { ui: { app: uiApp.config.uri } }; - } - - expect(mcpResponse._meta).toBeUndefined(); + expect(tools[0]._meta).toBeUndefined(); }); }); describe('when registry has not been loaded at all', () => { - it('should NOT add _meta because getAppForTool returns null', () => { - // Registry never loaded - reset() was called in beforeEach - const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow'); - expect(uiApp).toBeNull(); + it('should NOT add _meta because registry is not loaded', () => { + const tools: any[] = [ + { name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } }, + ]; - const mcpResponse: any = { - content: [{ type: 'text', text: 'result' }], - }; + UIAppRegistry.injectToolMeta(tools); - if (uiApp && uiApp.html) { - mcpResponse._meta = { ui: { app: uiApp.config.uri } }; - } - - expect(mcpResponse._meta).toBeUndefined(); + expect(tools[0]._meta).toBeUndefined(); }); }); - describe('coexistence with structuredContent', () => { + describe('empty tool list', () => { beforeEach(() => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue('ui'); UIAppRegistry.load(); }); - it('should coexist with structuredContent on the response', () => { - const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow'); - - const mcpResponse: any = { - content: [{ type: 'text', text: 'result' }], - structuredContent: { workflowId: '123', status: 'created' }, - }; - - if (uiApp && uiApp.html) { - mcpResponse._meta = { ui: { app: uiApp.config.uri } }; - } - - expect(mcpResponse.structuredContent).toBeDefined(); - expect(mcpResponse.structuredContent.workflowId).toBe('123'); - expect(mcpResponse._meta).toBeDefined(); - expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result'); - }); - - it('should not overwrite existing _meta properties when merging', () => { - const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow'); - - const mcpResponse: any = { - content: [{ type: 'text', text: 'result' }], - _meta: { existingProp: 'value' }, - }; - - if (uiApp && uiApp.html) { - mcpResponse._meta = { ...mcpResponse._meta, ui: { app: uiApp.config.uri } }; - } - - expect(mcpResponse._meta.existingProp).toBe('value'); - expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result'); - }); - - it('should work with responses that have both structuredContent and existing _meta', () => { - const uiApp = UIAppRegistry.getAppForTool('validate_workflow'); - - const mcpResponse: any = { - content: [{ type: 'text', text: 'validation ok' }], - structuredContent: { valid: true, errors: [] }, - _meta: { timing: 42 }, - }; - - if (uiApp && uiApp.html) { - mcpResponse._meta = { ...mcpResponse._meta, ui: { app: uiApp.config.uri } }; - } - - expect(mcpResponse.structuredContent.valid).toBe(true); - expect(mcpResponse._meta.timing).toBe(42); - expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/validation-summary'); + it('should handle an empty tools array without error', () => { + const tools: any[] = []; + UIAppRegistry.injectToolMeta(tools); + expect(tools.length).toBe(0); }); }); }); diff --git a/tests/unit/mcp/ui/registry.test.ts b/tests/unit/mcp/ui/registry.test.ts index a448261..7bb1cc8 100644 --- a/tests/unit/mcp/ui/registry.test.ts +++ b/tests/unit/mcp/ui/registry.test.ts @@ -287,6 +287,75 @@ describe('UIAppRegistry', () => { }); }); + describe('injectToolMeta()', () => { + it('should not modify tools before load() is called', () => { + const tools: any[] = [ + { name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } }, + ]; + UIAppRegistry.injectToolMeta(tools); + expect(tools[0]._meta).toBeUndefined(); + }); + + describe('after loading with HTML', () => { + beforeEach(() => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('loaded'); + UIAppRegistry.load(); + }); + + it('should set _meta.ui.resourceUri on matching operation tools', () => { + const tools: any[] = [ + { name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } }, + ]; + UIAppRegistry.injectToolMeta(tools); + expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/operation-result' } }); + }); + + it('should set _meta.ui.resourceUri on matching validation tools', () => { + const tools: any[] = [ + { name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } }, + ]; + UIAppRegistry.injectToolMeta(tools); + expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/validation-summary' } }); + }); + + it('should not set _meta on tools without a matching UI app', () => { + const tools: any[] = [ + { name: 'search_nodes', description: 'Search', inputSchema: { type: 'object', properties: {} } }, + ]; + UIAppRegistry.injectToolMeta(tools); + expect(tools[0]._meta).toBeUndefined(); + }); + + it('should handle a mix of matching and non-matching tools', () => { + const tools: any[] = [ + { name: 'n8n_delete_workflow', description: 'Delete', inputSchema: { type: 'object', properties: {} } }, + { name: 'get_node_essentials', description: 'Essentials', inputSchema: { type: 'object', properties: {} } }, + { name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } }, + ]; + UIAppRegistry.injectToolMeta(tools); + expect(tools[0]._meta?.ui?.resourceUri).toBe('ui://n8n-mcp/operation-result'); + expect(tools[1]._meta).toBeUndefined(); + expect(tools[2]._meta?.ui?.resourceUri).toBe('ui://n8n-mcp/validation-summary'); + }); + }); + + describe('after loading without HTML', () => { + beforeEach(() => { + mockExistsSync.mockReturnValue(false); + UIAppRegistry.load(); + }); + + it('should not set _meta when HTML is not available', () => { + const tools: any[] = [ + { name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } }, + ]; + UIAppRegistry.injectToolMeta(tools); + expect(tools[0]._meta).toBeUndefined(); + }); + }); + }); + describe('reset()', () => { it('should clear loaded state so getters return defaults', () => { mockExistsSync.mockReturnValue(true); diff --git a/ui-apps/package.json b/ui-apps/package.json index 92f61f9..f4600f8 100644 --- a/ui-apps/package.json +++ b/ui-apps/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.0.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/ui-apps/src/shared/hooks/useToolData.ts b/ui-apps/src/shared/hooks/useToolData.ts index 41ab208..d86332d 100644 --- a/ui-apps/src/shared/hooks/useToolData.ts +++ b/ui-apps/src/shared/hooks/useToolData.ts @@ -1,30 +1,34 @@ import { useState, useEffect } from 'react'; - -declare global { - interface Window { - __MCP_DATA__?: unknown; - } -} +import { App } from '@modelcontextprotocol/ext-apps'; export function useToolData(): T | null { const [data, setData] = useState(null); useEffect(() => { - // Try window.__MCP_DATA__ first (injected by host) - if (window.__MCP_DATA__) { - setData(window.__MCP_DATA__ as T); - return; - } + const app = new App(); - // Try embedded script tag - const scriptEl = document.getElementById('mcp-data'); - if (scriptEl?.textContent) { - try { - setData(JSON.parse(scriptEl.textContent) as T); - } catch { - // Ignore parse errors + app.ontoolresult = (result: any) => { + // The host pushes tool result content; parse the first text item as JSON + if (result?.content) { + const textItem = Array.isArray(result.content) + ? result.content.find((c: any) => c.type === 'text') + : null; + if (textItem?.text) { + try { + setData(JSON.parse(textItem.text) as T); + } catch { + // Not JSON — use raw text as-is + setData(textItem.text as unknown as T); + } + } } - } + }; + + app.connect(); + + return () => { + app.close(); + }; }, []); return data;