@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,183 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useState,
6
+ type ReactNode,
7
+ } from 'react';
8
+ import { useApp, useHostStyles, useDocumentTheme } from '@modelcontextprotocol/ext-apps/react';
9
+ import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
10
+
11
+ /**
12
+ * Value provided by `MCPAppProvider`.
13
+ *
14
+ * Contains the ext-apps `App` instance, connection state, and the
15
+ * current host context (theme, styles, display mode, locale, etc.).
16
+ */
17
+ export interface MCPAppContextValue {
18
+ /** The connected ext-apps `App` instance, null during initialization */
19
+ app: App | null;
20
+ /** Whether initialization completed successfully */
21
+ isConnected: boolean;
22
+ /** Connection error if initialization failed, null otherwise */
23
+ error: Error | null;
24
+ /** Current host context, undefined until connected */
25
+ hostContext: McpUiHostContext | undefined;
26
+ }
27
+
28
+ const MCPAppContext = createContext<MCPAppContextValue | null>(null);
29
+
30
+ /**
31
+ * Provider that creates the ext-apps `App` instance and applies host styles.
32
+ *
33
+ * This provider:
34
+ * - Creates and manages the `App` connection via `useApp()`
35
+ * - Automatically applies the host's CSS custom properties, theme, and fonts
36
+ * - Tracks host context changes (theme toggles, display mode changes, etc.)
37
+ * - Makes the app instance and host context available to child components
38
+ *
39
+ * @internal Used by `renderMCPApp` — not typically used directly.
40
+ */
41
+ export function MCPAppProvider({ children }: { children: ReactNode }) {
42
+ const [hostContext, setHostContext] = useState<
43
+ McpUiHostContext | undefined
44
+ >(undefined);
45
+
46
+ const onAppCreated = useCallback((app: App) => {
47
+ app.onhostcontextchanged = (params) => {
48
+ setHostContext((prev) => ({ ...prev, ...params }));
49
+ };
50
+ }, []);
51
+
52
+ const { app, isConnected, error } = useApp({
53
+ appInfo: {
54
+ name: 'mcp-web-app',
55
+ version: '0.1.0',
56
+ },
57
+ capabilities: {},
58
+ onAppCreated,
59
+ });
60
+
61
+ // Capture initial host context once connected
62
+ const initialContext = app?.getHostContext();
63
+ if (initialContext && !hostContext) {
64
+ setHostContext(initialContext);
65
+ }
66
+
67
+ // Automatically apply host CSS variables, theme, and fonts
68
+ useHostStyles(app, initialContext);
69
+
70
+ return (
71
+ <MCPAppContext.Provider
72
+ value={{ app, isConnected, error, hostContext }}
73
+ >
74
+ {children}
75
+ </MCPAppContext.Provider>
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Access the full MCP App context including the app instance and host context.
81
+ *
82
+ * Must be called within an `MCPAppProvider` (which is set up automatically
83
+ * by `renderMCPApp`).
84
+ *
85
+ * @returns The context value with app, connection state, and host context
86
+ * @throws If called outside of an MCPAppProvider
87
+ *
88
+ * @internal Used by `useMCPAppProps` and `useMCPApp`.
89
+ */
90
+ export function useMCPAppContext(): MCPAppContextValue {
91
+ const ctx = useContext(MCPAppContext);
92
+ if (!ctx) {
93
+ throw new Error(
94
+ 'useMCPAppContext must be used within an MCPAppProvider. ' +
95
+ 'If you are using renderMCPApp(), this is set up automatically. ' +
96
+ 'Otherwise, wrap your component tree with <MCPAppProvider>.'
97
+ );
98
+ }
99
+ return ctx;
100
+ }
101
+
102
+ /**
103
+ * Get the current host context from the MCP host application.
104
+ *
105
+ * Returns the full `McpUiHostContext` from the host (e.g., Claude Desktop),
106
+ * which includes theme, styles, display mode, locale, container dimensions,
107
+ * and more. The value updates automatically when the host sends
108
+ * `host-context-changed` notifications.
109
+ *
110
+ * Must be called within an `MCPAppProvider` (set up automatically
111
+ * by `renderMCPApp`).
112
+ *
113
+ * @returns The current host context, or undefined if not yet connected
114
+ *
115
+ * @example Access host display mode
116
+ * ```tsx
117
+ * import { useMCPHostContext } from '@mcp-web/app';
118
+ *
119
+ * function MyApp() {
120
+ * const hostContext = useMCPHostContext();
121
+ *
122
+ * return (
123
+ * <div>
124
+ * <p>Display mode: {hostContext?.displayMode}</p>
125
+ * <p>Platform: {hostContext?.platform}</p>
126
+ * </div>
127
+ * );
128
+ * }
129
+ * ```
130
+ */
131
+ export function useMCPHostContext(): McpUiHostContext | undefined {
132
+ return useMCPAppContext().hostContext;
133
+ }
134
+
135
+ /**
136
+ * Get the current theme preference from the MCP host application.
137
+ *
138
+ * Returns `"light"` or `"dark"` based on the host's current theme.
139
+ * Updates reactively when the user toggles theme in the host.
140
+ *
141
+ * This hook reads the theme from the `data-theme` attribute on
142
+ * `document.documentElement`, which is set automatically by the
143
+ * host styles system. It uses a `MutationObserver` internally so
144
+ * it will re-render your component whenever the theme changes.
145
+ *
146
+ * Must be called within an `MCPAppProvider` (set up automatically
147
+ * by `renderMCPApp`).
148
+ *
149
+ * @returns The current theme — `"light"` or `"dark"`
150
+ *
151
+ * @example Sync with Tailwind CSS dark mode
152
+ * ```tsx
153
+ * import { useMCPHostTheme } from '@mcp-web/app';
154
+ * import { useEffect } from 'react';
155
+ *
156
+ * function MyApp(props: MyProps) {
157
+ * const theme = useMCPHostTheme();
158
+ *
159
+ * useEffect(() => {
160
+ * document.documentElement.classList.toggle('dark', theme === 'dark');
161
+ * }, [theme]);
162
+ *
163
+ * return (
164
+ * <div className="bg-white dark:bg-gray-900">
165
+ * {props.children}
166
+ * </div>
167
+ * );
168
+ * }
169
+ * ```
170
+ *
171
+ * @example Conditional rendering based on theme
172
+ * ```tsx
173
+ * import { useMCPHostTheme } from '@mcp-web/app';
174
+ *
175
+ * function Logo() {
176
+ * const theme = useMCPHostTheme();
177
+ * return <img src={theme === 'dark' ? '/logo-light.svg' : '/logo-dark.svg'} />;
178
+ * }
179
+ * ```
180
+ */
181
+ export function useMCPHostTheme() {
182
+ return useDocumentTheme();
183
+ }
@@ -0,0 +1,141 @@
1
+ import type { ComponentType } from 'react';
2
+ import { StrictMode, Suspense } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { MCPAppProvider } from './mcp-app-context.js';
5
+ import { useMCPAppProps } from './use-mcp-app-props.js';
6
+
7
+ /**
8
+ * Options for rendering an MCP App.
9
+ */
10
+ export interface RenderMCPAppOptions {
11
+ /**
12
+ * Component to show while waiting for props.
13
+ * @default A simple "Loading..." div
14
+ */
15
+ loading?: ComponentType;
16
+
17
+ /**
18
+ * ID of the root element to mount the app.
19
+ * @default 'root'
20
+ */
21
+ rootId?: string;
22
+
23
+ /**
24
+ * Whether to wrap the app in React.StrictMode.
25
+ * @default true
26
+ */
27
+ strictMode?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Default loading component shown while waiting for props.
32
+ * Uses host CSS variable with fallback for theme-aware loading text.
33
+ */
34
+ function DefaultLoading() {
35
+ return (
36
+ <div
37
+ style={{
38
+ display: 'flex',
39
+ alignItems: 'center',
40
+ justifyContent: 'center',
41
+ height: '100%',
42
+ fontFamily: 'var(--font-sans, system-ui, sans-serif)',
43
+ color: 'var(--color-text-secondary, #666)',
44
+ }}
45
+ >
46
+ Loading...
47
+ </div>
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Internal wrapper that handles props subscription.
53
+ */
54
+ function MCPAppWrapper<P extends Record<string, unknown>>({
55
+ Component,
56
+ Loading,
57
+ }: {
58
+ Component: ComponentType<P>;
59
+ Loading: ComponentType;
60
+ }) {
61
+ const props = useMCPAppProps<P>();
62
+
63
+ if (!props) {
64
+ return <Loading />;
65
+ }
66
+
67
+ return <Component {...props} />;
68
+ }
69
+
70
+ /**
71
+ * Render a React component as an MCP App.
72
+ *
73
+ * This helper sets up the React root and handles props subscription,
74
+ * so your component can be a regular React component that receives props -
75
+ * no MCP-specific code required in your component.
76
+ *
77
+ * @param Component - Your React component (receives props from the MCP handler)
78
+ * @param options - Optional configuration for loading state and root element
79
+ *
80
+ * @example Basic usage with an existing component
81
+ * ```tsx
82
+ * // src/apps/stats.tsx
83
+ * import { renderMCPApp } from '@mcp-web/app';
84
+ * import { Stats } from '../components/Stats';
85
+ *
86
+ * // Stats is a regular component: function Stats({ total, completed }: StatsProps) { ... }
87
+ * renderMCPApp(Stats);
88
+ * ```
89
+ *
90
+ * @example With custom loading component
91
+ * ```tsx
92
+ * import { renderMCPApp } from '@mcp-web/app';
93
+ * import { Stats } from '../components/Stats';
94
+ * import { Spinner } from '../components/Spinner';
95
+ *
96
+ * renderMCPApp(Stats, {
97
+ * loading: Spinner,
98
+ * });
99
+ * ```
100
+ *
101
+ * @example Inline component definition
102
+ * ```tsx
103
+ * import { renderMCPApp } from '@mcp-web/app';
104
+ *
105
+ * interface ChartProps {
106
+ * data: number[];
107
+ * title: string;
108
+ * }
109
+ *
110
+ * renderMCPApp<ChartProps>(({ data, title }) => (
111
+ * <div>
112
+ * <h1>{title}</h1>
113
+ * <Chart data={data} />
114
+ * </div>
115
+ * ));
116
+ * ```
117
+ */
118
+ export function renderMCPApp<P extends Record<string, unknown>>(
119
+ Component: ComponentType<P>,
120
+ options: RenderMCPAppOptions = {}
121
+ ): void {
122
+ const { loading: Loading = DefaultLoading, rootId = 'root', strictMode = true } = options;
123
+
124
+ const rootElement = document.getElementById(rootId);
125
+ if (!rootElement) {
126
+ throw new Error(
127
+ `[renderMCPApp] Could not find element with id "${rootId}". ` +
128
+ `Make sure your HTML has <div id="${rootId}"></div>`
129
+ );
130
+ }
131
+
132
+ const app = (
133
+ <Suspense fallback={<Loading />}>
134
+ <MCPAppProvider>
135
+ <MCPAppWrapper Component={Component} Loading={Loading} />
136
+ </MCPAppProvider>
137
+ </Suspense>
138
+ );
139
+
140
+ createRoot(rootElement).render(strictMode ? <StrictMode>{app}</StrictMode> : app);
141
+ }
@@ -0,0 +1,166 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useMCPAppContext } from './mcp-app-context.js';
3
+
4
+ /**
5
+ * React hook to receive props from the MCP host via the ext-apps protocol.
6
+ *
7
+ * This hook connects to the host (e.g., Claude Desktop) using the
8
+ * `@modelcontextprotocol/ext-apps` JSON-RPC protocol. It listens for
9
+ * `tool-result` notifications, which contain the props returned by the
10
+ * tool handler as JSON in `content[0].text`.
11
+ *
12
+ * Must be called within an `MCPAppProvider` (set up automatically
13
+ * by `renderMCPApp`).
14
+ *
15
+ * @template T - The type of props expected from the handler
16
+ * @returns The props object, or null if not yet received
17
+ *
18
+ * @example Basic Usage
19
+ * ```tsx
20
+ * import { useMCPAppProps } from '@mcp-web/app';
21
+ *
22
+ * interface MyAppProps {
23
+ * title: string;
24
+ * data: number[];
25
+ * }
26
+ *
27
+ * function MyApp() {
28
+ * const props = useMCPAppProps<MyAppProps>();
29
+ *
30
+ * if (!props) {
31
+ * return <div>Waiting for data...</div>;
32
+ * }
33
+ *
34
+ * return (
35
+ * <div>
36
+ * <h1>{props.title}</h1>
37
+ * <ul>
38
+ * {props.data.map((item, i) => (
39
+ * <li key={i}>{item}</li>
40
+ * ))}
41
+ * </ul>
42
+ * </div>
43
+ * );
44
+ * }
45
+ * ```
46
+ *
47
+ * @example With Default Props for Development
48
+ * ```tsx
49
+ * function MyApp() {
50
+ * const props = useMCPAppProps<MyAppProps>() ?? {
51
+ * title: 'Development Preview',
52
+ * data: [1, 2, 3],
53
+ * };
54
+ *
55
+ * return <div>{props.title}</div>;
56
+ * }
57
+ * ```
58
+ */
59
+ export function useMCPAppProps<T>(): T | null {
60
+ const [props, setProps] = useState<T | null>(null);
61
+ const { app } = useMCPAppContext();
62
+
63
+ useEffect(() => {
64
+ if (!app) return;
65
+
66
+ // Listen for tool result - this is where our props come from.
67
+ // The tool handler returns props which the bridge wraps into
68
+ // CallToolResult.content[0].text as JSON.
69
+ app.ontoolresult = async (result) => {
70
+ if (result?.content) {
71
+ for (const block of result.content) {
72
+ if (block.type === 'text' && typeof block.text === 'string') {
73
+ try {
74
+ const parsed = JSON.parse(block.text);
75
+ setProps(parsed as T);
76
+ } catch {
77
+ // If JSON parsing fails, treat the text as-is
78
+ setProps(block.text as unknown as T);
79
+ }
80
+ return;
81
+ }
82
+ }
83
+ }
84
+ };
85
+
86
+ // Also listen for tool input - useful for apps that need
87
+ // the raw tool call arguments
88
+ app.ontoolinput = async (input) => {
89
+ // If we have arguments and no result yet, use them as initial props.
90
+ // This enables apps to render immediately with the tool input
91
+ // before the full result arrives.
92
+ if (input?.arguments) {
93
+ setProps((prev) => prev ?? (input.arguments as unknown as T));
94
+ }
95
+ };
96
+ }, [app]);
97
+
98
+ return props;
99
+ }
100
+
101
+ /**
102
+ * Get the ext-apps `App` instance for advanced use cases.
103
+ *
104
+ * This hook provides access to the underlying `App` class from
105
+ * `@modelcontextprotocol/ext-apps`, enabling bidirectional communication
106
+ * with the host (e.g., calling server tools, sending messages).
107
+ *
108
+ * Must be called within an `MCPAppProvider` (set up automatically
109
+ * by `renderMCPApp`).
110
+ *
111
+ * @returns The App state including app instance, connection status, and errors
112
+ *
113
+ * @example Calling a server tool from the app
114
+ * ```tsx
115
+ * import { useMCPApp } from '@mcp-web/app';
116
+ *
117
+ * function MyApp() {
118
+ * const { app, isConnected } = useMCPApp();
119
+ *
120
+ * const handleClick = async () => {
121
+ * if (app) {
122
+ * const result = await app.callServerTool({
123
+ * name: 'update_data',
124
+ * arguments: { key: 'value' },
125
+ * });
126
+ * console.log('Server tool result:', result);
127
+ * }
128
+ * };
129
+ *
130
+ * return <button onClick={handleClick}>Update</button>;
131
+ * }
132
+ * ```
133
+ */
134
+ export function useMCPApp() {
135
+ const { app, isConnected, error } = useMCPAppContext();
136
+ return { app, isConnected, error };
137
+ }
138
+
139
+ /**
140
+ * Get current MCP App props synchronously.
141
+ *
142
+ * @deprecated Use `useMCPAppProps` hook instead. This function is maintained
143
+ * for backward compatibility but does not work with the ext-apps protocol.
144
+ *
145
+ * @template T - The type of props expected
146
+ * @returns null (ext-apps protocol is async-only)
147
+ */
148
+ export function getMCPAppProps<T>(): T | null {
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * Subscribe to MCP App props changes.
154
+ *
155
+ * @deprecated Use `useMCPAppProps` hook instead. This function is maintained
156
+ * for backward compatibility but does not work with the ext-apps protocol.
157
+ *
158
+ * @template T - The type of props expected
159
+ * @param _listener - Function called when props are received or updated
160
+ * @returns No-op unsubscribe function
161
+ */
162
+ export function subscribeMCPAppProps<T>(
163
+ _listener: (props: T) => void
164
+ ): () => void {
165
+ return () => {};
166
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { defineMCPAppsConfig } from '../src/vite-plugin';
3
+ import type { UserConfig } from 'vite';
4
+
5
+ describe('defineMCPAppsConfig', () => {
6
+ test('returns a Vite config object', () => {
7
+ const config = defineMCPAppsConfig() as UserConfig;
8
+
9
+ expect(config).toBeDefined();
10
+ expect(config.base).toBe('./');
11
+ expect(config.build).toBeDefined();
12
+ });
13
+
14
+ test('sets critical build options for single-file output', () => {
15
+ const config = defineMCPAppsConfig() as UserConfig;
16
+
17
+ expect(config.build?.assetsInlineLimit).toBe(Number.MAX_SAFE_INTEGER);
18
+ expect(config.build?.cssCodeSplit).toBe(false);
19
+ expect(config.build?.emptyOutDir).toBe(true);
20
+ });
21
+
22
+ test('includes viteSingleFile and mcpAppPlugin plugins', () => {
23
+ const config = defineMCPAppsConfig() as UserConfig;
24
+
25
+ expect(config.plugins).toBeDefined();
26
+ expect(Array.isArray(config.plugins)).toBe(true);
27
+
28
+ const pluginNames = (config.plugins as Array<{ name?: string }>)
29
+ .filter((p) => p && typeof p === 'object' && 'name' in p)
30
+ .map((p) => p.name);
31
+
32
+ expect(pluginNames).toContain('vite:singlefile');
33
+ expect(pluginNames).toContain('mcp-web-app');
34
+ });
35
+
36
+ test('merges user plugins with internal plugins', () => {
37
+ const userPlugin = { name: 'user-plugin' };
38
+ const config = defineMCPAppsConfig({
39
+ plugins: [userPlugin],
40
+ }) as UserConfig;
41
+
42
+ const pluginNames = (config.plugins as Array<{ name?: string }>)
43
+ .filter((p) => p && typeof p === 'object' && 'name' in p)
44
+ .map((p) => p.name);
45
+
46
+ expect(pluginNames).toContain('user-plugin');
47
+ expect(pluginNames).toContain('vite:singlefile');
48
+ expect(pluginNames).toContain('mcp-web-app');
49
+ });
50
+
51
+ test('user plugins come before internal plugins', () => {
52
+ const userPlugin = { name: 'user-plugin' };
53
+ const config = defineMCPAppsConfig({
54
+ plugins: [userPlugin],
55
+ }) as UserConfig;
56
+
57
+ const pluginNames = (config.plugins as Array<{ name?: string }>)
58
+ .filter((p) => p && typeof p === 'object' && 'name' in p)
59
+ .map((p) => p.name);
60
+
61
+ const userIndex = pluginNames.indexOf('user-plugin');
62
+ const singleFileIndex = pluginNames.indexOf('vite:singlefile');
63
+
64
+ expect(userIndex).toBeLessThan(singleFileIndex);
65
+ });
66
+
67
+ test('accepts custom appsConfig and outDir', () => {
68
+ const config = defineMCPAppsConfig(
69
+ {},
70
+ {
71
+ appsConfig: 'custom/apps.ts',
72
+ outDir: 'custom/output',
73
+ }
74
+ ) as UserConfig;
75
+
76
+ expect(config).toBeDefined();
77
+ expect(config.build?.outDir).toContain('custom/output');
78
+ });
79
+
80
+ test('preserves user build options that are not critical', () => {
81
+ const config = defineMCPAppsConfig({
82
+ build: {
83
+ sourcemap: true,
84
+ minify: 'terser',
85
+ },
86
+ }) as UserConfig;
87
+
88
+ expect(config.build?.sourcemap).toBe(true);
89
+ expect(config.build?.minify).toBe('terser');
90
+ });
91
+
92
+ test('overrides critical settings even if user provides them', () => {
93
+ const config = defineMCPAppsConfig({
94
+ build: {
95
+ assetsInlineLimit: 100,
96
+ cssCodeSplit: true,
97
+ },
98
+ }) as UserConfig;
99
+
100
+ // Critical settings should be overridden
101
+ expect(config.build?.assetsInlineLimit).toBe(Number.MAX_SAFE_INTEGER);
102
+ expect(config.build?.cssCodeSplit).toBe(false);
103
+ });
104
+
105
+ test('default export is same as named export', async () => {
106
+ const { default: defaultExport, defineMCPAppsConfig: namedExport } =
107
+ await import('../src/vite-plugin');
108
+ expect(defaultExport).toBe(namedExport);
109
+ });
110
+ });