@mcp-web/app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/create-app.d.ts +160 -0
- package/dist/create-app.d.ts.map +1 -0
- package/dist/create-app.js +94 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +70 -0
- package/dist/internal.d.ts +12 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +10 -0
- package/dist/mcp-app-context.d.ts +122 -0
- package/dist/mcp-app-context.d.ts.map +1 -0
- package/dist/mcp-app-context.js +140 -0
- package/dist/mcp-app-context.test.d.ts +2 -0
- package/dist/mcp-app-context.test.d.ts.map +1 -0
- package/dist/mcp-app-context.test.js +254 -0
- package/dist/render-mcp-app.d.ts +71 -0
- package/dist/render-mcp-app.d.ts.map +1 -0
- package/dist/render-mcp-app.js +87 -0
- package/dist/use-mcp-app-props.d.ts +116 -0
- package/dist/use-mcp-app-props.d.ts.map +1 -0
- package/dist/use-mcp-app-props.js +158 -0
- package/dist/vite-plugin.d.ts +123 -0
- package/dist/vite-plugin.d.ts.map +1 -0
- package/dist/vite-plugin.js +450 -0
- package/package.json +53 -0
- package/src/create-app.test.ts +181 -0
- package/src/create-app.ts +213 -0
- package/src/index.ts +87 -0
- package/src/internal.ts +12 -0
- package/src/mcp-app-context.test.tsx +323 -0
- package/src/mcp-app-context.tsx +183 -0
- package/src/render-mcp-app.tsx +141 -0
- package/src/use-mcp-app-props.ts +166 -0
- package/src/vite-plugin.test.ts +110 -0
- package/src/vite-plugin.ts +620 -0
- package/tsconfig.json +32 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { AppDefinitionSchema } from '@mcp-web/types';
|
|
2
|
+
import type { ComponentType } from 'react';
|
|
3
|
+
import type { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract props type from a React component.
|
|
7
|
+
*/
|
|
8
|
+
// biome-ignore lint/suspicious/noExplicitAny: Need any for component props extraction
|
|
9
|
+
type ComponentProps<T> = T extends ComponentType<infer P> ? P : any;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Configuration for creating an MCP App.
|
|
13
|
+
*
|
|
14
|
+
* An MCP App combines a tool (that AI can call) with a visual component
|
|
15
|
+
* (rendered in an iframe). The handler returns props that are passed to
|
|
16
|
+
* your React component via postMessage.
|
|
17
|
+
*
|
|
18
|
+
* The component's props type is used to ensure type safety - the handler
|
|
19
|
+
* must return props that match what the component expects.
|
|
20
|
+
*
|
|
21
|
+
* @template TComponent - React component type
|
|
22
|
+
* @template TInput - Zod schema type for tool input parameters
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* import { Statistics } from './components/Statistics';
|
|
27
|
+
*
|
|
28
|
+
* const statsApp = createApp({
|
|
29
|
+
* name: 'show_stats',
|
|
30
|
+
* description: 'Display statistics visualization',
|
|
31
|
+
* component: Statistics,
|
|
32
|
+
* handler: () => ({
|
|
33
|
+
* completionRate: 0.75,
|
|
34
|
+
* totalTasks: 100,
|
|
35
|
+
* }),
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export interface CreateAppConfig<
|
|
40
|
+
// biome-ignore lint/suspicious/noExplicitAny: Component can have any props
|
|
41
|
+
TComponent extends ComponentType<any> = ComponentType<any>,
|
|
42
|
+
TInput extends z.ZodObject | undefined = undefined,
|
|
43
|
+
> {
|
|
44
|
+
/** Unique name for the app (also used as tool name) */
|
|
45
|
+
name: string;
|
|
46
|
+
/** Description of what the app does (shown to AI) */
|
|
47
|
+
description: string;
|
|
48
|
+
/** React component to render - handler must return props matching this component */
|
|
49
|
+
component: TComponent;
|
|
50
|
+
/** Optional Zod schema for validating tool input */
|
|
51
|
+
inputSchema?: TInput;
|
|
52
|
+
/** Optional Zod schema for validating props output */
|
|
53
|
+
propsSchema?: z.ZodType<ComponentProps<TComponent>>;
|
|
54
|
+
/** Handler function that returns props for the component */
|
|
55
|
+
handler: TInput extends z.ZodObject
|
|
56
|
+
? (
|
|
57
|
+
input: z.infer<TInput>
|
|
58
|
+
) => ComponentProps<TComponent> | Promise<ComponentProps<TComponent>>
|
|
59
|
+
: () => ComponentProps<TComponent> | Promise<ComponentProps<TComponent>>;
|
|
60
|
+
/** URL to fetch the app HTML from (defaults to /mcp-web-apps/{kebab-case-name}.html) */
|
|
61
|
+
url?: string;
|
|
62
|
+
/** Resource URI for the app (defaults to ui://{name}/app.html) */
|
|
63
|
+
resourceUri?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A created app ready for registration with MCPWeb.
|
|
68
|
+
*
|
|
69
|
+
* Created apps are validated at creation time but not yet active.
|
|
70
|
+
* Register with `mcp.addApp(app)` or the `useMCPApps()` hook to make
|
|
71
|
+
* the tool available to AI.
|
|
72
|
+
*
|
|
73
|
+
* @template TComponent - React component type
|
|
74
|
+
* @template TInput - Zod schema type for tool input parameters
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* const statsApp = createApp({ ... });
|
|
79
|
+
*
|
|
80
|
+
* // Register directly
|
|
81
|
+
* mcp.addApp(statsApp);
|
|
82
|
+
*
|
|
83
|
+
* // Or via React hook
|
|
84
|
+
* useMCPApps(statsApp);
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export interface CreatedApp<
|
|
88
|
+
// biome-ignore lint/suspicious/noExplicitAny: Component can have any props
|
|
89
|
+
TComponent extends ComponentType<any> = ComponentType<any>,
|
|
90
|
+
TInput extends z.ZodObject | undefined = undefined,
|
|
91
|
+
> {
|
|
92
|
+
/** Marker to identify this as a created app */
|
|
93
|
+
readonly __brand: 'CreatedApp';
|
|
94
|
+
/** The app definition for registration */
|
|
95
|
+
readonly definition: {
|
|
96
|
+
name: string;
|
|
97
|
+
description: string;
|
|
98
|
+
component: TComponent;
|
|
99
|
+
inputSchema?: TInput;
|
|
100
|
+
propsSchema?: z.ZodType<ComponentProps<TComponent>>;
|
|
101
|
+
handler: CreateAppConfig<TComponent, TInput>['handler'];
|
|
102
|
+
url?: string;
|
|
103
|
+
resourceUri?: string;
|
|
104
|
+
};
|
|
105
|
+
/** The original config for type inference */
|
|
106
|
+
readonly config: CreateAppConfig<TComponent, TInput>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Creates an MCP App definition without registering it.
|
|
111
|
+
*
|
|
112
|
+
* MCP Apps combine a tool (that AI can call to get props) with a visual
|
|
113
|
+
* React component. When AI calls the tool, the handler returns props which
|
|
114
|
+
* are passed to the component via postMessage.
|
|
115
|
+
*
|
|
116
|
+
* The Vite plugin automatically generates entry files for your apps, so you
|
|
117
|
+
* only need to define the app configuration:
|
|
118
|
+
*
|
|
119
|
+
* ```typescript
|
|
120
|
+
* // src/mcp-apps.ts
|
|
121
|
+
* import { createApp } from '@mcp-web/app';
|
|
122
|
+
* import { Statistics } from './components/Statistics';
|
|
123
|
+
*
|
|
124
|
+
* export const statisticsApp = createApp({
|
|
125
|
+
* name: 'show_statistics',
|
|
126
|
+
* description: 'Display statistics visualization',
|
|
127
|
+
* component: Statistics,
|
|
128
|
+
* handler: () => ({
|
|
129
|
+
* completionRate: 0.75,
|
|
130
|
+
* totalTasks: 100,
|
|
131
|
+
* }),
|
|
132
|
+
* });
|
|
133
|
+
* ```
|
|
134
|
+
*
|
|
135
|
+
* @example With Input Schema
|
|
136
|
+
* ```typescript
|
|
137
|
+
* import { createApp } from '@mcp-web/app';
|
|
138
|
+
* import { z } from 'zod';
|
|
139
|
+
* import { Chart } from './components/Chart';
|
|
140
|
+
*
|
|
141
|
+
* export const chartApp = createApp({
|
|
142
|
+
* name: 'show_chart',
|
|
143
|
+
* description: 'Display a chart with the given data',
|
|
144
|
+
* component: Chart,
|
|
145
|
+
* inputSchema: z.object({
|
|
146
|
+
* chartType: z.enum(['bar', 'line', 'pie']).describe('Type of chart'),
|
|
147
|
+
* title: z.string().describe('Chart title'),
|
|
148
|
+
* }),
|
|
149
|
+
* handler: ({ chartType, title }) => ({
|
|
150
|
+
* chartType,
|
|
151
|
+
* title,
|
|
152
|
+
* data: getChartData(),
|
|
153
|
+
* }),
|
|
154
|
+
* });
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export function createApp<
|
|
158
|
+
// biome-ignore lint/suspicious/noExplicitAny: Component can have any props
|
|
159
|
+
TComponent extends ComponentType<any>,
|
|
160
|
+
TInput extends z.ZodObject | undefined = undefined,
|
|
161
|
+
>(
|
|
162
|
+
config: CreateAppConfig<TComponent, TInput>
|
|
163
|
+
): CreatedApp<TComponent, TInput> {
|
|
164
|
+
// Validate at creation time
|
|
165
|
+
const validationResult = AppDefinitionSchema.safeParse(config);
|
|
166
|
+
if (!validationResult.success) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Invalid app definition: ${validationResult.error.message}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
__brand: 'CreatedApp' as const,
|
|
174
|
+
definition: {
|
|
175
|
+
name: config.name,
|
|
176
|
+
description: config.description,
|
|
177
|
+
component: config.component,
|
|
178
|
+
inputSchema: config.inputSchema,
|
|
179
|
+
propsSchema: config.propsSchema,
|
|
180
|
+
handler: config.handler,
|
|
181
|
+
url: config.url,
|
|
182
|
+
resourceUri: config.resourceUri,
|
|
183
|
+
},
|
|
184
|
+
config,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Type guard to check if a value is a CreatedApp.
|
|
190
|
+
*
|
|
191
|
+
* Useful for validating values passed to `addApp()` or for runtime checks.
|
|
192
|
+
*
|
|
193
|
+
* @param value - The value to check
|
|
194
|
+
* @returns True if the value is a CreatedApp instance
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```typescript
|
|
198
|
+
* import { createApp, isCreatedApp } from '@mcp-web/app';
|
|
199
|
+
*
|
|
200
|
+
* const maybeApp = getAppFromSomewhere();
|
|
201
|
+
* if (isCreatedApp(maybeApp)) {
|
|
202
|
+
* mcp.addApp(maybeApp);
|
|
203
|
+
* }
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
export function isCreatedApp(value: unknown): value is CreatedApp {
|
|
207
|
+
return (
|
|
208
|
+
typeof value === 'object' &&
|
|
209
|
+
value !== null &&
|
|
210
|
+
'__brand' in value &&
|
|
211
|
+
(value as CreatedApp).__brand === 'CreatedApp'
|
|
212
|
+
);
|
|
213
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mcp-web/app - Build tooling for MCP Apps
|
|
3
|
+
*
|
|
4
|
+
* This package provides tools for creating MCP Apps - visual UI components
|
|
5
|
+
* that AI agents can render inline in chat interfaces like Claude Desktop.
|
|
6
|
+
*
|
|
7
|
+
* ## Main Exports
|
|
8
|
+
*
|
|
9
|
+
* - `createApp` - Define an app with a handler and component
|
|
10
|
+
*
|
|
11
|
+
* ## Vite Plugin (via `@mcp-web/app/vite`)
|
|
12
|
+
*
|
|
13
|
+
* - `defineMCPAppsConfig` - Create a Vite config for building MCP Apps
|
|
14
|
+
*
|
|
15
|
+
* ## Host Theming
|
|
16
|
+
*
|
|
17
|
+
* MCP Apps automatically inherit the host's theme. Use these hooks to access
|
|
18
|
+
* the host's theme and context in your components:
|
|
19
|
+
*
|
|
20
|
+
* - `useMCPHostTheme` - Get the current host theme (`"light"` or `"dark"`)
|
|
21
|
+
* - `useMCPHostContext` - Get the full host context (theme, display mode, locale, etc.)
|
|
22
|
+
*
|
|
23
|
+
* ## Advanced Exports
|
|
24
|
+
*
|
|
25
|
+
* - `renderMCPApp` - Manually render a component (auto-generated by Vite plugin)
|
|
26
|
+
* - `useMCPAppProps` - React hook for custom prop handling
|
|
27
|
+
* - `useMCPApp` - Access the ext-apps `App` instance
|
|
28
|
+
* - `MCPAppProvider` - Context provider (set up automatically by renderMCPApp)
|
|
29
|
+
*
|
|
30
|
+
* @example Define an MCP App (src/mcp-apps.ts)
|
|
31
|
+
* ```typescript
|
|
32
|
+
* import { createApp } from '@mcp-web/app';
|
|
33
|
+
* import { Statistics } from './components/Statistics';
|
|
34
|
+
*
|
|
35
|
+
* export const statsApp = createApp({
|
|
36
|
+
* name: 'show_stats',
|
|
37
|
+
* description: 'Display statistics visualization',
|
|
38
|
+
* component: Statistics,
|
|
39
|
+
* handler: () => ({
|
|
40
|
+
* completionRate: 0.75,
|
|
41
|
+
* totalTasks: 100,
|
|
42
|
+
* }),
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @example Build Config (vite.apps.config.ts)
|
|
47
|
+
* ```typescript
|
|
48
|
+
* import react from '@vitejs/plugin-react';
|
|
49
|
+
* import { defineMCPAppsConfig } from '@mcp-web/app/vite';
|
|
50
|
+
*
|
|
51
|
+
* // Automatically discovers apps in src/mcp-apps.ts
|
|
52
|
+
* export default defineMCPAppsConfig({
|
|
53
|
+
* plugins: [react()],
|
|
54
|
+
* });
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @example Register with MCPWeb (in your app)
|
|
58
|
+
* ```typescript
|
|
59
|
+
* import { statsApp } from './mcp-apps';
|
|
60
|
+
*
|
|
61
|
+
* mcp.addApp(statsApp);
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* @packageDocumentation
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
export { createApp, isCreatedApp } from './create-app.js';
|
|
68
|
+
export type { CreateAppConfig, CreatedApp } from './create-app.js';
|
|
69
|
+
|
|
70
|
+
export {
|
|
71
|
+
useMCPAppProps,
|
|
72
|
+
useMCPApp,
|
|
73
|
+
getMCPAppProps,
|
|
74
|
+
subscribeMCPAppProps,
|
|
75
|
+
} from './use-mcp-app-props.js';
|
|
76
|
+
|
|
77
|
+
export {
|
|
78
|
+
MCPAppProvider,
|
|
79
|
+
useMCPHostContext,
|
|
80
|
+
useMCPHostTheme,
|
|
81
|
+
} from './mcp-app-context.js';
|
|
82
|
+
export type { MCPAppContextValue } from './mcp-app-context.js';
|
|
83
|
+
|
|
84
|
+
export { renderMCPApp } from './render-mcp-app.js';
|
|
85
|
+
export type { RenderMCPAppOptions } from './render-mcp-app.js';
|
|
86
|
+
|
|
87
|
+
export { useDocumentTheme } from '@modelcontextprotocol/ext-apps/react';
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal exports for auto-generated MCP App entry files.
|
|
3
|
+
*
|
|
4
|
+
* This module is used by the Vite plugin to generate virtual modules
|
|
5
|
+
* that render MCP Apps. It should not be imported directly by users.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { renderMCPApp } from './render-mcp-app.js';
|
|
12
|
+
export type { RenderMCPAppOptions } from './render-mcp-app.js';
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { describe, expect, mock, test, beforeEach } from 'bun:test';
|
|
2
|
+
import { createElement, type ReactNode } from 'react';
|
|
3
|
+
import { renderToString } from 'react-dom/server';
|
|
4
|
+
|
|
5
|
+
// --- Mocks ---
|
|
6
|
+
|
|
7
|
+
// Track calls to ext-apps hooks
|
|
8
|
+
let mockUseAppReturn = {
|
|
9
|
+
app: null as unknown,
|
|
10
|
+
isConnected: false,
|
|
11
|
+
error: null as Error | null,
|
|
12
|
+
};
|
|
13
|
+
let mockUseAppOptions: unknown = null;
|
|
14
|
+
let mockUseHostStylesCalls: unknown[][] = [];
|
|
15
|
+
let mockUseDocumentThemeReturn: 'light' | 'dark' = 'light';
|
|
16
|
+
|
|
17
|
+
// Mock `@modelcontextprotocol/ext-apps/react` before importing our module
|
|
18
|
+
mock.module('@modelcontextprotocol/ext-apps/react', () => ({
|
|
19
|
+
useApp: (options: unknown) => {
|
|
20
|
+
mockUseAppOptions = options;
|
|
21
|
+
return mockUseAppReturn;
|
|
22
|
+
},
|
|
23
|
+
useHostStyles: (...args: unknown[]) => {
|
|
24
|
+
mockUseHostStylesCalls.push(args);
|
|
25
|
+
},
|
|
26
|
+
useDocumentTheme: () => mockUseDocumentThemeReturn,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Import after mocking
|
|
30
|
+
const {
|
|
31
|
+
MCPAppProvider,
|
|
32
|
+
useMCPAppContext,
|
|
33
|
+
useMCPHostContext,
|
|
34
|
+
useMCPHostTheme,
|
|
35
|
+
} = await import('./mcp-app-context.js');
|
|
36
|
+
|
|
37
|
+
// --- Helpers ---
|
|
38
|
+
|
|
39
|
+
/** Decode HTML entities produced by renderToString. */
|
|
40
|
+
function decodeHtml(html: string): string {
|
|
41
|
+
return html
|
|
42
|
+
.replace(/<[^>]+>/g, '')
|
|
43
|
+
.replace(/"/g, '"')
|
|
44
|
+
.replace(/&/g, '&')
|
|
45
|
+
.replace(/</g, '<')
|
|
46
|
+
.replace(/>/g, '>')
|
|
47
|
+
.replace(/'/g, "'")
|
|
48
|
+
.replace(/'/g, "'");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Render a component inside MCPAppProvider and return the HTML string. */
|
|
52
|
+
function renderWithProvider(children: ReactNode): string {
|
|
53
|
+
return renderToString(
|
|
54
|
+
createElement(MCPAppProvider, null, children)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Render with provider and extract the decoded text content. */
|
|
59
|
+
function renderText(component: () => ReactNode): string {
|
|
60
|
+
const html = renderWithProvider(createElement(component));
|
|
61
|
+
return decodeHtml(html);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Component that reads context and renders its values for assertion. */
|
|
65
|
+
function ContextReader() {
|
|
66
|
+
const ctx = useMCPAppContext();
|
|
67
|
+
return createElement(
|
|
68
|
+
'div',
|
|
69
|
+
{ 'data-testid': 'context' },
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
hasApp: ctx.app !== null,
|
|
72
|
+
isConnected: ctx.isConnected,
|
|
73
|
+
hasError: ctx.error !== null,
|
|
74
|
+
hasHostContext: ctx.hostContext !== undefined,
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Component that reads host context and renders it. */
|
|
80
|
+
function HostContextReader() {
|
|
81
|
+
const hostContext = useMCPHostContext();
|
|
82
|
+
return createElement(
|
|
83
|
+
'div',
|
|
84
|
+
null,
|
|
85
|
+
hostContext ? JSON.stringify(hostContext) : 'undefined'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Component that reads theme and renders it. */
|
|
90
|
+
function ThemeReader() {
|
|
91
|
+
const theme = useMCPHostTheme();
|
|
92
|
+
return createElement('div', null, theme);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- Tests ---
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
mockUseAppReturn = {
|
|
99
|
+
app: null,
|
|
100
|
+
isConnected: false,
|
|
101
|
+
error: null,
|
|
102
|
+
};
|
|
103
|
+
mockUseAppOptions = null;
|
|
104
|
+
mockUseHostStylesCalls = [];
|
|
105
|
+
mockUseDocumentThemeReturn = 'light';
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('MCPAppProvider', () => {
|
|
109
|
+
test('renders children', () => {
|
|
110
|
+
const html = renderWithProvider(
|
|
111
|
+
createElement('span', null, 'hello world')
|
|
112
|
+
);
|
|
113
|
+
expect(html).toContain('hello world');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('calls useApp with correct appInfo', () => {
|
|
117
|
+
renderWithProvider(createElement('div'));
|
|
118
|
+
expect(mockUseAppOptions).toBeDefined();
|
|
119
|
+
const options = mockUseAppOptions as {
|
|
120
|
+
appInfo: { name: string; version: string };
|
|
121
|
+
capabilities: Record<string, unknown>;
|
|
122
|
+
onAppCreated: (app: unknown) => void;
|
|
123
|
+
};
|
|
124
|
+
expect(options.appInfo).toEqual({
|
|
125
|
+
name: 'mcp-web-app',
|
|
126
|
+
version: '0.1.0',
|
|
127
|
+
});
|
|
128
|
+
expect(options.capabilities).toEqual({});
|
|
129
|
+
expect(typeof options.onAppCreated).toBe('function');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('calls useHostStyles with app and initial context', () => {
|
|
133
|
+
mockUseAppReturn = {
|
|
134
|
+
app: { getHostContext: () => null },
|
|
135
|
+
isConnected: true,
|
|
136
|
+
error: null,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
renderWithProvider(createElement('div'));
|
|
140
|
+
expect(mockUseHostStylesCalls.length).toBeGreaterThan(0);
|
|
141
|
+
|
|
142
|
+
const lastCall = mockUseHostStylesCalls[mockUseHostStylesCalls.length - 1];
|
|
143
|
+
// First arg is app, second is initial context
|
|
144
|
+
expect(lastCall[0]).toBe(mockUseAppReturn.app);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('provides context with disconnected state initially', () => {
|
|
148
|
+
mockUseAppReturn = {
|
|
149
|
+
app: null,
|
|
150
|
+
isConnected: false,
|
|
151
|
+
error: null,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const text = renderText(ContextReader);
|
|
155
|
+
const parsed = JSON.parse(text);
|
|
156
|
+
expect(parsed).toEqual({
|
|
157
|
+
hasApp: false,
|
|
158
|
+
isConnected: false,
|
|
159
|
+
hasError: false,
|
|
160
|
+
hasHostContext: false,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('provides context with connected state and app', () => {
|
|
165
|
+
const mockHostContext = { theme: 'dark', displayMode: 'inline' };
|
|
166
|
+
mockUseAppReturn = {
|
|
167
|
+
app: { getHostContext: () => mockHostContext },
|
|
168
|
+
isConnected: true,
|
|
169
|
+
error: null,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const text = renderText(ContextReader);
|
|
173
|
+
const parsed = JSON.parse(text);
|
|
174
|
+
expect(parsed).toEqual({
|
|
175
|
+
hasApp: true,
|
|
176
|
+
isConnected: true,
|
|
177
|
+
hasError: false,
|
|
178
|
+
hasHostContext: true,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('provides context with error state', () => {
|
|
183
|
+
mockUseAppReturn = {
|
|
184
|
+
app: null,
|
|
185
|
+
isConnected: false,
|
|
186
|
+
error: new Error('Connection failed'),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const text = renderText(ContextReader);
|
|
190
|
+
const parsed = JSON.parse(text);
|
|
191
|
+
expect(parsed).toEqual({
|
|
192
|
+
hasApp: false,
|
|
193
|
+
isConnected: false,
|
|
194
|
+
hasError: true,
|
|
195
|
+
hasHostContext: false,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('sets initial host context from app.getHostContext()', () => {
|
|
200
|
+
const mockHostContext = {
|
|
201
|
+
theme: 'dark',
|
|
202
|
+
displayMode: 'inline',
|
|
203
|
+
locale: 'en-US',
|
|
204
|
+
};
|
|
205
|
+
mockUseAppReturn = {
|
|
206
|
+
app: { getHostContext: () => mockHostContext },
|
|
207
|
+
isConnected: true,
|
|
208
|
+
error: null,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const text = renderText(HostContextReader);
|
|
212
|
+
const parsed = JSON.parse(text);
|
|
213
|
+
expect(parsed).toEqual(mockHostContext);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('host context is undefined when app has no context', () => {
|
|
217
|
+
mockUseAppReturn = {
|
|
218
|
+
app: { getHostContext: () => null },
|
|
219
|
+
isConnected: true,
|
|
220
|
+
error: null,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const text = renderText(HostContextReader);
|
|
224
|
+
expect(text).toBe('undefined');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('onAppCreated callback registers onhostcontextchanged handler', () => {
|
|
228
|
+
renderWithProvider(createElement('div'));
|
|
229
|
+
|
|
230
|
+
const options = mockUseAppOptions as {
|
|
231
|
+
onAppCreated: (app: {
|
|
232
|
+
onhostcontextchanged?: (params: unknown) => void;
|
|
233
|
+
}) => void;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Simulate what useApp does: call onAppCreated with an app instance
|
|
237
|
+
const fakeApp: { onhostcontextchanged?: (params: unknown) => void } = {};
|
|
238
|
+
options.onAppCreated(fakeApp);
|
|
239
|
+
|
|
240
|
+
// The handler should be registered
|
|
241
|
+
expect(typeof fakeApp.onhostcontextchanged).toBe('function');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('useMCPAppContext', () => {
|
|
246
|
+
test('throws when used outside MCPAppProvider', () => {
|
|
247
|
+
expect(() => {
|
|
248
|
+
renderToString(createElement(ContextReader));
|
|
249
|
+
}).toThrow('useMCPAppContext must be used within an MCPAppProvider');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('error message includes guidance about renderMCPApp', () => {
|
|
253
|
+
try {
|
|
254
|
+
renderToString(createElement(ContextReader));
|
|
255
|
+
// Should not reach here
|
|
256
|
+
expect(true).toBe(false);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
expect((error as Error).message).toContain('renderMCPApp');
|
|
259
|
+
expect((error as Error).message).toContain('MCPAppProvider');
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('useMCPHostContext', () => {
|
|
265
|
+
test('throws when used outside MCPAppProvider', () => {
|
|
266
|
+
expect(() => {
|
|
267
|
+
renderToString(createElement(HostContextReader));
|
|
268
|
+
}).toThrow('useMCPAppContext must be used within an MCPAppProvider');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('returns undefined when not connected', () => {
|
|
272
|
+
mockUseAppReturn = {
|
|
273
|
+
app: null,
|
|
274
|
+
isConnected: false,
|
|
275
|
+
error: null,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const text = renderText(HostContextReader);
|
|
279
|
+
expect(text).toBe('undefined');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('returns host context when connected', () => {
|
|
283
|
+
const context = {
|
|
284
|
+
theme: 'light',
|
|
285
|
+
displayMode: 'full',
|
|
286
|
+
platform: 'darwin',
|
|
287
|
+
};
|
|
288
|
+
mockUseAppReturn = {
|
|
289
|
+
app: { getHostContext: () => context },
|
|
290
|
+
isConnected: true,
|
|
291
|
+
error: null,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const text = renderText(HostContextReader);
|
|
295
|
+
expect(JSON.parse(text)).toEqual(context);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('useMCPHostTheme', () => {
|
|
300
|
+
test('returns light theme by default', () => {
|
|
301
|
+
mockUseDocumentThemeReturn = 'light';
|
|
302
|
+
|
|
303
|
+
const text = renderText(ThemeReader);
|
|
304
|
+
expect(text).toBe('light');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('returns dark theme when host is dark', () => {
|
|
308
|
+
mockUseDocumentThemeReturn = 'dark';
|
|
309
|
+
|
|
310
|
+
const text = renderText(ThemeReader);
|
|
311
|
+
expect(text).toBe('dark');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('delegates to useDocumentTheme from ext-apps', () => {
|
|
315
|
+
// The mock already tracks that useDocumentTheme is called.
|
|
316
|
+
// If it weren't called, useMCPHostTheme would not return the mock value.
|
|
317
|
+
mockUseDocumentThemeReturn = 'dark';
|
|
318
|
+
|
|
319
|
+
const text = renderText(ThemeReader);
|
|
320
|
+
// This confirms useMCPHostTheme delegates to our mocked useDocumentTheme
|
|
321
|
+
expect(text).toBe('dark');
|
|
322
|
+
});
|
|
323
|
+
});
|