sunpeak 0.15.4 → 0.16.2

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.
Files changed (105) hide show
  1. package/README.md +53 -49
  2. package/bin/commands/build.mjs +119 -6
  3. package/bin/commands/dev.mjs +168 -27
  4. package/bin/commands/new.mjs +13 -3
  5. package/bin/commands/start.mjs +215 -0
  6. package/bin/lib/extract-resource.mjs +1 -1
  7. package/bin/lib/extract-tool.mjs +78 -0
  8. package/bin/lib/patterns.mjs +2 -26
  9. package/bin/sunpeak.js +11 -1
  10. package/dist/chatgpt/index.cjs +3 -6
  11. package/dist/chatgpt/index.cjs.map +1 -1
  12. package/dist/chatgpt/index.d.ts +1 -1
  13. package/dist/chatgpt/index.js +6 -9
  14. package/dist/claude/index.cjs +1 -1
  15. package/dist/claude/index.js +1 -1
  16. package/dist/discovery-CH80W5l9.js +217 -0
  17. package/dist/discovery-CH80W5l9.js.map +1 -0
  18. package/dist/discovery-DmB8_4QL.cjs +216 -0
  19. package/dist/discovery-DmB8_4QL.cjs.map +1 -0
  20. package/dist/{index-Cngntkp2.cjs → index-Bll1bszc.cjs} +3 -6
  21. package/dist/{index-Cngntkp2.cjs.map → index-Bll1bszc.cjs.map} +1 -1
  22. package/dist/{index-Ce_5ZIdJ.js → index-CACtnwu2.js} +3 -6
  23. package/dist/{index-Ce_5ZIdJ.js.map → index-CACtnwu2.js.map} +1 -1
  24. package/dist/{index-CutQgPzR.js → index-CLcr8IyR.js} +3 -6
  25. package/dist/index-CLcr8IyR.js.map +1 -0
  26. package/dist/{index-B0dxRJvS.cjs → index-CaQmwZJc.cjs} +3 -6
  27. package/dist/index-CaQmwZJc.cjs.map +1 -0
  28. package/dist/index.cjs +49 -6
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.ts +3 -0
  31. package/dist/index.js +3405 -3362
  32. package/dist/index.js.map +1 -1
  33. package/dist/lib/discovery-cli.cjs +58 -5
  34. package/dist/lib/discovery-cli.cjs.map +1 -1
  35. package/dist/lib/discovery-cli.d.ts +3 -2
  36. package/dist/lib/discovery-cli.js +61 -8
  37. package/dist/lib/discovery-cli.js.map +1 -1
  38. package/dist/lib/discovery.d.ts +42 -43
  39. package/dist/lib/extract-tool.d.ts +12 -0
  40. package/dist/mcp/favicon.d.ts +1 -1
  41. package/dist/mcp/index.cjs +1582 -5
  42. package/dist/mcp/index.cjs.map +1 -1
  43. package/dist/mcp/index.d.ts +3 -1
  44. package/dist/mcp/index.js +1583 -6
  45. package/dist/mcp/index.js.map +1 -1
  46. package/dist/mcp/production-server.d.ts +156 -0
  47. package/dist/mcp/types.d.ts +24 -1
  48. package/dist/platform/chatgpt/index.cjs +1 -1
  49. package/dist/platform/chatgpt/index.js +1 -1
  50. package/dist/{protocol-DFbsCx7E.js → protocol-BD5jDQEx.js} +8 -1
  51. package/dist/{protocol-DFbsCx7E.js.map → protocol-BD5jDQEx.js.map} +1 -1
  52. package/dist/{protocol-CL4_Npj5.cjs → protocol-BOjXuK6l.cjs} +8 -1
  53. package/dist/{protocol-CL4_Npj5.cjs.map → protocol-BOjXuK6l.cjs.map} +1 -1
  54. package/dist/simulator/index.cjs +2 -5
  55. package/dist/simulator/index.cjs.map +1 -1
  56. package/dist/simulator/index.d.ts +1 -1
  57. package/dist/simulator/index.js +5 -8
  58. package/dist/simulator/simulator-url.d.ts +9 -9
  59. package/dist/{simulator-CxrtnguM.js → simulator-B7rw83zP.js} +9 -3
  60. package/dist/{simulator-CxrtnguM.js.map → simulator-B7rw83zP.js.map} +1 -1
  61. package/dist/{simulator-DcfQBRXE.cjs → simulator-DjZNa1MI.cjs} +9 -3
  62. package/dist/{simulator-DcfQBRXE.cjs.map → simulator-DjZNa1MI.cjs.map} +1 -1
  63. package/dist/simulator-url-CuLqtnSS.js.map +1 -1
  64. package/dist/simulator-url-rgg_KYOg.cjs.map +1 -1
  65. package/dist/types/resource-config.d.ts +7 -5
  66. package/dist/{use-app-BnoSPiUT.cjs → use-app-BpAJqzdE.cjs} +50 -21
  67. package/dist/{use-app-BnoSPiUT.cjs.map → use-app-BpAJqzdE.cjs.map} +1 -1
  68. package/dist/{use-app-D_TeaMFG.js → use-app-WOUdh1PR.js} +52 -23
  69. package/dist/{use-app-D_TeaMFG.js.map → use-app-WOUdh1PR.js.map} +1 -1
  70. package/package.json +1 -1
  71. package/template/.sunpeak/dev.tsx +8 -4
  72. package/template/.sunpeak/resource-loader.tsx +2 -1
  73. package/template/README.md +27 -22
  74. package/template/package.json +3 -1
  75. package/template/src/resources/albums/{albums-resource.test.tsx → albums.test.tsx} +1 -1
  76. package/template/src/resources/albums/{albums-resource.tsx → albums.tsx} +0 -1
  77. package/template/src/resources/carousel/{carousel-resource.test.tsx → carousel.test.tsx} +1 -1
  78. package/template/src/resources/carousel/{carousel-resource.tsx → carousel.tsx} +0 -1
  79. package/template/src/resources/index.ts +4 -4
  80. package/template/src/resources/map/{map-resource.test.tsx → map.test.tsx} +1 -1
  81. package/template/src/resources/map/{map-resource.tsx → map.tsx} +0 -1
  82. package/template/src/resources/review/{review-resource.test.tsx → review.test.tsx} +1 -1
  83. package/template/src/resources/review/{review-resource.tsx → review.tsx} +1 -2
  84. package/template/src/server.ts +17 -0
  85. package/template/src/tools/review-diff.ts +24 -0
  86. package/template/src/tools/review-post.ts +26 -0
  87. package/template/src/tools/review-purchase.ts +31 -0
  88. package/template/src/tools/show-albums.ts +22 -0
  89. package/template/src/tools/show-carousel.ts +25 -0
  90. package/template/src/tools/show-map.ts +29 -0
  91. package/template/tests/e2e/albums.spec.ts +6 -6
  92. package/template/tests/e2e/carousel.spec.ts +6 -6
  93. package/template/tests/e2e/map.spec.ts +11 -11
  94. package/template/tests/simulations/{review/review-diff-simulation.json → review-diff.json} +1 -31
  95. package/template/tests/simulations/{review/review-post-simulation.json → review-post.json} +1 -37
  96. package/template/tests/simulations/{review/review-purchase-simulation.json → review-purchase.json} +1 -38
  97. package/template/tests/simulations/{albums/albums-show-simulation.json → show-albums.json} +1 -24
  98. package/template/tests/simulations/{carousel/carousel-show-simulation.json → show-carousel.json} +1 -24
  99. package/template/tests/simulations/{map/map-show-simulation.json → show-map.json} +1 -35
  100. package/dist/discovery-CRR3SlyI.cjs +0 -156
  101. package/dist/discovery-CRR3SlyI.cjs.map +0 -1
  102. package/dist/discovery-DzV3HLXs.js +0 -157
  103. package/dist/discovery-DzV3HLXs.js.map +0 -1
  104. package/dist/index-B0dxRJvS.cjs.map +0 -1
  105. package/dist/index-CutQgPzR.js.map +0 -1
