@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,122 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
|
|
3
|
+
/**
|
|
4
|
+
* Value provided by `MCPAppProvider`.
|
|
5
|
+
*
|
|
6
|
+
* Contains the ext-apps `App` instance, connection state, and the
|
|
7
|
+
* current host context (theme, styles, display mode, locale, etc.).
|
|
8
|
+
*/
|
|
9
|
+
export interface MCPAppContextValue {
|
|
10
|
+
/** The connected ext-apps `App` instance, null during initialization */
|
|
11
|
+
app: App | null;
|
|
12
|
+
/** Whether initialization completed successfully */
|
|
13
|
+
isConnected: boolean;
|
|
14
|
+
/** Connection error if initialization failed, null otherwise */
|
|
15
|
+
error: Error | null;
|
|
16
|
+
/** Current host context, undefined until connected */
|
|
17
|
+
hostContext: McpUiHostContext | undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Provider that creates the ext-apps `App` instance and applies host styles.
|
|
21
|
+
*
|
|
22
|
+
* This provider:
|
|
23
|
+
* - Creates and manages the `App` connection via `useApp()`
|
|
24
|
+
* - Automatically applies the host's CSS custom properties, theme, and fonts
|
|
25
|
+
* - Tracks host context changes (theme toggles, display mode changes, etc.)
|
|
26
|
+
* - Makes the app instance and host context available to child components
|
|
27
|
+
*
|
|
28
|
+
* @internal Used by `renderMCPApp` — not typically used directly.
|
|
29
|
+
*/
|
|
30
|
+
export declare function MCPAppProvider({ children }: {
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
33
|
+
/**
|
|
34
|
+
* Access the full MCP App context including the app instance and host context.
|
|
35
|
+
*
|
|
36
|
+
* Must be called within an `MCPAppProvider` (which is set up automatically
|
|
37
|
+
* by `renderMCPApp`).
|
|
38
|
+
*
|
|
39
|
+
* @returns The context value with app, connection state, and host context
|
|
40
|
+
* @throws If called outside of an MCPAppProvider
|
|
41
|
+
*
|
|
42
|
+
* @internal Used by `useMCPAppProps` and `useMCPApp`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function useMCPAppContext(): MCPAppContextValue;
|
|
45
|
+
/**
|
|
46
|
+
* Get the current host context from the MCP host application.
|
|
47
|
+
*
|
|
48
|
+
* Returns the full `McpUiHostContext` from the host (e.g., Claude Desktop),
|
|
49
|
+
* which includes theme, styles, display mode, locale, container dimensions,
|
|
50
|
+
* and more. The value updates automatically when the host sends
|
|
51
|
+
* `host-context-changed` notifications.
|
|
52
|
+
*
|
|
53
|
+
* Must be called within an `MCPAppProvider` (set up automatically
|
|
54
|
+
* by `renderMCPApp`).
|
|
55
|
+
*
|
|
56
|
+
* @returns The current host context, or undefined if not yet connected
|
|
57
|
+
*
|
|
58
|
+
* @example Access host display mode
|
|
59
|
+
* ```tsx
|
|
60
|
+
* import { useMCPHostContext } from '@mcp-web/app';
|
|
61
|
+
*
|
|
62
|
+
* function MyApp() {
|
|
63
|
+
* const hostContext = useMCPHostContext();
|
|
64
|
+
*
|
|
65
|
+
* return (
|
|
66
|
+
* <div>
|
|
67
|
+
* <p>Display mode: {hostContext?.displayMode}</p>
|
|
68
|
+
* <p>Platform: {hostContext?.platform}</p>
|
|
69
|
+
* </div>
|
|
70
|
+
* );
|
|
71
|
+
* }
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function useMCPHostContext(): McpUiHostContext | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* Get the current theme preference from the MCP host application.
|
|
77
|
+
*
|
|
78
|
+
* Returns `"light"` or `"dark"` based on the host's current theme.
|
|
79
|
+
* Updates reactively when the user toggles theme in the host.
|
|
80
|
+
*
|
|
81
|
+
* This hook reads the theme from the `data-theme` attribute on
|
|
82
|
+
* `document.documentElement`, which is set automatically by the
|
|
83
|
+
* host styles system. It uses a `MutationObserver` internally so
|
|
84
|
+
* it will re-render your component whenever the theme changes.
|
|
85
|
+
*
|
|
86
|
+
* Must be called within an `MCPAppProvider` (set up automatically
|
|
87
|
+
* by `renderMCPApp`).
|
|
88
|
+
*
|
|
89
|
+
* @returns The current theme — `"light"` or `"dark"`
|
|
90
|
+
*
|
|
91
|
+
* @example Sync with Tailwind CSS dark mode
|
|
92
|
+
* ```tsx
|
|
93
|
+
* import { useMCPHostTheme } from '@mcp-web/app';
|
|
94
|
+
* import { useEffect } from 'react';
|
|
95
|
+
*
|
|
96
|
+
* function MyApp(props: MyProps) {
|
|
97
|
+
* const theme = useMCPHostTheme();
|
|
98
|
+
*
|
|
99
|
+
* useEffect(() => {
|
|
100
|
+
* document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
101
|
+
* }, [theme]);
|
|
102
|
+
*
|
|
103
|
+
* return (
|
|
104
|
+
* <div className="bg-white dark:bg-gray-900">
|
|
105
|
+
* {props.children}
|
|
106
|
+
* </div>
|
|
107
|
+
* );
|
|
108
|
+
* }
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* @example Conditional rendering based on theme
|
|
112
|
+
* ```tsx
|
|
113
|
+
* import { useMCPHostTheme } from '@mcp-web/app';
|
|
114
|
+
*
|
|
115
|
+
* function Logo() {
|
|
116
|
+
* const theme = useMCPHostTheme();
|
|
117
|
+
* return <img src={theme === 'dark' ? '/logo-light.svg' : '/logo-dark.svg'} />;
|
|
118
|
+
* }
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export declare function useMCPHostTheme(): import("@modelcontextprotocol/ext-apps").McpUiTheme;
|
|
122
|
+
//# sourceMappingURL=mcp-app-context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-app-context.d.ts","sourceRoot":"","sources":["../src/mcp-app-context.tsx"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAEf,OAAO,KAAK,EAAE,GAAG,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AAE5E;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,wEAAwE;IACxE,GAAG,EAAE,GAAG,GAAG,IAAI,CAAC;IAChB,oDAAoD;IACpD,WAAW,EAAE,OAAO,CAAC;IACrB,gEAAgE;IAChE,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,sDAAsD;IACtD,WAAW,EAAE,gBAAgB,GAAG,SAAS,CAAC;CAC3C;AAID;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,2CAoCnE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,IAAI,kBAAkB,CAUrD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,iBAAiB,IAAI,gBAAgB,GAAG,SAAS,CAEhE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,wBAAgB,eAAe,wDAE9B"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useContext, useState, } from 'react';
|
|
3
|
+
import { useApp, useHostStyles, useDocumentTheme } from '@modelcontextprotocol/ext-apps/react';
|
|
4
|
+
const MCPAppContext = createContext(null);
|
|
5
|
+
/**
|
|
6
|
+
* Provider that creates the ext-apps `App` instance and applies host styles.
|
|
7
|
+
*
|
|
8
|
+
* This provider:
|
|
9
|
+
* - Creates and manages the `App` connection via `useApp()`
|
|
10
|
+
* - Automatically applies the host's CSS custom properties, theme, and fonts
|
|
11
|
+
* - Tracks host context changes (theme toggles, display mode changes, etc.)
|
|
12
|
+
* - Makes the app instance and host context available to child components
|
|
13
|
+
*
|
|
14
|
+
* @internal Used by `renderMCPApp` — not typically used directly.
|
|
15
|
+
*/
|
|
16
|
+
export function MCPAppProvider({ children }) {
|
|
17
|
+
const [hostContext, setHostContext] = useState(undefined);
|
|
18
|
+
const onAppCreated = useCallback((app) => {
|
|
19
|
+
app.onhostcontextchanged = (params) => {
|
|
20
|
+
setHostContext((prev) => ({ ...prev, ...params }));
|
|
21
|
+
};
|
|
22
|
+
}, []);
|
|
23
|
+
const { app, isConnected, error } = useApp({
|
|
24
|
+
appInfo: {
|
|
25
|
+
name: 'mcp-web-app',
|
|
26
|
+
version: '0.1.0',
|
|
27
|
+
},
|
|
28
|
+
capabilities: {},
|
|
29
|
+
onAppCreated,
|
|
30
|
+
});
|
|
31
|
+
// Capture initial host context once connected
|
|
32
|
+
const initialContext = app?.getHostContext();
|
|
33
|
+
if (initialContext && !hostContext) {
|
|
34
|
+
setHostContext(initialContext);
|
|
35
|
+
}
|
|
36
|
+
// Automatically apply host CSS variables, theme, and fonts
|
|
37
|
+
useHostStyles(app, initialContext);
|
|
38
|
+
return (_jsx(MCPAppContext.Provider, { value: { app, isConnected, error, hostContext }, children: children }));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Access the full MCP App context including the app instance and host context.
|
|
42
|
+
*
|
|
43
|
+
* Must be called within an `MCPAppProvider` (which is set up automatically
|
|
44
|
+
* by `renderMCPApp`).
|
|
45
|
+
*
|
|
46
|
+
* @returns The context value with app, connection state, and host context
|
|
47
|
+
* @throws If called outside of an MCPAppProvider
|
|
48
|
+
*
|
|
49
|
+
* @internal Used by `useMCPAppProps` and `useMCPApp`.
|
|
50
|
+
*/
|
|
51
|
+
export function useMCPAppContext() {
|
|
52
|
+
const ctx = useContext(MCPAppContext);
|
|
53
|
+
if (!ctx) {
|
|
54
|
+
throw new Error('useMCPAppContext must be used within an MCPAppProvider. ' +
|
|
55
|
+
'If you are using renderMCPApp(), this is set up automatically. ' +
|
|
56
|
+
'Otherwise, wrap your component tree with <MCPAppProvider>.');
|
|
57
|
+
}
|
|
58
|
+
return ctx;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get the current host context from the MCP host application.
|
|
62
|
+
*
|
|
63
|
+
* Returns the full `McpUiHostContext` from the host (e.g., Claude Desktop),
|
|
64
|
+
* which includes theme, styles, display mode, locale, container dimensions,
|
|
65
|
+
* and more. The value updates automatically when the host sends
|
|
66
|
+
* `host-context-changed` notifications.
|
|
67
|
+
*
|
|
68
|
+
* Must be called within an `MCPAppProvider` (set up automatically
|
|
69
|
+
* by `renderMCPApp`).
|
|
70
|
+
*
|
|
71
|
+
* @returns The current host context, or undefined if not yet connected
|
|
72
|
+
*
|
|
73
|
+
* @example Access host display mode
|
|
74
|
+
* ```tsx
|
|
75
|
+
* import { useMCPHostContext } from '@mcp-web/app';
|
|
76
|
+
*
|
|
77
|
+
* function MyApp() {
|
|
78
|
+
* const hostContext = useMCPHostContext();
|
|
79
|
+
*
|
|
80
|
+
* return (
|
|
81
|
+
* <div>
|
|
82
|
+
* <p>Display mode: {hostContext?.displayMode}</p>
|
|
83
|
+
* <p>Platform: {hostContext?.platform}</p>
|
|
84
|
+
* </div>
|
|
85
|
+
* );
|
|
86
|
+
* }
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function useMCPHostContext() {
|
|
90
|
+
return useMCPAppContext().hostContext;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get the current theme preference from the MCP host application.
|
|
94
|
+
*
|
|
95
|
+
* Returns `"light"` or `"dark"` based on the host's current theme.
|
|
96
|
+
* Updates reactively when the user toggles theme in the host.
|
|
97
|
+
*
|
|
98
|
+
* This hook reads the theme from the `data-theme` attribute on
|
|
99
|
+
* `document.documentElement`, which is set automatically by the
|
|
100
|
+
* host styles system. It uses a `MutationObserver` internally so
|
|
101
|
+
* it will re-render your component whenever the theme changes.
|
|
102
|
+
*
|
|
103
|
+
* Must be called within an `MCPAppProvider` (set up automatically
|
|
104
|
+
* by `renderMCPApp`).
|
|
105
|
+
*
|
|
106
|
+
* @returns The current theme — `"light"` or `"dark"`
|
|
107
|
+
*
|
|
108
|
+
* @example Sync with Tailwind CSS dark mode
|
|
109
|
+
* ```tsx
|
|
110
|
+
* import { useMCPHostTheme } from '@mcp-web/app';
|
|
111
|
+
* import { useEffect } from 'react';
|
|
112
|
+
*
|
|
113
|
+
* function MyApp(props: MyProps) {
|
|
114
|
+
* const theme = useMCPHostTheme();
|
|
115
|
+
*
|
|
116
|
+
* useEffect(() => {
|
|
117
|
+
* document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
118
|
+
* }, [theme]);
|
|
119
|
+
*
|
|
120
|
+
* return (
|
|
121
|
+
* <div className="bg-white dark:bg-gray-900">
|
|
122
|
+
* {props.children}
|
|
123
|
+
* </div>
|
|
124
|
+
* );
|
|
125
|
+
* }
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* @example Conditional rendering based on theme
|
|
129
|
+
* ```tsx
|
|
130
|
+
* import { useMCPHostTheme } from '@mcp-web/app';
|
|
131
|
+
*
|
|
132
|
+
* function Logo() {
|
|
133
|
+
* const theme = useMCPHostTheme();
|
|
134
|
+
* return <img src={theme === 'dark' ? '/logo-light.svg' : '/logo-dark.svg'} />;
|
|
135
|
+
* }
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export function useMCPHostTheme() {
|
|
139
|
+
return useDocumentTheme();
|
|
140
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-app-context.test.d.ts","sourceRoot":"","sources":["../src/mcp-app-context.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, expect, mock, test, beforeEach } from 'bun:test';
|
|
2
|
+
import { createElement } from 'react';
|
|
3
|
+
import { renderToString } from 'react-dom/server';
|
|
4
|
+
// --- Mocks ---
|
|
5
|
+
// Track calls to ext-apps hooks
|
|
6
|
+
let mockUseAppReturn = {
|
|
7
|
+
app: null,
|
|
8
|
+
isConnected: false,
|
|
9
|
+
error: null,
|
|
10
|
+
};
|
|
11
|
+
let mockUseAppOptions = null;
|
|
12
|
+
let mockUseHostStylesCalls = [];
|
|
13
|
+
let mockUseDocumentThemeReturn = 'light';
|
|
14
|
+
// Mock `@modelcontextprotocol/ext-apps/react` before importing our module
|
|
15
|
+
mock.module('@modelcontextprotocol/ext-apps/react', () => ({
|
|
16
|
+
useApp: (options) => {
|
|
17
|
+
mockUseAppOptions = options;
|
|
18
|
+
return mockUseAppReturn;
|
|
19
|
+
},
|
|
20
|
+
useHostStyles: (...args) => {
|
|
21
|
+
mockUseHostStylesCalls.push(args);
|
|
22
|
+
},
|
|
23
|
+
useDocumentTheme: () => mockUseDocumentThemeReturn,
|
|
24
|
+
}));
|
|
25
|
+
// Import after mocking
|
|
26
|
+
const { MCPAppProvider, useMCPAppContext, useMCPHostContext, useMCPHostTheme, } = await import('./mcp-app-context.js');
|
|
27
|
+
// --- Helpers ---
|
|
28
|
+
/** Decode HTML entities produced by renderToString. */
|
|
29
|
+
function decodeHtml(html) {
|
|
30
|
+
return html
|
|
31
|
+
.replace(/<[^>]+>/g, '')
|
|
32
|
+
.replace(/"/g, '"')
|
|
33
|
+
.replace(/&/g, '&')
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>')
|
|
36
|
+
.replace(/'/g, "'")
|
|
37
|
+
.replace(/'/g, "'");
|
|
38
|
+
}
|
|
39
|
+
/** Render a component inside MCPAppProvider and return the HTML string. */
|
|
40
|
+
function renderWithProvider(children) {
|
|
41
|
+
return renderToString(createElement(MCPAppProvider, null, children));
|
|
42
|
+
}
|
|
43
|
+
/** Render with provider and extract the decoded text content. */
|
|
44
|
+
function renderText(component) {
|
|
45
|
+
const html = renderWithProvider(createElement(component));
|
|
46
|
+
return decodeHtml(html);
|
|
47
|
+
}
|
|
48
|
+
/** Component that reads context and renders its values for assertion. */
|
|
49
|
+
function ContextReader() {
|
|
50
|
+
const ctx = useMCPAppContext();
|
|
51
|
+
return createElement('div', { 'data-testid': 'context' }, JSON.stringify({
|
|
52
|
+
hasApp: ctx.app !== null,
|
|
53
|
+
isConnected: ctx.isConnected,
|
|
54
|
+
hasError: ctx.error !== null,
|
|
55
|
+
hasHostContext: ctx.hostContext !== undefined,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
/** Component that reads host context and renders it. */
|
|
59
|
+
function HostContextReader() {
|
|
60
|
+
const hostContext = useMCPHostContext();
|
|
61
|
+
return createElement('div', null, hostContext ? JSON.stringify(hostContext) : 'undefined');
|
|
62
|
+
}
|
|
63
|
+
/** Component that reads theme and renders it. */
|
|
64
|
+
function ThemeReader() {
|
|
65
|
+
const theme = useMCPHostTheme();
|
|
66
|
+
return createElement('div', null, theme);
|
|
67
|
+
}
|
|
68
|
+
// --- Tests ---
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
mockUseAppReturn = {
|
|
71
|
+
app: null,
|
|
72
|
+
isConnected: false,
|
|
73
|
+
error: null,
|
|
74
|
+
};
|
|
75
|
+
mockUseAppOptions = null;
|
|
76
|
+
mockUseHostStylesCalls = [];
|
|
77
|
+
mockUseDocumentThemeReturn = 'light';
|
|
78
|
+
});
|
|
79
|
+
describe('MCPAppProvider', () => {
|
|
80
|
+
test('renders children', () => {
|
|
81
|
+
const html = renderWithProvider(createElement('span', null, 'hello world'));
|
|
82
|
+
expect(html).toContain('hello world');
|
|
83
|
+
});
|
|
84
|
+
test('calls useApp with correct appInfo', () => {
|
|
85
|
+
renderWithProvider(createElement('div'));
|
|
86
|
+
expect(mockUseAppOptions).toBeDefined();
|
|
87
|
+
const options = mockUseAppOptions;
|
|
88
|
+
expect(options.appInfo).toEqual({
|
|
89
|
+
name: 'mcp-web-app',
|
|
90
|
+
version: '0.1.0',
|
|
91
|
+
});
|
|
92
|
+
expect(options.capabilities).toEqual({});
|
|
93
|
+
expect(typeof options.onAppCreated).toBe('function');
|
|
94
|
+
});
|
|
95
|
+
test('calls useHostStyles with app and initial context', () => {
|
|
96
|
+
mockUseAppReturn = {
|
|
97
|
+
app: { getHostContext: () => null },
|
|
98
|
+
isConnected: true,
|
|
99
|
+
error: null,
|
|
100
|
+
};
|
|
101
|
+
renderWithProvider(createElement('div'));
|
|
102
|
+
expect(mockUseHostStylesCalls.length).toBeGreaterThan(0);
|
|
103
|
+
const lastCall = mockUseHostStylesCalls[mockUseHostStylesCalls.length - 1];
|
|
104
|
+
// First arg is app, second is initial context
|
|
105
|
+
expect(lastCall[0]).toBe(mockUseAppReturn.app);
|
|
106
|
+
});
|
|
107
|
+
test('provides context with disconnected state initially', () => {
|
|
108
|
+
mockUseAppReturn = {
|
|
109
|
+
app: null,
|
|
110
|
+
isConnected: false,
|
|
111
|
+
error: null,
|
|
112
|
+
};
|
|
113
|
+
const text = renderText(ContextReader);
|
|
114
|
+
const parsed = JSON.parse(text);
|
|
115
|
+
expect(parsed).toEqual({
|
|
116
|
+
hasApp: false,
|
|
117
|
+
isConnected: false,
|
|
118
|
+
hasError: false,
|
|
119
|
+
hasHostContext: false,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
test('provides context with connected state and app', () => {
|
|
123
|
+
const mockHostContext = { theme: 'dark', displayMode: 'inline' };
|
|
124
|
+
mockUseAppReturn = {
|
|
125
|
+
app: { getHostContext: () => mockHostContext },
|
|
126
|
+
isConnected: true,
|
|
127
|
+
error: null,
|
|
128
|
+
};
|
|
129
|
+
const text = renderText(ContextReader);
|
|
130
|
+
const parsed = JSON.parse(text);
|
|
131
|
+
expect(parsed).toEqual({
|
|
132
|
+
hasApp: true,
|
|
133
|
+
isConnected: true,
|
|
134
|
+
hasError: false,
|
|
135
|
+
hasHostContext: true,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
test('provides context with error state', () => {
|
|
139
|
+
mockUseAppReturn = {
|
|
140
|
+
app: null,
|
|
141
|
+
isConnected: false,
|
|
142
|
+
error: new Error('Connection failed'),
|
|
143
|
+
};
|
|
144
|
+
const text = renderText(ContextReader);
|
|
145
|
+
const parsed = JSON.parse(text);
|
|
146
|
+
expect(parsed).toEqual({
|
|
147
|
+
hasApp: false,
|
|
148
|
+
isConnected: false,
|
|
149
|
+
hasError: true,
|
|
150
|
+
hasHostContext: false,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
test('sets initial host context from app.getHostContext()', () => {
|
|
154
|
+
const mockHostContext = {
|
|
155
|
+
theme: 'dark',
|
|
156
|
+
displayMode: 'inline',
|
|
157
|
+
locale: 'en-US',
|
|
158
|
+
};
|
|
159
|
+
mockUseAppReturn = {
|
|
160
|
+
app: { getHostContext: () => mockHostContext },
|
|
161
|
+
isConnected: true,
|
|
162
|
+
error: null,
|
|
163
|
+
};
|
|
164
|
+
const text = renderText(HostContextReader);
|
|
165
|
+
const parsed = JSON.parse(text);
|
|
166
|
+
expect(parsed).toEqual(mockHostContext);
|
|
167
|
+
});
|
|
168
|
+
test('host context is undefined when app has no context', () => {
|
|
169
|
+
mockUseAppReturn = {
|
|
170
|
+
app: { getHostContext: () => null },
|
|
171
|
+
isConnected: true,
|
|
172
|
+
error: null,
|
|
173
|
+
};
|
|
174
|
+
const text = renderText(HostContextReader);
|
|
175
|
+
expect(text).toBe('undefined');
|
|
176
|
+
});
|
|
177
|
+
test('onAppCreated callback registers onhostcontextchanged handler', () => {
|
|
178
|
+
renderWithProvider(createElement('div'));
|
|
179
|
+
const options = mockUseAppOptions;
|
|
180
|
+
// Simulate what useApp does: call onAppCreated with an app instance
|
|
181
|
+
const fakeApp = {};
|
|
182
|
+
options.onAppCreated(fakeApp);
|
|
183
|
+
// The handler should be registered
|
|
184
|
+
expect(typeof fakeApp.onhostcontextchanged).toBe('function');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
describe('useMCPAppContext', () => {
|
|
188
|
+
test('throws when used outside MCPAppProvider', () => {
|
|
189
|
+
expect(() => {
|
|
190
|
+
renderToString(createElement(ContextReader));
|
|
191
|
+
}).toThrow('useMCPAppContext must be used within an MCPAppProvider');
|
|
192
|
+
});
|
|
193
|
+
test('error message includes guidance about renderMCPApp', () => {
|
|
194
|
+
try {
|
|
195
|
+
renderToString(createElement(ContextReader));
|
|
196
|
+
// Should not reach here
|
|
197
|
+
expect(true).toBe(false);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
expect(error.message).toContain('renderMCPApp');
|
|
201
|
+
expect(error.message).toContain('MCPAppProvider');
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
describe('useMCPHostContext', () => {
|
|
206
|
+
test('throws when used outside MCPAppProvider', () => {
|
|
207
|
+
expect(() => {
|
|
208
|
+
renderToString(createElement(HostContextReader));
|
|
209
|
+
}).toThrow('useMCPAppContext must be used within an MCPAppProvider');
|
|
210
|
+
});
|
|
211
|
+
test('returns undefined when not connected', () => {
|
|
212
|
+
mockUseAppReturn = {
|
|
213
|
+
app: null,
|
|
214
|
+
isConnected: false,
|
|
215
|
+
error: null,
|
|
216
|
+
};
|
|
217
|
+
const text = renderText(HostContextReader);
|
|
218
|
+
expect(text).toBe('undefined');
|
|
219
|
+
});
|
|
220
|
+
test('returns host context when connected', () => {
|
|
221
|
+
const context = {
|
|
222
|
+
theme: 'light',
|
|
223
|
+
displayMode: 'full',
|
|
224
|
+
platform: 'darwin',
|
|
225
|
+
};
|
|
226
|
+
mockUseAppReturn = {
|
|
227
|
+
app: { getHostContext: () => context },
|
|
228
|
+
isConnected: true,
|
|
229
|
+
error: null,
|
|
230
|
+
};
|
|
231
|
+
const text = renderText(HostContextReader);
|
|
232
|
+
expect(JSON.parse(text)).toEqual(context);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
describe('useMCPHostTheme', () => {
|
|
236
|
+
test('returns light theme by default', () => {
|
|
237
|
+
mockUseDocumentThemeReturn = 'light';
|
|
238
|
+
const text = renderText(ThemeReader);
|
|
239
|
+
expect(text).toBe('light');
|
|
240
|
+
});
|
|
241
|
+
test('returns dark theme when host is dark', () => {
|
|
242
|
+
mockUseDocumentThemeReturn = 'dark';
|
|
243
|
+
const text = renderText(ThemeReader);
|
|
244
|
+
expect(text).toBe('dark');
|
|
245
|
+
});
|
|
246
|
+
test('delegates to useDocumentTheme from ext-apps', () => {
|
|
247
|
+
// The mock already tracks that useDocumentTheme is called.
|
|
248
|
+
// If it weren't called, useMCPHostTheme would not return the mock value.
|
|
249
|
+
mockUseDocumentThemeReturn = 'dark';
|
|
250
|
+
const text = renderText(ThemeReader);
|
|
251
|
+
// This confirms useMCPHostTheme delegates to our mocked useDocumentTheme
|
|
252
|
+
expect(text).toBe('dark');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Options for rendering an MCP App.
|
|
4
|
+
*/
|
|
5
|
+
export interface RenderMCPAppOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Component to show while waiting for props.
|
|
8
|
+
* @default A simple "Loading..." div
|
|
9
|
+
*/
|
|
10
|
+
loading?: ComponentType;
|
|
11
|
+
/**
|
|
12
|
+
* ID of the root element to mount the app.
|
|
13
|
+
* @default 'root'
|
|
14
|
+
*/
|
|
15
|
+
rootId?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Whether to wrap the app in React.StrictMode.
|
|
18
|
+
* @default true
|
|
19
|
+
*/
|
|
20
|
+
strictMode?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Render a React component as an MCP App.
|
|
24
|
+
*
|
|
25
|
+
* This helper sets up the React root and handles props subscription,
|
|
26
|
+
* so your component can be a regular React component that receives props -
|
|
27
|
+
* no MCP-specific code required in your component.
|
|
28
|
+
*
|
|
29
|
+
* @param Component - Your React component (receives props from the MCP handler)
|
|
30
|
+
* @param options - Optional configuration for loading state and root element
|
|
31
|
+
*
|
|
32
|
+
* @example Basic usage with an existing component
|
|
33
|
+
* ```tsx
|
|
34
|
+
* // src/apps/stats.tsx
|
|
35
|
+
* import { renderMCPApp } from '@mcp-web/app';
|
|
36
|
+
* import { Stats } from '../components/Stats';
|
|
37
|
+
*
|
|
38
|
+
* // Stats is a regular component: function Stats({ total, completed }: StatsProps) { ... }
|
|
39
|
+
* renderMCPApp(Stats);
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @example With custom loading component
|
|
43
|
+
* ```tsx
|
|
44
|
+
* import { renderMCPApp } from '@mcp-web/app';
|
|
45
|
+
* import { Stats } from '../components/Stats';
|
|
46
|
+
* import { Spinner } from '../components/Spinner';
|
|
47
|
+
*
|
|
48
|
+
* renderMCPApp(Stats, {
|
|
49
|
+
* loading: Spinner,
|
|
50
|
+
* });
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* @example Inline component definition
|
|
54
|
+
* ```tsx
|
|
55
|
+
* import { renderMCPApp } from '@mcp-web/app';
|
|
56
|
+
*
|
|
57
|
+
* interface ChartProps {
|
|
58
|
+
* data: number[];
|
|
59
|
+
* title: string;
|
|
60
|
+
* }
|
|
61
|
+
*
|
|
62
|
+
* renderMCPApp<ChartProps>(({ data, title }) => (
|
|
63
|
+
* <div>
|
|
64
|
+
* <h1>{title}</h1>
|
|
65
|
+
* <Chart data={data} />
|
|
66
|
+
* </div>
|
|
67
|
+
* ));
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare function renderMCPApp<P extends Record<string, unknown>>(Component: ComponentType<P>, options?: RenderMCPAppOptions): void;
|
|
71
|
+
//# sourceMappingURL=render-mcp-app.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render-mcp-app.d.ts","sourceRoot":"","sources":["../src/render-mcp-app.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAM3C;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC;IAExB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AA0CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5D,SAAS,EAAE,aAAa,CAAC,CAAC,CAAC,EAC3B,OAAO,GAAE,mBAAwB,GAChC,IAAI,CAoBN"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
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
|
+
* Default loading component shown while waiting for props.
|
|
8
|
+
* Uses host CSS variable with fallback for theme-aware loading text.
|
|
9
|
+
*/
|
|
10
|
+
function DefaultLoading() {
|
|
11
|
+
return (_jsx("div", { style: {
|
|
12
|
+
display: 'flex',
|
|
13
|
+
alignItems: 'center',
|
|
14
|
+
justifyContent: 'center',
|
|
15
|
+
height: '100%',
|
|
16
|
+
fontFamily: 'var(--font-sans, system-ui, sans-serif)',
|
|
17
|
+
color: 'var(--color-text-secondary, #666)',
|
|
18
|
+
}, children: "Loading..." }));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Internal wrapper that handles props subscription.
|
|
22
|
+
*/
|
|
23
|
+
function MCPAppWrapper({ Component, Loading, }) {
|
|
24
|
+
const props = useMCPAppProps();
|
|
25
|
+
if (!props) {
|
|
26
|
+
return _jsx(Loading, {});
|
|
27
|
+
}
|
|
28
|
+
return _jsx(Component, { ...props });
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Render a React component as an MCP App.
|
|
32
|
+
*
|
|
33
|
+
* This helper sets up the React root and handles props subscription,
|
|
34
|
+
* so your component can be a regular React component that receives props -
|
|
35
|
+
* no MCP-specific code required in your component.
|
|
36
|
+
*
|
|
37
|
+
* @param Component - Your React component (receives props from the MCP handler)
|
|
38
|
+
* @param options - Optional configuration for loading state and root element
|
|
39
|
+
*
|
|
40
|
+
* @example Basic usage with an existing component
|
|
41
|
+
* ```tsx
|
|
42
|
+
* // src/apps/stats.tsx
|
|
43
|
+
* import { renderMCPApp } from '@mcp-web/app';
|
|
44
|
+
* import { Stats } from '../components/Stats';
|
|
45
|
+
*
|
|
46
|
+
* // Stats is a regular component: function Stats({ total, completed }: StatsProps) { ... }
|
|
47
|
+
* renderMCPApp(Stats);
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @example With custom loading component
|
|
51
|
+
* ```tsx
|
|
52
|
+
* import { renderMCPApp } from '@mcp-web/app';
|
|
53
|
+
* import { Stats } from '../components/Stats';
|
|
54
|
+
* import { Spinner } from '../components/Spinner';
|
|
55
|
+
*
|
|
56
|
+
* renderMCPApp(Stats, {
|
|
57
|
+
* loading: Spinner,
|
|
58
|
+
* });
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* @example Inline component definition
|
|
62
|
+
* ```tsx
|
|
63
|
+
* import { renderMCPApp } from '@mcp-web/app';
|
|
64
|
+
*
|
|
65
|
+
* interface ChartProps {
|
|
66
|
+
* data: number[];
|
|
67
|
+
* title: string;
|
|
68
|
+
* }
|
|
69
|
+
*
|
|
70
|
+
* renderMCPApp<ChartProps>(({ data, title }) => (
|
|
71
|
+
* <div>
|
|
72
|
+
* <h1>{title}</h1>
|
|
73
|
+
* <Chart data={data} />
|
|
74
|
+
* </div>
|
|
75
|
+
* ));
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export function renderMCPApp(Component, options = {}) {
|
|
79
|
+
const { loading: Loading = DefaultLoading, rootId = 'root', strictMode = true } = options;
|
|
80
|
+
const rootElement = document.getElementById(rootId);
|
|
81
|
+
if (!rootElement) {
|
|
82
|
+
throw new Error(`[renderMCPApp] Could not find element with id "${rootId}". ` +
|
|
83
|
+
`Make sure your HTML has <div id="${rootId}"></div>`);
|
|
84
|
+
}
|
|
85
|
+
const app = (_jsx(Suspense, { fallback: _jsx(Loading, {}), children: _jsx(MCPAppProvider, { children: _jsx(MCPAppWrapper, { Component: Component, Loading: Loading }) }) }));
|
|
86
|
+
createRoot(rootElement).render(strictMode ? _jsx(StrictMode, { children: app }) : app);
|
|
87
|
+
}
|