sunpeak 0.16.4 → 0.16.7

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/README.md CHANGED
@@ -43,8 +43,6 @@ pnpm add -g sunpeak
43
43
  sunpeak new
44
44
  ```
45
45
 
46
- To add `sunpeak` to an existing project, refer to the [documentation](https://sunpeak.ai/docs/add-to-existing-project).
47
-
48
46
  ## Overview
49
47
 
50
48
  `sunpeak` is an npm package that helps you build MCP Apps (interactive UI resources) while keeping your MCP server client-agnostic. Built on the [MCP Apps SDK](https://github.com/modelcontextprotocol/ext-apps) (`@modelcontextprotocol/ext-apps`). `sunpeak` consists of:
@@ -49,7 +49,8 @@ function resolveEsmEntry(require, packageName) {
49
49
  * Build all resources for a Sunpeak project
50
50
  * Runs in the context of a user's project directory
51
51
  */
52
- export async function build(projectRoot = process.cwd()) {
52
+ export async function build(projectRoot = process.cwd(), { quiet = false } = {}) {
53
+ const log = quiet ? () => {} : console.log.bind(console);
53
54
 
54
55
  // Check for package.json first
55
56
  const pkgJsonPath = path.join(projectRoot, 'package.json');
@@ -191,7 +192,7 @@ export async function build(projectRoot = process.cwd()) {
191
192
  process.exit(1);
192
193
  }
193
194
 
194
- console.log('Building all resources...\n');
195
+ log('Building all resources...\n');
195
196
 
196
197
  // Read and validate the template
197
198
  const template = readFileSync(templateFile, 'utf-8');
@@ -214,7 +215,7 @@ export async function build(projectRoot = process.cwd()) {
214
215
  // Build all resources (but don't copy yet)
215
216
  for (let i = 0; i < resourceFiles.length; i++) {
216
217
  const { componentName, componentFile, kebabName, entry, jsOutput, buildOutDir } = resourceFiles[i];
217
- console.log(`[${i + 1}/${resourceFiles.length}] Building ${kebabName}...`);
218
+ log(`[${i + 1}/${resourceFiles.length}] Building ${kebabName}...`);
218
219
 
219
220
  try {
220
221
  // Create build directory if it doesn't exist
@@ -234,6 +235,7 @@ export async function build(projectRoot = process.cwd()) {
234
235
  await viteBuild({
235
236
  mode: 'production',
236
237
  root: projectRoot,
238
+ ...(quiet && { logLevel: 'silent' }),
237
239
  plugins: [react(), tailwindcss(), inlineCssPlugin(buildOutDir)],
238
240
  define: {
239
241
  'process.env.NODE_ENV': JSON.stringify('production'),
@@ -277,7 +279,7 @@ export async function build(projectRoot = process.cwd()) {
277
279
  }
278
280
 
279
281
  // Now copy all files from build-output to dist/{resource}/
280
- console.log('\nCopying built files to dist/...');
282
+ log('\nCopying built files to dist/...');
281
283
  const timestamp = Date.now().toString(36);
282
284
 
283
285
  for (const { jsOutput, htmlOutput, buildOutDir, distOutDir, kebabName, componentFile, resourceDir } of resourceFiles) {
@@ -296,7 +298,7 @@ export async function build(projectRoot = process.cwd()) {
296
298
  // Generate URI using resource name and build timestamp
297
299
  meta.uri = `ui://${meta.name}-${timestamp}`;
298
300
  writeFileSync(destJson, JSON.stringify(meta, null, 2));
299
- console.log(`✓ Generated ${kebabName}/${kebabName}.json (uri: ${meta.uri})`);
301
+ log(`✓ Generated ${kebabName}/${kebabName}.json (uri: ${meta.uri})`);
300
302
 
301
303
  // Read built JS file and wrap in HTML shell
302
304
  const builtJsFile = path.join(buildOutDir, jsOutput);
@@ -318,13 +320,13 @@ ${jsContents}
318
320
  </body>
