Skip to content

Commit a385629

Browse files
authored
Merge pull request #158 from QuantGeekDev/feat/mcp-apps-react
feat: add React support for MCP Apps
2 parents 277b511 + 9d67841 commit a385629

File tree

6 files changed

+653
-29
lines changed

6 files changed

+653
-29
lines changed

docs/apps.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,70 @@ await app.connect();
256256
257257
React hooks are available via `@modelcontextprotocol/ext-apps/react`.
258258
259+
### React Apps
260+
261+
The fastest way to create a React-based MCP App:
262+
263+
```bash
264+
# Standalone app (Mode A)
265+
mcp add app my-dashboard --react
266+
267+
# Individual tool with React UI (Mode B)
268+
mcp add tool my-widget --react
269+
```
270+
271+
This generates a complete React project in `src/app-views/<name>/`:
272+
273+
```
274+
src/app-views/my-dashboard/
275+
├── App.tsx # React component with useApp() wired up
276+
├── styles.css # Host theme fallbacks + base styles
277+
├── index.html # Vite entry point
278+
├── vite.config.ts # react() + viteSingleFile()
279+
└── tsconfig.json # Client-side config (react-jsx, DOM)
280+
```
281+
282+
Install the React dependencies:
283+
284+
```bash
285+
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk react react-dom
286+
npm install -D @types/react @types/react-dom @vitejs/plugin-react vite vite-plugin-singlefile
287+
```
288+
289+
Build the view into a single HTML file:
290+
291+
```bash
292+
cd src/app-views/my-dashboard && npx vite build
293+
```
294+
295+
The generated React component uses `useApp()` from `@modelcontextprotocol/ext-apps/react` to handle the MCP Apps protocol automatically:
296+
297+
```tsx
298+
import { useApp } from "@modelcontextprotocol/ext-apps/react";
299+
300+
function MyDashboard() {
301+
const [toolResult, setToolResult] = useState(null);
302+
303+
const { app, error } = useApp({
304+
appInfo: { name: "my-dashboard", version: "1.0.0" },
305+
onAppCreated: (app) => {
306+
app.ontoolresult = (result) => setToolResult(result);
307+
},
308+
});
309+
310+
if (!app) return <div>Connecting...</div>;
311+
312+
return (
313+
<div>
314+
<h2>Dashboard</h2>
315+
{/* Your interactive UI here */}
316+
</div>
317+
);
318+
}
319+
```
320+
321+
Host theme variables are automatically applied, so your app matches the look of Claude, ChatGPT, or VS Code.
322+
259323
## UI Configuration
260324
261325
### Content Security Policy (CSP)

src/cli/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ program
3939
new Command('tool')
4040
.description('Add a new tool')
4141
.argument('[name]', 'tool name')
42-
.action(addTool)
42+
.option('--react', 'generate a React-based tool with interactive UI')
43+
.action((name, opts) => addTool(name, opts))
4344
)
4445
.addCommand(
4546
new Command('prompt')
@@ -57,7 +58,8 @@ program
5758
new Command('app')
5859
.description('Add a new app with interactive UI')
5960
.argument('[name]', 'app name')
60-
.action(addApp)
61+
.option('--react', 'generate a React-based app view')
62+
.action((name, opts) => addApp(name, opts))
6163
);
6264

6365
program.addCommand(validateCommand);

