@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.
@@ -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';
@@ -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(/&quot;/g, '"')
44
+ .replace(/&amp;/g, '&')
45
+ .replace(/&lt;/g, '<')
46
+ .replace(/&gt;/g, '>')
47
+ .replace(/&#x27;/g, "'")
48
+ .replace(/&#39;/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
+ });