319
321
  </html>`;
320
322
  writeFileSync(destHtmlFile, html);
321
- console.log(`✓ Built ${kebabName}/${htmlOutput}`);
323
+ log(`✓ Built ${kebabName}/${htmlOutput}`);
322
324
  } else {
323
325
  console.error(`Built file not found: ${builtJsFile}`);
324
326
  if (existsSync(buildOutDir)) {
325
- console.log(` Files in ${buildOutDir}:`, readdirSync(buildOutDir));
327
+ log(` Files in ${buildOutDir}:`, readdirSync(buildOutDir));
326
328
  } else {
327
- console.log(` Build directory doesn't exist: ${buildOutDir}`);
329
+ log(` Build directory doesn't exist: ${buildOutDir}`);
328
330
  }
329
331
  process.exit(1);
330
332
  }
@@ -339,10 +341,10 @@ ${jsContents}
339
341
  rmSync(buildDir, { recursive: true });
340
342
  }
341
343
 
342
- console.log('\n✓ All resources built successfully!');
343
- console.log('\nBuilt resources:');
344
+ log('\n✓ All resources built successfully!');
345
+ log('\nBuilt resources:');
344
346
  for (const { kebabName } of resourceFiles) {
345
- console.log(` ${kebabName}`);
347
+ log(` ${kebabName}`);
346
348
  }
347
349
 
348
350
  // ========================================================================
@@ -360,7 +362,7 @@ ${jsContents}
360
362
  const hasServerEntry = existsSync(serverEntryPath);
361
363
 
362
364
  if (toolFiles.length > 0 || hasServerEntry) {
363
- console.log('\nCompiling server-side code...');
365
+ log('\nCompiling server-side code...');
364
366
 
365
367
  let esbuild;
366
368
  try {
@@ -407,7 +409,7 @@ ${jsContents}
407
409
  loader: { '.tsx': 'tsx', '.ts': 'ts' },
408
410
  logLevel: 'warning',
409
411
  });
410
- console.log(`✓ Compiled tools/${toolName}.js`);
412
+ log(`✓ Compiled tools/${toolName}.js`);
411
413
  } catch (err) {
412
414
  console.error(`Failed to compile tool ${toolName}:`, err.message);
413
415
  process.exit(1);
@@ -439,7 +441,7 @@ ${jsContents}
439
441
  loader: { '.tsx': 'tsx', '.ts': 'ts' },
440
442
  logLevel: 'warning',
441
443
  });
442
- console.log(`✓ Compiled server.js`);
444
+ log(`✓ Compiled server.js`);
443
445
  } catch (err) {
444
446
  console.error(`Failed to compile server entry:`, err.message);
445
447
  process.exit(1);
@@ -448,12 +450,13 @@ ${jsContents}
448
450
  }
449
451
  }
450
452
 
451
- console.log('\n✓ Build complete!');
453
+ log('\n✓ Build complete!');
452
454
  }
453
455
 
454
456
  // Allow running directly
