Skip to content

Commit 4ea299f

Browse files
QuantGeekDevclaude
andcommitted
feat: add React support for MCP Apps
Add --react flag to both `mcp add app` and `mcp add tool`: mcp add app my-dashboard --react # Mode A: standalone React app mcp add tool my-widget --react # Mode B: tool with React UI Generated files include: - React TSX component with useApp() + host theme integration - CSS with host variable fallbacks (light/dark support) - Vite config with react() + viteSingleFile() plugins - Client-side tsconfig with react-jsx, DOM libs, bundler resolution - HTML shell entry point for Vite Uses @modelcontextprotocol/ext-apps/react hooks (useApp) for the iframe-side protocol — no reinvention of the wheel. Shared template logic extracted to src/cli/templates/react-app.ts for reuse between app and tool scaffolding commands. 23 new unit tests for React template generation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 277b511 commit 4ea299f

5 files changed

Lines changed: 589 additions & 29 deletions

File tree

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)