package/README.md CHANGED
@@ -61,21 +61,26 @@ Next.js for MCP Apps. Using an example App `my-app` with a `Review` UI (MCP reso
61
61
 
62
62
  ```bash
63
63
  my-app/
64
- ├── src/resources/
65
- └── review/
66
- └── review-resource.tsx # Review UI component + resource metadata.
64
+ ├── src/
65
+ ├── resources/
66
+ └── review/
67
+ │ │ └── review.tsx # Review UI component + resource metadata.
68
+ │ ├── tools/
69
+ │ │ ├── review-diff.ts # Tool with handler, schema, and resource reference.
70
+ │ │ └── review-post.ts # Multiple tools can share one resource.
71
+ │ └── server.ts # Optional: auth, server config.
67
72
  ├── tests/simulations/
68
- └── review/
69
- ├── review-{scenario1}-simulation.json # Mock state for testing.
70
- │ └── review-{scenario2}-simulation.json # Mock state for testing.
73
+ ├── review-diff.json # Mock state for testing.
74
+ └── review-post.json # Mock state for testing.
71
75
  └── package.json
72
76
  ```
73
77
 
74
78
  1. Project scaffold: Complete development setup with the `sunpeak` library.
75
79
  2. UI components: Production-ready components following MCP App design guidelines.
76
80
  3. Convention over configuration:
77
- 1. Create a UI by creating a `-resource.tsx` file that exports a `ResourceConfig` and a React component ([example below](#resource-component)).
78
- 2. Create test state (`Simulation`s) for local dev, host dev, automated testing, and demos by creating a `-simulation.json` file. ([example below](#simulation))
81
+ 1. Create a UI by creating a `.tsx` file in `src/resources/{name}/` that exports a `ResourceConfig` and a React component ([example below](#resource-component)).
82
+ 2. Create a tool by creating a `.ts` file in `src/tools/` that exports `tool` (metadata with resource reference), `schema` (Zod), and a `default` handler ([example below](#tool-file)).
83
+ 3. Create test state (`Simulation`s) by creating a `.json` file in `tests/simulations/` ([example below](#simulation)).
79
84
 
80
85
  ### The `sunpeak` CLI
81
86
 
@@ -83,7 +88,8 @@ Commands for managing MCP Apps:
83
88
 
84
89
  - `sunpeak new [name] [resources]` - Create a new project
85
90
  - `sunpeak dev` - Start dev server with MCP endpoint and live simulator
86
- - `sunpeak build` - Build resources for production
91
+ - `sunpeak build` - Build resources and compile tools for production
92
+ - `sunpeak start` - Start the production MCP server (real handlers, auth, Zod validation)
87
93
  - `sunpeak upgrade` - Upgrade sunpeak to latest version
88
94
 
89
95
  ## Example App
@@ -92,23 +98,15 @@ Example `Resource`, `Simulation`, and testing file (using the `Simulator`) for a
92
98
 
93
99
  ### `Resource` Component
94
100
 
95
- ```bash
96
- my-app/
97
- ├── src/resources/
98
- │ └── review/
99
- │ └── review-resource.tsx # This!
100
- ```
101
-
102
101
  Each resource `.tsx` file exports both the React component and the MCP resource metadata:
103
102
 
104
103
  ```tsx
105
- // src/resources/review/review-resource.tsx
104
+ // src/resources/review/review.tsx
106
105
 
107
106
  import { useToolData } from 'sunpeak';
108
107
  import type { ResourceConfig } from 'sunpeak';
109
108
 
110
109
  export const resource: ResourceConfig = {
111
- name: 'review',
112
110
  description: 'Visualize and review a code change',
113
111
  _meta: { ui: { csp: { resourceDomains: ['https://cdn.example.com'] } } },
114
112
  };
@@ -120,48 +118,54 @@ export function ReviewResource() {
120
118
  }
121
119
  ```
122
120
 
123
- ### `Simulation`
121
+ ### Tool File
124
122
 
125
- ```bash
126
- ├── tests/simulations/
127
- │ └── review/
128
- │ ├── review-{scenario1}-simulation.json # These!
129
- │ └── review-{scenario2}-simulation.json # These!
130
- ```
123
+ Each tool `.ts` file exports metadata (with a direct resource reference), a Zod schema, and a handler:
124
+
125
+ ```ts
126
+ // src/tools/review-diff.ts
131
127
 
132
- `sunpeak` testing object (`.json`) defining key App-owned states.
128
+ import { z } from 'zod';
129
+ import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
133
130
 
134
- Testing an MCP App requires setting a lot of state: state in your **backend**, **MCP tools**, and the **host runtime**.
131
+ export const tool: AppToolConfig = {
132
+ resource: 'review',
133
+ title: 'Diff Review',
134
+ description: 'Show a review dialog for a proposed code diff',
135
+ annotations: { readOnlyHint: false },
136
+ _meta: { ui: { visibility: ['model', 'app'] } },
137
+ };
138
+
139
+ export const schema = {
140
+ changesetId: z.string().describe('Unique identifier for the changeset'),
141
+ title: z.string().describe('Title describing the changes'),
142
+ };
143
+
144
+ export default async function (args: Record<string, unknown>, extra: ToolHandlerExtra) {
145
+ return { structuredContent: { title: args.title, sections: [] } };
146
+ }
147
+ ```
148
+
149
+ ### `Simulation`
135
150
 
136
- `Simulation` files contain an [official MCP tool object](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool), `toolInput` (the arguments sent to CallTool), and `toolResult` (the [CallToolResult](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#structured-content) response) so you can define **backend**, **tool**, and **runtime** states for testing.
151
+ Simulation files provide fixture data for testing. Each references a tool by filename and contains the mock input/output:
137
152
 
138
153
  ```jsonc
139
- // tests/simulations/review-diff-simulation.json
154
+ // tests/simulations/review-diff.json
140
155
 
141
156
  {
142
- // Official MCP tool object.
143
- "tool": {
144
- "name": "review-diff",
145
- "description": "Show a review dialog for a proposed code diff",
146
- "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
147
- "title": "Diff Review",
148
- "annotations": { "readOnlyHint": false },
149
- "_meta": {
150
- "ui": { "visibility": ["model", "app"] },
151
- },
152
- },
153
- // Tool input arguments (sent to CallTool).
157
+ "tool": "review-diff", // References src/tools/review-diff.ts
158
+ "userMessage": "Refactor the auth module to use JWT tokens.",
154
159
  "toolInput": {
155
160
  "changesetId": "cs_789",
156
- "title": "Refactor Authentication Module",
161
+ "title": "Refactor Authentication Module"
157
162
  },
158
- // Tool result data (CallToolResult response).
159
163
  "toolResult": {
160
164
  "structuredContent": {
161
165
  "title": "Refactor Authentication Module",
162
- // ...
163
- },
164
- },
166
+ "sections": [...]
167
+ }
168
+ }
165
169
  }
166
170
  ```
167
171
 
@@ -217,6 +221,6 @@ npx skills add Sunpeak-AI/sunpeak@create-sunpeak-app
217
221
 
218
222
  ## Resources
219
223
 
220
- - [MCP Apps](https://github.com/modelcontextprotocol/ext-apps)
221
- - [MCP Apps SDK Design Guidelines](https://developers.openai.com/apps-sdk/concepts/design-guidelines)
222
- - [MCP Apps SDK Documentation - UI](https://developers.openai.com/apps-sdk/build/chatgpt-ui)
224
+ - [MCP Apps Documentation](https://sunpeak.ai/docs/mcp-apps/introduction)
225
+ - [MCP Apps SDK](https://github.com/modelcontextprotocol/ext-apps)
226
+ - [ChatGPT Apps SDK Design Guidelines](https://developers.openai.com/apps-sdk/concepts/design-guidelines)
@@ -59,6 +59,11 @@ export async function build(projectRoot = process.cwd()) {
59
59
  process.exit(1);
60
60
  }
61
61
 
62
+ // Read project identity from package.json for appInfo
63
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
64
+ const appName = pkg.name || 'sunpeak-app';
65
+ const appVersion = pkg.version || '0.1.0';
66
+
62
67
  // Check if we're in the sunpeak workspace (directory is named "template")
63
68
  const isTemplate = path.basename(projectRoot) === 'template';
64
69
  const parentSrc = path.resolve(projectRoot, '../src');
@@ -153,7 +158,8 @@ export async function build(projectRoot = process.cwd()) {
153
158
  .filter(entry => entry.isDirectory())
154
159
  .map(entry => {
155
160
  const kebabName = entry.name;
156
- const resourceFile = `${kebabName}-resource.tsx`;
161
+
162
+ const resourceFile = `${kebabName}.tsx`;
157
163
  const resourcePath = path.join(resourcesDir, kebabName, resourceFile);
158
164
 
159
165
  // Skip directories without a resource file
@@ -163,10 +169,11 @@ export async function build(projectRoot = process.cwd()) {
163
169
 
164
170
  // Convert kebab-case to PascalCase: 'review' -> 'Review', 'my-widget' -> 'MyWidget'
165
171
  const pascalName = toPascalCase(kebabName);
172
+ const componentFile = resourceFile.replace(/\.tsx$/, '');
166
173
 
167
174
  return {
168
175
  componentName: `${pascalName}Resource`,
169
- componentFile: `${kebabName}-resource`,
176
+ componentFile,
170
177
  kebabName,
171
178
  resourceDir: path.join(resourcesDir, kebabName),
172
179
  entry: `.tmp/index-${kebabName}.tsx`,
@@ -180,7 +187,7 @@ export async function build(projectRoot = process.cwd()) {
180
187
 
181
188
  if (resourceFiles.length === 0) {
182
189
  console.error('Error: No resource directories found in src/resources/');
183
- console.error('Each resource should be a directory like: src/resources/review/review-resource.tsx');
190
+ console.error('Each resource should be a directory like: src/resources/review/review.tsx');
184
191
  process.exit(1);
185
192
  }
186
193
 
@@ -218,7 +225,7 @@ export async function build(projectRoot = process.cwd()) {
218
225
  // Create entry file from template in temp directory
219
226
  const entryContent = template
220
227
  .replace('// RESOURCE_IMPORT', `import { ${componentName}, resource } from '../src/resources/${kebabName}/${componentFile}';`)
221
- .replace('// RESOURCE_MOUNT', `createRoot(root).render(<AppProvider appInfo={{ name: resource.name, version: '1.0.0' }}><${componentName} /></AppProvider>);`);
228
+ .replace('// RESOURCE_MOUNT', `createRoot(root).render(<AppProvider appInfo={{ name: ${JSON.stringify(appName)}, version: ${JSON.stringify(appVersion)} }}><${componentName} /></AppProvider>);`);
222
229
 
223
230
  const entryPath = path.join(projectRoot, entry);
224
231
  writeFileSync(entryPath, entryContent);
@@ -283,6 +290,8 @@ export async function build(projectRoot = process.cwd()) {
283
290
  const destJson = path.join(distOutDir, `${kebabName}.json`);
284
291
 
285
292
  const meta = await extractResourceExport(srcTsx);
293
+ // Inject name from directory key if not explicitly set
294
+ meta.name = meta.name ?? kebabName;
286
295
  // Generate URI using resource name and build timestamp
287
296
  meta.uri = `ui://${meta.name}-${timestamp}`;
288
297
  writeFileSync(destJson, JSON.stringify(meta, null, 2));
@@ -331,10 +340,114 @@ ${jsContents}
331
340
 
332
341
  console.log('\n✓ All resources built successfully!');
333
342
  console.log('\nBuilt resources:');
334
- for (const { kebabName, distOutDir } of resourceFiles) {
335
- const files = readdirSync(distOutDir);
343
+ for (const { kebabName } of resourceFiles) {
336
344
  console.log(` ${kebabName}`);
337
345
  }
346
+
347
+ // ========================================================================
348
+ // Compile server-side code (tools + server entry) for `sunpeak start`
349
+ // ========================================================================
350
+
351
+ const toolsDir = path.join(projectRoot, 'src/tools');
352
+ const serverEntryPath = path.join(projectRoot, 'src/server.ts');
353
+
354
+ // Find tool files
355
+ const toolFiles = existsSync(toolsDir)
356
+ ? readdirSync(toolsDir).filter(f => f.endsWith('.ts'))
357
+ : [];
358
+
359
+ const hasServerEntry = existsSync(serverEntryPath);
360
+
361
+ if (toolFiles.length > 0 || hasServerEntry) {
362
+ console.log('\nCompiling server-side code...');
363
+
364
+ let esbuild;
365
+ try {
366
+ const esbuildPath = resolveEsmEntry(require, 'esbuild');
367
+ esbuild = await import(esbuildPath);
368
+ } catch {
369
+ console.warn('Warning: esbuild not found — skipping tool/server compilation.');
370
+ console.warn('Install esbuild to enable `sunpeak start`: npm install -D esbuild');
371
+ esbuild = null;
372
+ }
373
+
374
+ if (esbuild) {
375
+ const toolsOutDir = path.join(distDir, 'tools');
376
+ if (toolFiles.length > 0) {
377
+ mkdirSync(toolsOutDir, { recursive: true });
378
+ }
379
+
380
+ // Compile each tool file
381
+ for (const toolFile of toolFiles) {
382
+ const toolName = toolFile.replace(/\.ts$/, '');
383
+ const toolPath = path.join(toolsDir, toolFile);
384
+
385
+ try {
386
+ await esbuild.build({
387
+ entryPoints: [toolPath],
388
+ bundle: true,
389
+ format: 'esm',
390
+ platform: 'node',
391
+ target: 'node18',
392
+ outfile: path.join(toolsOutDir, `${toolName}.js`),
393
+ // Externalize bare specifiers (node_modules) — resolve at runtime
394
+ plugins: [{
395
+ name: 'externalize-bare-specifiers',
396
+ setup(build) {
397
+ build.onResolve({ filter: /.*/ }, (args) => {
398
+ if (args.kind !== 'import-statement') return;
399
+ if (!args.path.startsWith('.') && !args.path.startsWith('/')) {
400
+ return { external: true };
401
+ }
402
+ return undefined;
403
+ });
404
+ },
405
+ }],
406
+ loader: { '.tsx': 'tsx', '.ts': 'ts' },
407
+ logLevel: 'warning',
408
+ });
409
+ console.log(`✓ Compiled tools/${toolName}.js`);
410
+ } catch (err) {
411
+ console.error(`Failed to compile tool ${toolName}:`, err.message);
412
+ process.exit(1);
413
+ }
414
+ }
415
+
416
+ // Compile server entry if present
417
+ if (hasServerEntry) {
418
+ try {
419
+ await esbuild.build({
420
+ entryPoints: [serverEntryPath],
421
+ bundle: true,
422
+ format: 'esm',
423
+ platform: 'node',
424
+ target: 'node18',
425
+ outfile: path.join(distDir, 'server.js'),
426
+ plugins: [{
427
+ name: 'externalize-bare-specifiers',
428
+ setup(build) {
429
+ build.onResolve({ filter: /.*/ }, (args) => {
430
+ if (args.kind !== 'import-statement') return;
431
+ if (!args.path.startsWith('.') && !args.path.startsWith('/')) {
432
+ return { external: true };
433
+ }
434
+ return undefined;
435
+ });
436
+ },
437
+ }],
438
+ loader: { '.tsx': 'tsx', '.ts': 'ts' },
439
+ logLevel: 'warning',
440
+ });
441
+ console.log(`✓ Compiled server.js`);
442
+ } catch (err) {
443
+ console.error(`Failed to compile server entry:`, err.message);
444
+ process.exit(1);
445
+ }
446
+ }
447
+ }
448
+ }
449
+
450
+ console.log('\n✓ Build complete!');
338
451
  }
339
452
 
340
453
  // Allow running directly
@@ -178,8 +178,8 @@ export async function dev(projectRoot = process.cwd(), args = []) {
178
178
  sunpeakMcp = await import(pathToFileURL(join(sunpeakBase, 'dist/mcp/index.js')).href);
179
179
  sunpeakDiscovery = await import(pathToFileURL(join(sunpeakBase, 'dist/lib/discovery-cli.js')).href);
180
180
  }
181
- const { FAVICON_BUFFER: faviconBuffer, runMCPServer } = sunpeakMcp;
182
- const { findResourceDirs, findSimulationFiles, extractResourceExport } = sunpeakDiscovery;
181
+ const { FAVICON_BUFFER: faviconBuffer, runMCPServer, startProductionHttpServer } = sunpeakMcp;
182
+ const { findResourceDirs, findSimulationFilesFlat, findToolFiles, extractResourceExport, extractToolExport } = sunpeakDiscovery;
183
183
 
184
184
  // Vite plugin to serve the sunpeak favicon
185
185
  const sunpeakFaviconPlugin = () => ({
@@ -229,28 +229,73 @@ export async function dev(projectRoot = process.cwd(), args = []) {
229
229
  // Discover simulations using sunpeak's discovery utilities
230
230
  const resourcesDir = join(projectRoot, 'src/resources');
231
231
  const simulationsDir = join(projectRoot, 'tests/simulations');
232
- const resourceDirs = findResourceDirs(resourcesDir, (key) => `${key}-resource.tsx`, fs);
232
+ const toolsDir = join(projectRoot, 'src/tools');
233
233
 
234
- const simulations = [];
235
- for (const { key: resourceKey, dir: resourceDir, resourcePath } of resourceDirs) {
234
+ const resourceDirs = findResourceDirs(resourcesDir, (key) => `${key}.tsx`, fs);
235
+
236
+ // Build resource metadata map
237
+ const resourceMap = new Map();
238
+ for (const { key, resourcePath } of resourceDirs) {
236
239
  const resource = await extractResourceExport(resourcePath);
237
- const resourceSimDir = join(simulationsDir, resourceKey);
238
- const simulationFiles = findSimulationFiles(resourceSimDir, resourceKey, fs);
239
-
240
- for (const { filename, path: simPath } of simulationFiles) {
241
- const simulationKey = filename.replace(/-simulation\.json$/, '');
242
- const simulation = JSON.parse(readFileSync(simPath, 'utf-8'));
243
-
244
- simulations.push({
245
- ...simulation,
246
- name: simulationKey,
247
- distPath: join(projectRoot, `dist/${resourceKey}/${resourceKey}.html`),
248
- srcPath: `/src/resources/${resourceKey}/${resourceKey}-resource.tsx`,
249
- resource,
250
- });
240
+ // Inject name from directory key if not explicitly set
241
+ resource.name = resource.name ?? key;
242
+ resourceMap.set(key, resource);
243
+ }
244
+
245
+ // Discover tool files and extract metadata
246
+ const toolFiles = findToolFiles(toolsDir, fs);
247
+ const toolMap = new Map();
248
+ for (const { name: toolName, path: toolPath } of toolFiles) {
249
+ try {
250
+ const { tool } = await extractToolExport(toolPath);
251
+ toolMap.set(toolName, tool);
252
+ } catch (err) {
253
+ console.warn(`Warning: Could not extract metadata from tool ${toolName}: ${err.message}`);
251
254
  }
252
255
  }
253
256
 
257
+ // Discover simulations from flat directory
258
+ const simulations = [];
259
+ const simFiles = findSimulationFilesFlat(simulationsDir, fs);
260
+
261
+ for (const { name: simName, path: simPath } of simFiles) {
262
+ const simulation = JSON.parse(readFileSync(simPath, 'utf-8'));
263
+ const toolName = typeof simulation.tool === 'string' ? simulation.tool : simName;
264
+
265
+ // Look up tool metadata
266
+ const tool = toolMap.get(toolName);
267
+ if (!tool) {
268
+ console.warn(`Warning: Tool "${toolName}" not found for simulation "${simName}". Skipping.`);
269
+ continue;
270
+ }
271
+
272
+ // tool.resource is the resource name string — find matching resource key
273
+ const resourceName = tool.resource;
274
+ const resourceKey = resourceName
275
+ ? Array.from(resourceMap.keys()).find((k) => resourceMap.get(k).name === resourceName)
276
+ : undefined;
277
+
278
+ if (!resourceKey) {
279
+ console.warn(`Warning: No resource found for tool "${toolName}" in simulation "${simName}". Skipping.`);
280
+ continue;
281
+ }
282
+
283
+ // Determine source path for the resource
284
+ const resourceDir = resourceDirs.find((d) => d.key === resourceKey);
285
+ const srcPath = resourceDir
286
+ ? `/src/resources/${resourceKey}/${basename(resourceDir.resourcePath)}`
287
+ : undefined;
288
+
289
+ simulations.push({
290
+ ...simulation,
291
+ ...(typeof simulation.tool === 'string' ? { tool: { name: toolName, ...tool } } : {}),
292
+ name: simName,
293
+ distPath: join(projectRoot, `dist/${resourceKey}/${resourceKey}.html`),
294
+ srcPath,
295
+ resource: resourceMap.get(resourceKey),
296
+ });
297
+ }
298
+
254
299
  // Start MCP server with its own Vite instance (unless --prod-mcp is set)
255
300
  if (simulations.length > 0) {
256
301
  const mcpMode = prodMcp ? 'production build' : 'Vite HMR';
@@ -294,7 +339,7 @@ const Component = ResourceModule.default || ResourceModule['${componentName}'];
294
339
  if (!Component) {
295
340
  document.getElementById('root').innerHTML = '<pre style="color:red;padding:16px">Component not found: ${componentName}\\nExports: ' + Object.keys(ResourceModule).join(', ') + '</pre>';
296
341
  } else {
297
- const appInfo = { name: ResourceModule.resource?.name || '${componentName}', version: '1.0.0' };
342
+ const appInfo = { name: ${JSON.stringify(pkg.name || 'sunpeak-app')}, version: ${JSON.stringify(pkg.version || '0.1.0')} };
298
343
  root.render(
299
344
  createElement(AppProvider, { appInfo }, createElement(Component))
300
345
  );
@@ -344,13 +389,109 @@ if (import.meta.hot) {
344
389
  }
345
390
 
346
391
  const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
347
- const mcpHandle = runMCPServer({
348
- name: pkg.name || 'Sunpeak',
349
- version: pkg.version || '0.1.0',
350
- simulations,
351
- port: 8000,
352
- ...(mcpViteServer && { viteServer: mcpViteServer }),
353
- });
392
+
393
+ let mcpHandle = null;
394
+
395
+ if (prodMcp) {
396
+ // --prod-mcp: Use real tool handlers via Vite SSR
397
+ // Load full tool modules (handler + schema + config) from TypeScript source
398
+ const ssrLoader = await createServer({
399
+ root: projectRoot,
400
+ server: { middlewareMode: true },
401
+ appType: 'custom',
402
+ logLevel: 'silent',
403
+ resolve: {
404
+ alias: {
405
+ ...(isTemplate && { sunpeak: parentSrc }),
406
+ },
407
+ },
408
+ });
409
+
410
+ const prodTools = [];
411
+ for (const { name: toolName, path: toolPath } of toolFiles) {
412
+ try {
413
+ const relativePath = './' + toolPath.replace(projectRoot + '/', '').replace(projectRoot + '\\', '');
414
+ const mod = await ssrLoader.ssrLoadModule(relativePath);
415
+ if (mod.tool && mod.default) {
416
+ prodTools.push({
417
+ name: toolName,
418
+ tool: mod.tool,
419
+ schema: mod.schema,
420
+ handler: mod.default,
421
+ });
422
+ }
423
+ } catch (err) {
424
+ console.warn(`Warning: Could not load tool handler ${toolName}: ${err.message}`);
425
+ }
426
+ }
427
+
428
+ // Load server entry if present
429
+ const serverEntryPath = join(projectRoot, 'src/server.ts');
430
+ let auth = undefined;
431
+ let serverConfig = {};
432
+ if (existsSync(serverEntryPath)) {
433
+ try {
434
+ const serverMod = await ssrLoader.ssrLoadModule('./src/server.ts');
435
+ if (typeof serverMod.auth === 'function') {
436
+ auth = serverMod.auth;
437
+ console.log('Loaded auth from src/server.ts');
438
+ }
439
+ if (serverMod.server) serverConfig = serverMod.server;
440
+ } catch (err) {
441
+ console.warn(`Warning: Could not load server entry: ${err.message}`);
442
+ }
443
+ }
444
+
445
+ await ssrLoader.close();
446
+
447
+ // Build production resources from dist/
448
+ const prodResources = [];
449
+ for (const { key } of resourceDirs) {
450
+ const meta = resourceMap.get(key);
451
+ if (!meta) continue;
452
+ const htmlPath = join(projectRoot, `dist/${key}/${key}.html`);
453
+ const jsonPath = join(projectRoot, `dist/${key}/${key}.json`);
454
+ if (!existsSync(htmlPath)) continue;
455
+
456
+ const html = readFileSync(htmlPath, 'utf-8');
457
+ let uri = `ui://${meta.name ?? key}`;
458
+ let _meta = meta._meta;
459
+
460
+ // Use URI from build JSON if available (has cache-bust timestamp)
461
+ if (existsSync(jsonPath)) {
462
+ try {
463
+ const buildMeta = JSON.parse(readFileSync(jsonPath, 'utf-8'));
464
+ if (buildMeta.uri) uri = buildMeta.uri;
465
+ } catch {}
466
+ }
467
+
468
+ prodResources.push({
469
+ name: meta.name ?? key,
470
+ uri,
471
+ html,
472
+ description: meta.description,
473
+ _meta,
474
+ });
475
+ }
476
+
477
+ const name = serverConfig.name ?? pkg.name ?? 'Sunpeak';
478
+ const version = serverConfig.version ?? pkg.version ?? '0.1.0';
479
+
480
+ console.log(`Starting production MCP server with ${prodTools.length} tool(s)...`);
481
+ startProductionHttpServer(
482
+ { name, version, tools: prodTools, resources: prodResources, auth },
483
+ 8000
484
+ );
485
+ } else {
486
+ // Default: simulation-based MCP server with fixture data
487
+ mcpHandle = runMCPServer({
488
+ name: pkg.name || 'Sunpeak',
489
+ version: pkg.version || '0.1.0',
490
+ simulations,
491
+ port: 8000,
492
+ ...(mcpViteServer && { viteServer: mcpViteServer }),
493
+ });
494
+ }
354
495
 
355
496
  // Build production bundles and watch for changes.
356
497
  // Tunnel clients (e.g. Claude via ngrok) get the pre-built HTML since they can't
@@ -153,9 +153,19 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
153
153
  if (src.includes('/resources/') && name === resource) {
154
154
  return false;
155
155
  }
156
- // Skip simulation directories for excluded resources: tests/simulations/{resource}/
157
- if (src.includes('/tests/simulations/') && name === resource) {
158
- return false;
156
+ // Skip flat simulation files for excluded resources: tests/simulations/*.json
157
+ if (src.includes('/tests/simulations/') && name.endsWith('.json')) {
158
+ const baseName = name.replace(/\.json$/, '');
159
+ if (baseName === resource || baseName.startsWith(resource + '-') || baseName.endsWith('-' + resource)) {
160
+ return false;
161
+ }
162
+ }
163
+ // Skip tool files for excluded resources: src/tools/*.ts
164
+ if (src.includes('/src/tools/') && name.endsWith('.ts')) {
165
+ const baseName = name.replace(/\.ts$/, '');
166
+ if (baseName === resource || baseName.startsWith(resource + '-') || baseName.endsWith('-' + resource)) {
167
+ return false;
168
+ }
159
169
  }
160
170
  // Skip e2e test files for excluded resources
161
171
  if (src.includes('/tests/e2e/') && name === `${resource}.spec.ts`) {