src/cli/project/add-app.ts

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ import { join } from 'path';
33
import prompts from 'prompts';
44
import { validateMCPProject } from '../utils/validate-project.js';
55
import { toPascalCase } from '../utils/string-utils.js';
6-
7-
export async function addApp(name?: string) {
6+
import {
7+
generateReactHtmlShell,
8+
generateReactApp,
9+
generateReactStyles,
10+
generateViteConfig,
11+
generateTsconfigApp,
12+
getReactInstallInstructions,
13+
} from '../templates/react-app.js';
14+
15+
export async function addApp(name?: string, options?: { react?: boolean }) {
816
await validateMCPProject();
917

1018
let appName = name;
@@ -33,6 +41,7 @@ export async function addApp(name?: string) {
3341
throw new Error('App name is required');
3442
}
3543

44+
const useReact = options?.react ?? false;
3645
const className = toPascalCase(appName);
3746
const fileName = `${className}App.ts`;
3847
const appsDir = join(process.cwd(), 'src/apps');
@@ -42,22 +51,83 @@ export async function addApp(name?: string) {
4251
await mkdir(appsDir, { recursive: true });
4352
await mkdir(viewsDir, { recursive: true });
4453

45-
const appContent = generateAppClass(appName, className);
46-
const htmlContent = generateHtmlView(appName, className);
47-
48-
await writeFile(join(appsDir, fileName), appContent);
49-
await writeFile(join(viewsDir, 'index.html'), htmlContent);
50-
51-
console.log(`App ${appName} created successfully:`);
52-
console.log(` - App class: src/apps/${fileName}`);
53-
console.log(` - HTML view: src/app-views/${appName}/index.html`);
54+
if (useReact) {
55+
await writeFile(join(appsDir, fileName), generateReactAppClass(appName, className));
56+
await writeFile(join(viewsDir, 'index.html'), generateReactHtmlShell(appName));
57+
await writeFile(join(viewsDir, 'App.tsx'), generateReactApp(appName, className));
58+
await writeFile(join(viewsDir, 'styles.css'), generateReactStyles());
59+
await writeFile(join(viewsDir, 'vite.config.ts'), generateViteConfig());
60+
await writeFile(join(viewsDir, 'tsconfig.json'), generateTsconfigApp());
61+
62+
console.log(`React app ${appName} created successfully:`);
63+
console.log(` - App class: src/apps/${fileName}`);
64+
console.log(` - React entry: src/app-views/${appName}/App.tsx`);
65+
console.log(` - Styles: src/app-views/${appName}/styles.css`);
66+
console.log(` - Vite config: src/app-views/${appName}/vite.config.ts`);
67+
console.log(getReactInstallInstructions().replace(/<name>/g, appName));
68+
} else {
69+
await writeFile(join(appsDir, fileName), generateVanillaAppClass(appName, className));
70+
await writeFile(join(viewsDir, 'index.html'), generateVanillaHtmlView(appName, className));
71+
72+
console.log(`App ${appName} created successfully:`);
73+
console.log(` - App class: src/apps/${fileName}`);
74+
console.log(` - HTML view: src/app-views/${appName}/index.html`);
75+
}
5476
} catch (error) {
5577
console.error('Error creating app:', error);
5678
process.exit(1);
5779
}
5880
}
5981

60-
function generateAppClass(appName: string, className: string): string {
82+
// ── React App Class (Mode A) ──────────────────────────────────────────────────
83+
84+
function generateReactAppClass(appName: string, className: string): string {
85+
return `import { MCPApp } from "mcp-framework";
86+
import { z } from "zod";
87+
import { readFileSync } from "fs";
88+
import { join, dirname } from "path";
89+
import { fileURLToPath } from "url";
90+
91+
const __dirname = dirname(fileURLToPath(import.meta.url));
92+
93+
class ${className}App extends MCPApp {
94+
name = "${appName}";
95+
96+
ui = {
97+
resourceUri: "ui://${appName}/view",
98+
resourceName: "${className}",
99+
resourceDescription: "${className} interactive view",
100+
};
101+
102+
getContent() {
103+
// Reads the Vite-bundled single HTML file
104+
return readFileSync(
105+
join(__dirname, "../../app-views/${appName}/dist/index.html"),
106+
"utf-8"
107+
);
108+
}
109+
110+
tools = [
111+
{
112+
name: "${appName}_show",
113+
description: "Display the ${className} view",
114+
schema: z.object({
115+
query: z.string().describe("Input query"),
116+
}),
117+
execute: async (input: { query: string }) => {
118+
return { result: \`Processed: \${input.query}\` };
119+
},
120+
},
121+
];
122+
}
123+
124+
export default ${className}App;
125+
`;
126+
}
127+
128+
// ── Vanilla Templates (unchanged) ─────────────────────────────────────────────
129+
130+
function generateVanillaAppClass(appName: string, className: string): string {
61131
return `import { MCPApp } from "mcp-framework";
62132
import { z } from "zod";
63133
import { readFileSync } from "fs";
@@ -100,7 +170,7 @@ export default ${className}App;
100170
`;
101171
}
102172

103-
function generateHtmlView(appName: string, className: string): string {
173+
function generateVanillaHtmlView(appName: string, className: string): string {
104174
return `<!DOCTYPE html>
105175
<html lang="en">
106176
<head>
@@ -151,23 +221,20 @@ function generateHtmlView(appName: string, className: string): string {
151221
protocolVersion: "2026-01-26",
152222
});
153223
154-
// Apply host theme
155224
const vars = init.hostContext?.styles?.variables;
156225
if (vars) {
157226
for (const [key, value] of Object.entries(vars)) {
158227
if (value) document.documentElement.style.setProperty(key, value);
159228
}
160229
}
161230
162-
// Handle tool input
163231
onNotification("ui/notifications/tool-input", (params) => {
164232
document.getElementById("app").innerHTML =
165233
"<h2>${className}</h2><pre>" +
166234
JSON.stringify(params.arguments, null, 2) +
167235
"</pre>";
168236
});
169237
170-
// Handle tool result
171238
onNotification("ui/notifications/tool-result", (params) => {
172239
const text = params.content?.[0]?.text ?? JSON.stringify(params);
173240
document.getElementById("app").innerHTML =

src/cli/project/add-tool.ts

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ import { join } from "path";
33
import prompts from "prompts";
44
import { validateMCPProject } from "../utils/validate-project.js";
55
import { toPascalCase } from "../utils/string-utils.js";
6+
import {
7+
generateReactHtmlShell,
8+
generateReactApp,
9+
generateReactStyles,
10+
generateViteConfig,
11+
generateTsconfigApp,
12+
getReactInstallInstructions,
13+
} from "../templates/react-app.js";
614

7-
export async function addTool(name?: string) {
15+
export async function addTool(name?: string, options?: { react?: boolean }) {
816
await validateMCPProject();
917

1018
let toolName = name;
@@ -33,39 +41,106 @@ export async function addTool(name?: string) {
3341
throw new Error("Tool name is required");
3442
}
3543

44+
const useReact = options?.react ?? false;
3645
const className = toPascalCase(toolName);
3746
const fileName = `${className}Tool.ts`;
3847
const toolsDir = join(process.cwd(), "src/tools");
3948

4049
try {
4150
await mkdir(toolsDir, { recursive: true });
4251

43-
const toolContent = `import { MCPTool, MCPInput } from "mcp-framework";
52+
if (useReact) {
53+
// Generate tool with app property (Mode B) + React view
54+
const viewsDir = join(process.cwd(), "src/app-views", toolName);
55+
await mkdir(viewsDir, { recursive: true });
56+
57+
await writeFile(join(toolsDir, fileName), generateReactToolContent(toolName, className));
58+
await writeFile(join(viewsDir, "index.html"), generateReactHtmlShell(toolName));
59+
await writeFile(join(viewsDir, "App.tsx"), generateReactApp(toolName, className));
60+
await writeFile(join(viewsDir, "styles.css"), generateReactStyles());
61+
await writeFile(join(viewsDir, "vite.config.ts"), generateViteConfig());
62+
await writeFile(join(viewsDir, "tsconfig.json"), generateTsconfigApp());
63+
64+
console.log(
65+
`React tool ${toolName} created successfully with interactive UI:`
66+
);
67+
console.log(` - Tool class: src/tools/${fileName}`);
68+
console.log(` - React entry: src/app-views/${toolName}/App.tsx`);
69+
console.log(` - Styles: src/app-views/${toolName}/styles.css`);
70+
console.log(` - Vite config: src/app-views/${toolName}/vite.config.ts`);
71+
console.log(getReactInstallInstructions().replace(/<name>/g, toolName));
72+
} else {
73+
await writeFile(join(toolsDir, fileName), generateToolContent(toolName, className));
74+
console.log(
75+
`Tool ${toolName} created successfully at src/tools/${fileName}`
76+
);
77+
}
78+
} catch (error) {
79+
console.error("Error creating tool:", error);
80+
process.exit(1);
81+
}
82+
}
83+
84+
// ── React Tool (Mode B with app property) ─────────────────────────────────────
85+
86+
function generateReactToolContent(toolName: string, className: string): string {
87+
return `import { MCPTool, MCPInput } from "mcp-framework";
4488
import { z } from "zod";
89+
import { readFileSync } from "fs";
90+
import { join, dirname } from "path";
91+
import { fileURLToPath } from "url";
92+
93+
const __dirname = dirname(fileURLToPath(import.meta.url));
4594
4695
const schema = z.object({
4796
message: z.string().describe("Message to process"),
4897
});
4998
5099
class ${className}Tool extends MCPTool {
51100
name = "${toolName}";
52-
description = "${className} tool description";
101+
description = "${className} tool with interactive UI";
53102
schema = schema;
54103
104+
// Attach a React-based MCP App UI to this tool
105+
app = {
106+
resourceUri: "ui://${toolName}/view",
107+
resourceName: "${className} View",
108+
content: () =>
109+
readFileSync(
110+
join(__dirname, "../../app-views/${toolName}/dist/index.html"),
111+
"utf-8"
112+
),
113+
};
114+
55115
async execute(input: MCPInput<this>) {
116+
// This return value is the text fallback for non-UI hosts
56117
return \`Processed: \${input.message}\`;
57118
}
58119
}
59120
60-
export default ${className}Tool;`;
121+
export default ${className}Tool;
122+
`;
123+
}
61124

62-
await writeFile(join(toolsDir, fileName), toolContent);
125+
// ── Vanilla Tool (unchanged) ──────────────────────────────────────────────────
63126

64-
console.log(
65-
`Tool ${toolName} created successfully at src/tools/${fileName}`
66-
);
67-
} catch (error) {
68-
console.error("Error creating tool:", error);
69-
process.exit(1);
127+
function generateToolContent(toolName: string, className: string): string {
128+
return `import { MCPTool, MCPInput } from "mcp-framework";
129+
import { z } from "zod";
130+
131+
const schema = z.object({
132+
message: z.string().describe("Message to process"),
133+
});
134+
135+
class ${className}Tool extends MCPTool {
136+
name = "${toolName}";
137+
description = "${className} tool description";
138+
schema = schema;
139+
140+
async execute(input: MCPInput<this>) {
141+
return \`Processed: \${input.message}\`;
70142
}
71143
}
144+
145+
export default ${className}Tool;`;
146+
}

0 commit comments

Comments
 (0)