455
457
  if (import.meta.url === `file://${process.argv[1]}`) {
456
- build().catch(error => {
458
+ const quiet = process.argv.includes('--quiet');
459
+ build(process.cwd(), { quiet }).catch(error => {
457
460
  console.error(error);
458
461
  process.exit(1);
459
462
  });
@@ -66,17 +66,17 @@ function startBuildWatcher(projectRoot, resourcesDir, mcpHandle) {
66
66
  let activeChild = null;
67
67
  const sunpeakBin = join(dirname(new URL(import.meta.url).pathname), '..', 'sunpeak.js');
68
68
 
69
- const runBuild = (reason) => {
69
+ const runBuild = () => {
70
70
  // Kill any in-progress build and start fresh
71
71
  if (activeChild) {
72
72
  activeChild.kill('SIGTERM');
73
73
  activeChild = null;
74
74
  }
75
75
 
76
- console.log(`[build] ${reason}`);
77
- const child = spawn(process.execPath, [sunpeakBin, 'build'], {
76
+ console.log(`[build] Building resources for the MCP server for non-ChatGPT hosts...`);
77
+ const child = spawn(process.execPath, [sunpeakBin, 'build', '--quiet'], {
78
78
  cwd: projectRoot,
79
- stdio: ['ignore', 'inherit', 'inherit'],
79
+ stdio: ['ignore', 'pipe', 'inherit'],
80
80
  env: { ...process.env, NODE_ENV: 'production' },
81
81
  });
82
82
  activeChild = child;
@@ -85,6 +85,7 @@ function startBuildWatcher(projectRoot, resourcesDir, mcpHandle) {
85
85
  if (child !== activeChild) return; // Superseded by a newer build
86
86
  activeChild = null;
87
87
  if (code === 0) {
88
+ console.log(`[build] Built resources for the MCP server for non-ChatGPT hosts.`);
88
89
  // Notify non-local sessions (Claude, etc.) that resources changed
89
90
  mcpHandle?.invalidateResources();
90
91
  } else if (code !== null) {
@@ -94,7 +95,7 @@ function startBuildWatcher(projectRoot, resourcesDir, mcpHandle) {
94
95
  };
95
96
 
96
97
  // Initial build
97
- runBuild('Initial production build for tunnel clients...');
98
+ runBuild();
98
99
 
99
100
  // Watch src/resources/ for changes using fs.watch (recursive supported on macOS/Windows)
100
101
  let debounceTimer = null;
@@ -108,7 +109,7 @@ function startBuildWatcher(projectRoot, resourcesDir, mcpHandle) {
108
109
 
109
110
  clearTimeout(debounceTimer);
110
111
  debounceTimer = setTimeout(() => {
111
- runBuild(`Rebuilding (${filename} changed)...`);
112
+ runBuild();
112
113
  }, 500);
113
114
  });
114
115
  console.log('[build] Watching src/resources/ for changes...');
@@ -2,26 +2,59 @@
2
2
  import { existsSync, mkdirSync, cpSync, readFileSync, writeFileSync, renameSync } from 'fs';
3
3
  import { join, dirname, basename } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
- import { createInterface } from 'readline';
6
- import { execSync } from 'child_process';
5
+ import { execSync, exec } from 'child_process';
6
+ import { promisify } from 'util';
7
+
8
+ const execAsync = promisify(exec);
9
+ import * as clack from '@clack/prompts';
7
10
  import { discoverResources } from '../lib/patterns.mjs';
8
11
  import { detectPackageManager } from '../utils.mjs';
9
12
 
10
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
14
 
12
15
  /**
13
- * Default prompt implementation using readline
14
- * @param {string} question
16
+ * Default prompt for project name using clack text input.
15
17
  * @returns {Promise<string>}
16
18
  */
17
- function defaultPrompt(question) {
18
- const rl = createInterface({ input: process.stdin, output: process.stdout });
19
- return new Promise((resolve) => {
20
- rl.question(question, (answer) => {
21
- rl.close();
22
- resolve(answer.trim());
23
- });
19
+ async function defaultPromptName() {
20
+ const value = await clack.text({
21
+ message: 'Project name',
22
+ placeholder: 'my-app',
23
+ defaultValue: 'my-app',
24
+ validate: (v) => {
25
+ if (v === 'template') return '"template" is a reserved name';
26
+ },
24
27
  });
28
+ if (clack.isCancel(value)) {
29
+ clack.cancel('Cancelled.');
30
+ process.exit(0);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ /**
36
+ * Default resource selection using clack multiselect.
37
+ * @param {string[]} availableResources
38
+ * @returns {Promise<string[]>}
39
+ */
40
+ async function defaultSelectResources(availableResources) {
41
+ const selected = await clack.multiselect({
42
+ message: 'Resources (UIs) to include (space to toggle)',
43
+ options: (() => {
44
+ const maxLen = Math.max(...availableResources.map((r) => r.length));
45
+ return availableResources.map((r) => ({
46
+ value: r,
47
+ label: `${r.padEnd(maxLen)} (https://sunpeak.ai/docs/api-reference/resources/${r})`,
48
+ }));
49
+ })(),
50
+ initialValues: availableResources,
51
+ required: true,
52
+ });
53
+ if (clack.isCancel(selected)) {
54
+ clack.cancel('Cancelled.');
55
+ process.exit(0);
56
+ }
57
+ return selected;
25
58
  }
26
59
 
27
60
  /**
@@ -37,7 +70,12 @@ export const defaultDeps = {
37
70
  writeFileSync,
38
71
  renameSync,
39
72
  execSync,
40
- prompt: defaultPrompt,
73
+ execAsync,
74
+ promptName: defaultPromptName,
75
+ selectResources: defaultSelectResources,
76
+ intro: clack.intro,
77
+ outro: clack.outro,
78
+ spinner: clack.spinner,
41
79
  console,
42
80
  process,
43
81
  cwd: () => process.cwd(),
@@ -88,6 +126,8 @@ export function parseResourcesInput(input, validResources, deps = defaultDeps) {
88
126
  export async function init(projectName, resourcesArg, deps = defaultDeps) {
89
127
  const d = { ...defaultDeps, ...deps };
90
128
 
129
+ d.intro('☀️ sunpeak');
130
+
91
131
  // Discover available resources from template
92
132
  const availableResources = d.discoverResources();
93
133
  if (availableResources.length === 0) {
@@ -96,10 +136,7 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
96
136
  }
97
137
 
98
138
  if (!projectName) {
99
- projectName = await d.prompt('☀️ 🏔️ Project name [my-app]: ');
100
- if (!projectName) {
101
- projectName = 'my-app';
102
- }
139
+ projectName = await d.promptName();
103
140
  }
104
141
 
105
142
  if (projectName === 'template') {
@@ -107,17 +144,13 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
107
144
  d.process.exit(1);
108
145
  }
109
146
 
110
- // Use resources from args or ask for them
111
- let resourcesInput;
112
- if (resourcesArg) {
113
- resourcesInput = resourcesArg;
114
- d.console.log(`☀️ 🏔️ Resources: ${resourcesArg}`);
147
+ // Use resources from args or interactively select them
148
+ let selectedResources;
149
+ if (resourcesArg !== undefined) {
150
+ selectedResources = parseResourcesInput(resourcesArg, availableResources, d);
115
151
  } else {
116
- resourcesInput = await d.prompt(
117
- `☀️ 🏔️ Resources (UIs) to include [${availableResources.join(', ')}]: `
118
- );
152
+ selectedResources = await d.selectResources(availableResources);
119
153
  }
120
- const selectedResources = parseResourcesInput(resourcesInput, availableResources, d);
121
154
 
122
155
  const targetDir = join(d.cwd(), projectName);
123
156
 
@@ -126,13 +159,11 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
126
159
  d.process.exit(1);
127
160
  }
128
161
 
129
- d.console.log(`☀️ 🏔️ Creating ${projectName}...`);
130
-
131
- d.mkdirSync(targetDir, { recursive: true });
132
-
133
162
  // Filter resource directories based on selection
134
163
  const excludedResources = availableResources.filter((r) => !selectedResources.includes(r));
135
164
 
165
+ d.mkdirSync(targetDir, { recursive: true });
166
+
136
167
  d.cpSync(d.templateDir, targetDir, {
137
168
  recursive: true,
138
169
  filter: (src) => {
@@ -223,32 +254,30 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
223
254
 
224
255
  d.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
225
256
 
226
- // Detect package manager and run install
257
+ // Install dependencies with spinner
227
258
  const pm = d.detectPackageManager();
228
- d.console.log(`☀️ 🏔️ Installing dependencies with ${pm}...`);
259
+ const s = d.spinner();
260
+ s.start(`Installing dependencies with ${pm}...`);
229
261
 
230
262
  try {
231
- d.execSync(`${pm} install`, { cwd: targetDir, stdio: 'inherit' });
263
+ await d.execAsync(`${pm} install`, { cwd: targetDir });
264
+ s.stop(`Installed dependencies with ${pm}`);
232
265
  } catch {
233
- d.console.error(`\nInstall failed. You can try running "${pm} install" manually in the project directory.`);
266
+ s.stop(`Install failed. You can try running "${pm} install" manually.`);
234
267
  }
235
268
 
236
269
  const runCmd = pm === 'npm' ? 'npm run' : pm;
237
270
 
238
- d.console.log(`
239
- Done! To get started:
271
+ d.outro(`Done! To get started:
240
272
 
241
273
  cd ${projectName}
242
274
  sunpeak dev
243
275
 
244
- That's it! Your project commands:
276
+ Your project commands:
245
277
 
246
278
  sunpeak dev # Start dev server + MCP endpoint
247
279
  sunpeak build # Build for production
248
- ${runCmd} test # Run tests
249
-
250
- See README.md for more details.
251
- `);
280
+ ${runCmd} test # Run tests`);
252
281
  }
253
282
 
254
283
  // Allow running directly
@@ -15520,27 +15520,30 @@ function createAppServer(config, simulations, viteMode) {
15520
15520
  },
15521
15521
  { capabilities: { resources: {}, tools: {} } }
15522
15522
  );
15523
- const registeredResources = /* @__PURE__ */ new Set();
15523
+ const registeredUriSet = /* @__PURE__ */ new Set();
15524
+ const resourceHandles = /* @__PURE__ */ new Map();
15525
+ const toolHandles = [];
15524
15526
  for (const simulation of simulations) {
15525
15527
  const resource = simulation.resource;
15526
15528
  const tool = simulation.tool;
15527
15529
  const toolResult = simulation.toolResult ?? { structuredContent: null };
15528
15530
  const uri2 = resource.uri ?? `ui://${resource.name}`;
15531
+ const resourceName = resource.name;
15529
15532
  const resourceMeta = resource._meta ?? {};
15530
15533
  const toolMeta = tool._meta ?? {};
15531
- if (!registeredResources.has(uri2)) {
15532
- registeredResources.add(uri2);
15534
+ if (!registeredUriSet.has(uri2)) {
15535
+ registeredUriSet.add(uri2);
15533
15536
  const listMeta = viteMode ? injectViteCSP(resourceMeta) : resourceMeta;
15534
15537
  console.log(`[MCP] RegisterResource: ${uri2}`);
15535
- ak(
15536
- mcpServer,
15537
- resource.name,
15538
+ const handle = mcpServer.registerResource(
15539
+ resourceName,
15538
15540
  uri2,
15539
15541
  {
15542
+ mimeType: EI,
15540
15543
  description: resource.description,
15541
15544
  _meta: listMeta
15542
15545
  },
15543
- async (_uri, extra) => {
15546
+ async (readUri, extra) => {
15544
15547
  const prodBuild = needsProdBuild(
15545
15548
  extra?.requestInfo?.headers ?? {}
15546
15549
  );
@@ -15549,17 +15552,18 @@ function createAppServer(config, simulations, viteMode) {
15549
15552
  try {
15550
15553
  content = getResourceHtml(simulation, viteMode, prodBuild);
15551
15554
  } catch (error) {
15552
- console.error(`[MCP] ReadResource error for ${uri2}:`, error);
15555
+ console.error(`[MCP] ReadResource error for ${readUri.href}:`, error);
15553
15556
  throw error;
15554
15557
  }
15555
15558
  const sizeKB = (content.length / 1024).toFixed(1);
15556
15559
  console.log(
15557
- `[MCP] ReadResource: ${uri2} → ${sizeKB}KB${prodBuild ? " (prod build)" : " (vite)"}`
15560
+ `[MCP] ReadResource: ${readUri.href} → ${sizeKB}KB${prodBuild ? " (prod build)" : " (vite)"}`
15558
15561
  );
15559
15562
  return {
15560
15563
  contents: [
15561
15564
  {
15562
- uri: uri2,
15565
+ // Use readUri (not closure variable) so the response URI matches after updates
15566
+ uri: readUri.href,
15563
15567
  mimeType: EI,
15564
15568
  text: content,
15565
15569
  _meta: readMeta
@@ -15568,20 +15572,22 @@ function createAppServer(config, simulations, viteMode) {
15568
15572
  };
15569
15573
  }
15570
15574
  );
15575
+ resourceHandles.set(resourceName, handle);
15571
15576
  }
15572
- hk(
15577
+ const fullToolMeta = {
15578
+ ...toolMeta,
15579
+ ui: {
15580
+ resourceUri: uri2,
15581
+ // Preserve tool visibility from simulation metadata if declared
15582
+ ...toolMeta.ui?.visibility ? { visibility: toolMeta.ui.visibility } : {}
15583
+ }
15584
+ };
15585
+ const toolHandle = hk(
15573
15586
  mcpServer,
15574
15587
  tool.name,
15575
15588
  {
15576
15589
  description: tool.description,
15577
- _meta: {
15578
- ...toolMeta,
15579
- ui: {
15580
- resourceUri: uri2,
15581
- // Preserve tool visibility from simulation metadata if declared
15582
- ...toolMeta.ui?.visibility ? { visibility: toolMeta.ui.visibility } : {}
15583
- }
15584
- }
15590
+ _meta: fullToolMeta
15585
15591
  },
15586
15592
  async (extra) => {
15587
15593
  const args = extra.request?.params?.arguments ?? {};
@@ -15602,12 +15608,13 @@ function createAppServer(config, simulations, viteMode) {
15602
15608
  };
15603
15609
  }
15604
15610
  );
15611
+ toolHandles.push({ handle: toolHandle, resourceName, toolMeta: fullToolMeta });
15605
15612
  }
15606
- const registeredUris = Array.from(registeredResources).join(", ");
15613
+ const registeredUris = Array.from(registeredUriSet).join(", ");
15607
15614
  console.log(
15608
15615
  `[MCP] Registered ${simulations.length} tool(s) and resource(s)${viteMode ? " (vite mode)" : ""}: ${registeredUris}`
15609
15616
  );
15610
- return mcpServer;
15617
+ return { server: mcpServer, resourceHandles, toolHandles };
15611
15618
  }
15612
15619
  const SESSION_IDLE_TIMEOUT_MS$1 = 5 * 60 * 1e3;
15613
15620
  function isLocalConnection(req) {
@@ -15674,11 +15681,18 @@ async function handleMcpRequest(req, res, config, simulations, viteMode) {
15674
15681
  }
15675
15682
  if (req.method === "POST") {
15676
15683
  const isLocal = isLocalConnection(req);
15677
- const server = createAppServer(config, simulations, viteMode);
15684
+ const { server, resourceHandles, toolHandles } = createAppServer(config, simulations, viteMode);
15678
15685
  const transport = new StreamableHTTPServerTransport({
15679
15686
  sessionIdGenerator: () => node_crypto.randomUUID(),
15680
15687
  onsessioninitialized: (id2) => {
15681
- sessions.set(id2, { server, transport, isLocal, lastActivity: Date.now() });
15688
+ sessions.set(id2, {
15689
+ server,
15690
+ transport,
15691
+ isLocal,
15692
+ lastActivity: Date.now(),
15693
+ resourceHandles,
15694
+ toolHandles
15695
+ });
15682
15696
  const origin = isLocal ? "local" : "tunnel";
15683
15697
  console.log(
15684
15698
  `[MCP] Session started: ${id2.substring(0, 8)}... (${origin}, ${sessions.size} active)`
@@ -15793,16 +15807,24 @@ function runMCPServer(config) {
15793
15807
  process.on("SIGINT", () => void shutdown());
15794
15808
  return {
15795
15809
  invalidateResources() {
15796
- let notified = 0;
15810
+ if (sessions.size === 0) return;
15811
+ const timestamp = Date.now();
15797
15812
  for (const [, session] of sessions) {
15798
- if (!session.isLocal) {
15799
- session.server.sendResourceListChanged();
15800
- notified++;
15813
+ for (const [name, handle] of session.resourceHandles) {
15814
+ handle.update({ uri: `ui://${name}-${timestamp}` });
15815
+ }
15816
+ for (const { handle, resourceName, toolMeta } of session.toolHandles) {
15817
+ const newUri = `ui://${resourceName}-${timestamp}`;
15818
+ handle.update({
15819
+ _meta: {
15820
+ ...toolMeta,
15821
+ ui: { ...toolMeta.ui, resourceUri: newUri },
15822
+ "ui/resourceUri": newUri
15823
+ }
15824
+ });
15801
15825
  }
15802
15826
  }
15803
- if (notified > 0) {
15804
- console.log(`[MCP] Notified ${notified} session(s) of resource changes`);
15805
- }
15827
+ console.log(`[MCP] Cache-busted ${sessions.size} session(s) with timestamp ${timestamp}`);
15806
15828
  }
15807
15829
  };
15808
15830
  }