sunpeak 0.20.1 → 0.20.5

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 (59) hide show
  1. package/README.md +59 -89
  2. package/bin/commands/inspect.mjs +142 -13
  3. package/bin/commands/new.mjs +33 -9
  4. package/bin/commands/test-init.mjs +113 -100
  5. package/bin/commands/test.mjs +7 -2
  6. package/bin/lib/eval/eval-runner.mjs +7 -1
  7. package/bin/lib/inspect/inspect-config.mjs +1 -1
  8. package/bin/lib/live/live-config.d.mts +10 -0
  9. package/bin/lib/live/live-config.mjs +34 -2
  10. package/bin/lib/test/base-config.mjs +3 -1
  11. package/bin/lib/test/test-config.mjs +1 -1
  12. package/bin/sunpeak.js +16 -15
  13. package/dist/chatgpt/index.cjs +1 -1
  14. package/dist/chatgpt/index.js +1 -1
  15. package/dist/claude/index.cjs +1 -1
  16. package/dist/claude/index.js +1 -1
  17. package/dist/host/chatgpt/index.cjs +1 -1
  18. package/dist/host/chatgpt/index.js +1 -1
  19. package/dist/index.cjs +3 -3
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.js +3 -3
  22. package/dist/index.js.map +1 -1
  23. package/dist/inspector/index.cjs +1 -1
  24. package/dist/inspector/index.js +1 -1
  25. package/dist/{inspector-BBDa5yCm.js → inspector-60Na_Zc4.js} +2 -2
  26. package/dist/inspector-60Na_Zc4.js.map +1 -0
  27. package/dist/{inspector-DAA1Wiyh.cjs → inspector-D0qOqYX2.cjs} +2 -2
  28. package/dist/{inspector-BBDa5yCm.js.map → inspector-D0qOqYX2.cjs.map} +1 -1
  29. package/dist/mcp/index.cjs +1 -1
  30. package/dist/mcp/index.cjs.map +1 -1
  31. package/dist/mcp/index.js +1 -1
  32. package/dist/mcp/index.js.map +1 -1
  33. package/dist/{use-app-DPkj5Jp_.cjs → use-app-B33mckz4.cjs} +7 -3
  34. package/dist/use-app-B33mckz4.cjs.map +1 -0
  35. package/dist/{use-app-Cr0auUa1.js → use-app-kv5GQr0G.js} +7 -3
  36. package/dist/use-app-kv5GQr0G.js.map +1 -0
  37. package/package.json +3 -3
  38. package/template/README.md +21 -23
  39. package/template/dist/albums/albums.html +1 -1
  40. package/template/dist/albums/albums.json +1 -1
  41. package/template/dist/carousel/carousel.html +1 -1
  42. package/template/dist/carousel/carousel.json +1 -1
  43. package/template/dist/map/map.html +1 -1
  44. package/template/dist/map/map.json +1 -1
  45. package/template/dist/review/review.html +1 -1
  46. package/template/dist/review/review.json +1 -1
  47. package/template/node_modules/.vite/deps/_metadata.json +3 -3
  48. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps.js +6 -2
  49. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps.js.map +1 -1
  50. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_app-bridge.js +1 -1
  51. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_app-bridge.js.map +1 -1
  52. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_react.js +6 -2
  53. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_react.js.map +1 -1
  54. package/template/node_modules/.vite-mcp/deps/_metadata.json +22 -22
  55. package/template/package.json +2 -1
  56. package/template/tests/e2e/visual.spec.ts +2 -2
  57. package/dist/inspector-DAA1Wiyh.cjs.map +0 -1
  58. package/dist/use-app-Cr0auUa1.js.map +0 -1
  59. package/dist/use-app-DPkj5Jp_.cjs.map +0 -1
package/README.md CHANGED
@@ -16,7 +16,13 @@
16
16
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?style=flat&logo=typescript&label=ts&color=FFB800&logoColor=white&labelColor=000035)](https://www.typescriptlang.org/)
17
17
  [![React](https://img.shields.io/badge/React-19-blue?style=flat&logo=react&label=react&color=FFB800&logoColor=white&labelColor=000035)](https://reactjs.org/)
18
18
 
19
- Inspector, testing framework, and runtime framework for MCP servers and MCP Apps.
19
+ MCP App framework, MCP testing framework, and inspector for MCP servers and MCP Apps.
20
+
21
+ Build cross-platform: sunpeak is a ChatGPT App framework, Claude Connector framework, and more.
22
+
23
+ ```bash
24
+ npx sunpeak new
25
+ ```
20
26
 
21
27
  [Demo (Hosted)](https://sunpeak.ai/inspector) ~
22
28
  [Demo (Video)](https://cdn.sunpeak.ai/sunpeak-demo-prod.mp4) ~
@@ -26,126 +32,89 @@ Inspector, testing framework, and runtime framework for MCP servers and MCP Apps
26
32
 
27
33
  ## sunpeak is three things
28
34
 
29
- ### 1. Inspector
35
+ ### 1. App Framework
36
+
37
+ Building an MCP App from scratch means wiring up an MCP server, handling protocol message routing, managing resource HTML bundles, and setting up a dev environment with hot reload. Each host has different capabilities and CSS variables, so you end up writing platform-specific code without a clear structure.
30
38
 
31
- Manually test any MCP server in replicated ChatGPT and Claude runtimes.
39
+ sunpeak gives you a convention-over-configuration framework with the inspector and testing built in.
32
40
 
33
41
  ```bash
34
- sunpeak inspect --server http://localhost:8000/mcp
42
+ npx sunpeak new
35
43
  ```
36
44
 
37
- <div align="center">
38
- <a href="https://sunpeak.ai/docs/mcp-apps-inspector">
39
- <picture>
40
- <img alt="Inspector" src="https://cdn.sunpeak.ai/chatgpt-simulator.png">
41
- </picture>
42
- </a>
43
- </div>
44
-
45
- - Multi-host inspector replicating ChatGPT and Claude runtimes
46
- - Toggle themes, display modes, device types from the sidebar or URL params
47
- - Call real tool handlers or use simulation fixtures for mock data
45
+ This creates a project, starts a dev server with HMR, and opens the inspector at `localhost:3000`:
48
46
 
49
- ### 2. Testing Framework
47
+ ```
48
+ sunpeak-app/
49
+ ├── src/resources/review/review.tsx # UI component (React)
50
+ ├── src/tools/review-diff.ts # Tool handler, schema, resource link
51
+ ├── tests/simulations/review-diff.json # Mock data for the inspector
52
+ └── package.json
53
+ ```
50
54
 
51
- Automatically test any MCP server against replicated ChatGPT and Claude runtimes.
55
+ Tools, resources, and simulations are auto-discovered from the file system. Multi-platform React hooks (`useToolData`, `useAppState`, `useTheme`, `useDisplayMode`) let you write your app logic once and deploy it across ChatGPT, Claude, and future hosts.
52
56
 
53
- ```ts
54
- import { test, expect } from 'sunpeak/test';
57
+ [App framework documentation →](https://sunpeak.ai/docs/mcp-apps-framework)
55
58
 
56
- test('review tool renders title', async ({ inspector }) => {
57
- const result = await inspector.renderTool('review-diff');
58
- const app = result.app();
59
- await expect(app.locator('h1:has-text("Refactor")')).toBeVisible();
60
- });
61
- ```
59
+ ---
62
60
 
63
- - **Works for any MCP server**: `sunpeak test init` scaffolds tests for Python, Go, TS, or any language
64
- - **MCP-native assertions**: `toBeError()`, `toHaveTextContent()`, `toHaveStructuredContent()`
65
- - **Multi-host**: Tests run against ChatGPT and Claude hosts automatically
66
- - **Live tests**: Automated browser tests against real ChatGPT via `sunpeak/test/live`
67
- - **Evals**: Test your tool interface design against multiple LLMs (GPT-4o, Claude, Gemini, etc.) via `sunpeak/eval`
61
+ ### 2. Testing Framework
68
62
 
69
- ### 3. App Framework
63
+ MCP Apps render inside host iframes with host-specific themes, display modes, and capabilities. Standard browser testing can't replicate this because the runtime environment only exists inside ChatGPT and Claude. Each app also has many dimensions of state: tool inputs, tool results, server tool responses, host context, and display configuration. Testing all combinations manually is slow and error-prone.
70
64
 
71
- Next.js for MCP Apps. Convention-over-configuration project structure with the inspector and testing built in.
65
+ sunpeak replicates these host runtimes and provides simulation fixtures (JSON files that define reproducible tool states) so you can test every combination of host, theme, and data in CI without accounts or API credits.
72
66
 
73
67
  ```bash
74
- sunpeak-app/
75
- ├── src/
76
- │ ├── resources/
77
- │ │ └── review/
78
- │ │ └── review.tsx # Review UI component + resource metadata.
79
- │ ├── tools/
80
- │ │ ├── review-diff.ts # Tool with handler, schema, and optional resource link.
81
- │ │ ├── review-post.ts # Multiple tools can share one resource.
82
- │ │ └── review.ts # Backend-only tool (no resource, no UI).
83
- │ └── server.ts # Optional: auth, server config.
84
- ├── tests/simulations/
85
- │ ├── review-diff.json # Mock state for testing (includes serverTools).
86
- │ ├── review-post.json # Mock state for testing (includes serverTools).
87
- │ └── review-purchase.json # Mock state for testing (includes serverTools).
88
- └── package.json
68
+ npx sunpeak test init --server http://localhost:8000/mcp
89
69
  ```
90
70
 
91
- - **Runtime APIs**: Strongly typed React hooks (`useToolData`, `useAppState`, `useHostContext`, etc.)
92
- - **Convention over configuration**: Resources, tools, and simulations are auto-discovered
93
- - **Multi-platform**: Build once, deploy to ChatGPT, Claude, and future hosts
94
-
95
- ## Quickstart
96
-
97
- Requirements: Node (20+), pnpm (10+)
71
+ This scaffolds E2E tests, visual regression, live host tests, and multi-model evals. Then run them:
98
72
 
99
73
  ```bash
100
- pnpm add -g sunpeak
101
- sunpeak new
74
+ npx sunpeak test
102
75
  ```
103
76
 
104
- ## CLI
77
+ Playwright fixtures handle inspector startup, MCP connection, iframe traversal, and host switching. Works with Python, Go, TypeScript, Rust, or any language.
105
78
 
106
- **Testing** (works with any MCP server):
79
+ ```ts
80
+ import { test, expect } from 'sunpeak/test';
107
81
 
108
- | Command | Description |
109
- | ------------------------------------- | ------------------------------------------- |
110
- | `sunpeak inspect --server <url\|cmd>` | Inspect any MCP server in the inspector |
111
- | `sunpeak test` | Run unit + e2e tests |
112
- | `sunpeak test --unit` | Run unit tests only (Vitest) |
113
- | `sunpeak test --e2e` | Run e2e tests only (Playwright) |
114
- | `sunpeak test --visual` | Run e2e tests with visual regression |
115
- | `sunpeak test --visual --update` | Update visual regression baselines |
116
- | `sunpeak test --live` | Run live tests against real hosts |
117
- | `sunpeak test --eval` | Run evals against multiple LLM models |
118
- | `sunpeak test init` | Scaffold test infrastructure into a project |
82
+ test('search tool returns results', async ({ mcp }) => {
83
+ const result = await mcp.callTool('search', { query: 'headphones' });
84
+ expect(result.isError).toBeFalsy();
85
+ });
119
86
 
120
- **App framework** (for sunpeak projects):
87
+ test('album cards render', async ({ inspector }) => {
88
+ const result = await inspector.renderTool('show-albums');
89
+ await expect(result.app().locator('button:has-text("Summer Slice")')).toBeVisible();
90
+ });
91
+ ```
121
92
 
122
- | Command | Description |
123
- | -------------------------------- | ------------------------------------------- |
124
- | `sunpeak new [name] [resources]` | Create a new project |
125
- | `sunpeak dev` | Start dev server + inspector + MCP endpoint |
126
- | `sunpeak build` | Build resources + tools for production |
127
- | `sunpeak start` | Start production MCP server |
128
- | `sunpeak upgrade` | Upgrade sunpeak to latest version |
93
+ [Testing documentation →](https://sunpeak.ai/docs/testing/overview)
129
94
 
130
- ## Coding Agent Skills
95
+ ---
131
96
 
132
- Install the sunpeak skills to give your coding agent (Claude Code, Cursor, etc.) built-in knowledge of sunpeak patterns, hooks, and testing:
97
+ ### 3. Inspector
98
+
99
+ MCP servers are opaque. You can call tools and read the JSON responses, but you can't see how your app actually looks and behaves inside ChatGPT or Claude without deploying to each host, setting up a tunnel, paying for accounts, and manually refreshing through a multi-step cycle on every code change.
100
+
101
+ The sunpeak inspector replicates the ChatGPT and Claude app runtimes locally. Point it at any MCP server and see your tools and resources rendered the same way they appear in production hosts.
133
102
 
134
103
  ```bash
135
- pnpm dlx skills add Sunpeak-AI/sunpeak@create-sunpeak-app Sunpeak-AI/sunpeak@test-mcp-server
104
+ npx sunpeak inspect --server http://localhost:8000/mcp
136
105
  ```
137
106
 
138
- ## Troubleshooting
139
-
140
- If your app doesn't render in ChatGPT or Claude:
107
+ <div align="center">
108
+ <a href="https://sunpeak.ai/docs/mcp-apps-inspector">
109
+ <picture>
110
+ <img alt="Inspector" src="https://cdn.sunpeak.ai/chatgpt-simulator.png">
111
+ </picture>
112
+ </a>
113
+ </div>
141
114
 
142
- 1. **Check your tunnel** is running and pointing to the correct port
143
- 2. **Restart `sunpeak dev`** to clear stale connections
144
- 3. **Refresh or re-add the MCP server** in the host's settings (Settings > MCP Servers)
145
- 4. **Hard refresh** the host page (`Cmd+Shift+R` / `Ctrl+Shift+R`)
146
- 5. **Open a new chat** in the host (cached iframes persist per-conversation)
115
+ Toggle between hosts, themes, display modes, and device types from the sidebar. Call real tool handlers or load simulation fixtures for deterministic mock data. Changes reflect instantly via HMR. Works with any MCP server in any language.
147
116
 
148
- Full guide: [sunpeak.ai/docs/app-framework/guides/troubleshooting](https://sunpeak.ai/docs/app-framework/guides/troubleshooting)
117
+ [Inspector documentation ](https://sunpeak.ai/docs/mcp-apps-inspector)
149
118
 
150
119
  ## Resources
151
120
 
@@ -153,3 +122,4 @@ Full guide: [sunpeak.ai/docs/app-framework/guides/troubleshooting](https://sunpe
153
122
  - [MCP Overview](https://sunpeak.ai/docs/mcp-apps/mcp/overview) · [Tools](https://sunpeak.ai/docs/mcp-apps/mcp/tools) · [Resources](https://sunpeak.ai/docs/mcp-apps/mcp/resources)
154
123
  - [MCP Apps SDK](https://github.com/modelcontextprotocol/ext-apps)
155
124
  - [ChatGPT Apps SDK Design Guidelines](https://developers.openai.com/apps-sdk/concepts/design-guidelines)
125
+ - [Troubleshooting](https://sunpeak.ai/docs/app-framework/guides/troubleshooting)
@@ -388,7 +388,7 @@ function isAuthError(err) {
388
388
  * Create an MCP client connection.
389
389
  * @param {string} serverArg - URL or command string
390
390
  * @param {{ type?: 'none' | 'bearer' | 'oauth', bearerToken?: string, authProvider?: import('@modelcontextprotocol/sdk/client/auth.js').OAuthClientProvider, env?: Record<string, string>, cwd?: string }} [authConfig]
391
- * @returns {Promise<{ client: import('@modelcontextprotocol/sdk/client/index.js').Client, transport: import('@modelcontextprotocol/sdk/types.js').Transport, stderrOutput?: string[] }>}
391
+ * @returns {Promise<{ client: import('@modelcontextprotocol/sdk/client/index.js').Client, transport: import('@modelcontextprotocol/sdk/types.js').Transport, serverUrl?: string, stderrOutput?: string[] }>}
392
392
  */
393
393
  async function createMcpConnection(serverArg, authConfig) {
394
394
  const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
@@ -400,6 +400,19 @@ async function createMcpConnection(serverArg, authConfig) {
400
400
  '@modelcontextprotocol/sdk/client/streamableHttp.js'
401
401
  );
402
402
 
403
+ // Follow redirects (e.g. /mcp → /mcp/) before creating the transport.
404
+ // The MCP SDK transport doesn't follow redirects on its own.
405
+ let finalUrl = serverArg;
406
+ try {
407
+ const probeResponse = await fetch(serverArg, { method: 'HEAD', redirect: 'follow' });
408
+ if (probeResponse.url && probeResponse.url !== serverArg) {
409
+ finalUrl = probeResponse.url;
410
+ }
411
+ } catch {
412
+ // Probe failed (server down, network error) — use original URL and let
413
+ // the transport handle the error with its own diagnostics.
414
+ }
415
+
403
416
  const transportOpts = {};
404
417
 
405
418
  if (authConfig?.type === 'bearer' && authConfig.bearerToken) {
@@ -410,9 +423,9 @@ async function createMcpConnection(serverArg, authConfig) {
410
423
  transportOpts.authProvider = authConfig.authProvider;
411
424
  }
412
425
 
413
- const transport = new StreamableHTTPClientTransport(new URL(serverArg), transportOpts);
426
+ const transport = new StreamableHTTPClientTransport(new URL(finalUrl), transportOpts);
414
427
  await client.connect(transport);
415
- return { client, transport };
428
+ return { client, transport, serverUrl: finalUrl };
416
429
  } else {
417
430
  // Stdio transport — parse command string
418
431
  const parts = serverArg.split(/\s+/);
@@ -501,8 +514,21 @@ async function discoverSimulations(client) {
501
514
  const uri = tool._meta?.ui?.resourceUri ?? tool._meta?.['ui/resourceUri'];
502
515
  if (uri) {
503
516
  resource = resourceByUri.get(uri);
504
- if (resource) {
505
- resourceUrl = `/__sunpeak/read-resource?uri=${encodeURIComponent(uri)}`;
517
+ // Always create a resource URL when a tool declares a resourceUri,
518
+ // even if it wasn't found in listResources(). The server may use
519
+ // resource templates (e.g., ui://counter/{ui}) that resolve dynamically.
520
+ // The /__sunpeak/read-resource endpoint calls client.readResource()
521
+ // which handles template resolution server-side.
522
+ resourceUrl = `/__sunpeak/read-resource?uri=${encodeURIComponent(uri)}`;
523
+ // Create a synthetic resource object when not found via listResources().
524
+ // The inspector UI needs .resource to include the tool in the simulation list.
525
+ if (!resource) {
526
+ resource = {
527
+ uri,
528
+ name: tool.name,
529
+ title: tool.title || tool.name,
530
+ mimeType: 'text/html',
531
+ };
506
532
  }
507
533
  }
508
534
 
@@ -641,6 +667,45 @@ root.render(
641
667
  * @param {{ callToolDirect?: (name: string, args: Record<string, unknown>) => Promise<object>, simulationsDir?: string | null }} [pluginOpts]
642
668
  */
643
669
  function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
670
+ // Server URL and options for automatic session recovery.
671
+ // Set by inspectServer() after creating the initial connection.
672
+ let _serverUrl = '';
673
+ /** @type {Record<string, unknown>} */
674
+ let _connectionOpts = {};
675
+
676
+ /**
677
+ * Check if an error is a dead-session error (MCP server no longer recognizes
678
+ * the session ID). This happens when the MCP server restarts, the session
679
+ * times out, or the connection is interrupted.
680
+ * @param {Error} err
681
+ */
682
+ function isDeadSession(err) {
683
+ const msg = err?.message ?? '';
684
+ return msg.includes('Unknown session') || msg.includes('404') || msg.includes('fetch failed');
685
+ }
686
+
687
+ /**
688
+ * Attempt to reconnect to the MCP server and replace the current client.
689
+ * Returns true if reconnection succeeded.
690
+ */
691
+ async function tryReconnect() {
692
+ if (!_serverUrl) return false;
693
+ try {
694
+ console.warn(`[inspect] MCP session lost, reconnecting to ${_serverUrl}...`);
695
+ const newConn = await createMcpConnection(_serverUrl, _connectionOpts);
696
+ setClient(newConn.client);
697
+ console.log('[inspect] MCP session re-established');
698
+ return true;
699
+ } catch (err) {
700
+ console.error(`[inspect] MCP reconnection failed: ${err?.message ?? err}`);
701
+ return false;
702
+ }
703
+ }
704
+
705
+ // Initialize reconnection state from plugin options.
706
+ if (pluginOpts.serverUrl) _serverUrl = pluginOpts.serverUrl;
707
+ if (pluginOpts.connectionOpts) _connectionOpts = pluginOpts.connectionOpts;
708
+
644
709
  // In-memory OAuth state keyed by server URL, persisted across reconnects.
645
710
  /** @type {Map<string, { provider: any, getAuthUrl: () => URL | undefined, hasTokens: () => boolean, stateParam: string }>} */
646
711
  const oauthProviders = new Map();
@@ -654,7 +719,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
654
719
  return {
655
720
  name: 'sunpeak-inspect-endpoints',
656
721
  configureServer(server) {
657
- // List tools from connected server
722
+ // List tools from connected server (with automatic session recovery)
658
723
  server.middlewares.use('/__sunpeak/list-tools', async (_req, res) => {
659
724
  try {
660
725
  const client = getClient();
@@ -662,6 +727,15 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
662
727
  res.writeHead(200, { 'Content-Type': 'application/json' });
663
728
  res.end(JSON.stringify(result));
664
729
  } catch (err) {
730
+ // If the session died (server restarted, timeout, etc.), try to reconnect once.
731
+ if (isDeadSession(err) && await tryReconnect()) {
732
+ try {
733
+ const result = await getClient().listTools();
734
+ res.writeHead(200, { 'Content-Type': 'application/json' });
735
+ res.end(JSON.stringify(result));
736
+ return;
737
+ } catch { /* fall through to error response */ }
738
+ }
665
739
  res.writeHead(500, { 'Content-Type': 'application/json' });
666
740
  res.end(JSON.stringify({ error: err.message }));
667
741
  }
@@ -706,6 +780,16 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
706
780
  res.writeHead(200, { 'Content-Type': 'application/json' });
707
781
  res.end(JSON.stringify(result));
708
782
  } catch (err) {
783
+ // Try reconnecting on dead session before returning error
784
+ if (isDeadSession(err) && await tryReconnect()) {
785
+ try {
786
+ const { name, arguments: args } = parsed;
787
+ const result = await getClient().callTool({ name, arguments: args });
788
+ res.writeHead(200, { 'Content-Type': 'application/json' });
789
+ res.end(JSON.stringify(result));
790
+ return;
791
+ } catch { /* fall through */ }
792
+ }
709
793
  res.writeHead(200, { 'Content-Type': 'application/json' });
710
794
  res.end(
711
795
  JSON.stringify({
@@ -1145,6 +1229,22 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
1145
1229
  res.end('');
1146
1230
  }
1147
1231
  } catch (err) {
1232
+ // Try reconnecting on dead session before returning error
1233
+ if (isDeadSession(err) && await tryReconnect()) {
1234
+ try {
1235
+ const retryResult = await getClient().readResource({ uri });
1236
+ const retryContent = retryResult.contents?.[0];
1237
+ if (retryContent) {
1238
+ const mimeType = retryContent.mimeType || 'text/html';
1239
+ res.writeHead(200, {
1240
+ 'Content-Type': `${mimeType}; charset=utf-8`,
1241
+ 'X-Content-Type-Options': 'nosniff',
1242
+ });
1243
+ res.end(typeof retryContent.text === 'string' ? retryContent.text : '');
1244
+ return;
1245
+ }
1246
+ } catch { /* fall through */ }
1247
+ }
1148
1248
  res.writeHead(500, { 'Content-Type': 'text/plain' });
1149
1249
  res.end(`Error reading resource: ${err.message}`);
1150
1250
  }
@@ -1229,13 +1329,16 @@ export async function inspectServer(opts) {
1229
1329
  // Connect to the MCP server (with retry for local servers that may still be starting)
1230
1330
  let mcpConnection;
1231
1331
  let lastStderrOutput = [];
1332
+ // Track the resolved URL (after following redirects like /mcp → /mcp/).
1333
+ let resolvedServerUrl = serverArg;
1232
1334
  const maxRetries = 5;
1233
1335
  const connectionOpts = {};
1234
1336
  if (serverEnv) connectionOpts.env = serverEnv;
1235
1337
  if (serverCwd) connectionOpts.cwd = serverCwd;
1236
1338
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1237
1339
  try {
1238
- mcpConnection = await createMcpConnection(serverArg, connectionOpts);
1340
+ mcpConnection = await createMcpConnection(resolvedServerUrl, connectionOpts);
1341
+ if (mcpConnection.serverUrl) resolvedServerUrl = mcpConnection.serverUrl;
1239
1342
  break;
1240
1343
  } catch (err) {
1241
1344
  // Capture stderr from the failed connection attempt for diagnostics.
@@ -1244,16 +1347,17 @@ export async function inspectServer(opts) {
1244
1347
  }
1245
1348
 
1246
1349
  // If the server requires OAuth, negotiate it and retry once.
1247
- if (isAuthError(err) && serverArg.startsWith('http')) {
1350
+ if (isAuthError(err) && resolvedServerUrl.startsWith('http')) {
1248
1351
  console.log('Server requires authentication. Negotiating OAuth...');
1249
1352
  try {
1250
- const authProvider = await negotiateOAuth(serverArg);
1353
+ const authProvider = await negotiateOAuth(resolvedServerUrl);
1251
1354
  console.log('OAuth authorized. Reconnecting...');
1252
- mcpConnection = await createMcpConnection(serverArg, {
1355
+ mcpConnection = await createMcpConnection(resolvedServerUrl, {
1253
1356
  ...connectionOpts,
1254
1357
  type: 'oauth',
1255
1358
  authProvider,
1256
1359
  });
1360
+ if (mcpConnection.serverUrl) resolvedServerUrl = mcpConnection.serverUrl;
1257
1361
  break;
1258
1362
  } catch (oauthErr) {
1259
1363
  console.error(`OAuth negotiation failed: ${oauthErr.message}`);
@@ -1278,6 +1382,23 @@ export async function inspectServer(opts) {
1278
1382
 
1279
1383
  console.log('Connected. Discovering tools and resources...');
1280
1384
 
1385
+ // Monitor transport health. The MCP SDK opens a background SSE stream after
1386
+ // initialization. If this stream drops, the server may purge the session,
1387
+ // causing "Unknown session" errors on subsequent requests. Log lifecycle
1388
+ // events so we can diagnose connection issues when they occur.
1389
+ if (mcpConnection.transport) {
1390
+ const origOnError = mcpConnection.transport.onerror;
1391
+ mcpConnection.transport.onerror = (err) => {
1392
+ console.warn(`[inspect] MCP transport error: ${err?.message ?? err}`);
1393
+ origOnError?.(err);
1394
+ };
1395
+ const origOnClose = mcpConnection.transport.onclose;
1396
+ mcpConnection.transport.onclose = () => {
1397
+ console.warn('[inspect] MCP transport closed (session may be lost)');
1398
+ origOnClose?.();
1399
+ };
1400
+ }
1401
+
1281
1402
  // Extract app name and icon from server info (reported during MCP initialize)
1282
1403
  const serverInfo = mcpConnection.client.getServerVersion();
1283
1404
  const serverAppName = nameOverride ?? serverInfo?.name;
@@ -1333,7 +1454,7 @@ export async function inspectServer(opts) {
1333
1454
  </body>
1334
1455
  </html>`;
1335
1456
 
1336
- const inspectorServerUrl = serverArg;
1457
+ const inspectorServerUrl = resolvedServerUrl;
1337
1458
 
1338
1459
  // Create the Vite server.
1339
1460
  // Use the sunpeak package dir as root to avoid scanning the user's project
@@ -1357,7 +1478,7 @@ export async function inspectServer(opts) {
1357
1478
  sunpeakInspectEndpointsPlugin(
1358
1479
  () => mcpConnection.client,
1359
1480
  (newClient) => { mcpConnection.client = newClient; },
1360
- { callToolDirect: opts.callToolDirect, simulationsDir }
1481
+ { callToolDirect: opts.callToolDirect, simulationsDir, serverUrl: resolvedServerUrl, connectionOpts }
1361
1482
  ),
1362
1483
  // Serve /dist/{name}/{name}.html from the project directory (for Prod Resources mode).
1363
1484
  // The Inspector polls these paths via HEAD to check if built resources exist.
@@ -1441,8 +1562,16 @@ export async function inspectServer(opts) {
1441
1562
  ],
1442
1563
  server: {
1443
1564
  port,
1444
- open: open ?? (!process.env.CI && !process.env.SUNPEAK_LIVE_TEST),
1565
+ // Listen on all interfaces so both 127.0.0.1 (used by Playwright tests)
1566
+ // and localhost (used by interactive browsing) connect successfully.
1567
+ // Without this, Vite defaults to localhost which may resolve to IPv6-only
1568
+ // (::1) on macOS, causing ECONNREFUSED for IPv4 clients.
1569
+ host: '0.0.0.0',
1570
+ // Allow any hostname so the inspector works behind tunnels, in containers,
1571
+ // and with custom /etc/hosts entries. Without this, Vite 8's DNS rebinding
1572
+ // protection blocks requests whose Host header isn't localhost/127.0.0.1.
1445
1573
  allowedHosts: 'all',
1574
+ open: open ?? (!process.env.CI && !process.env.SUNPEAK_LIVE_TEST),
1446
1575
  },
1447
1576
  optimizeDeps: {
1448
1577
  // Only pre-bundle React — the virtual entry module imports sunpeak from
@@ -299,6 +299,28 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
299
299
 
300
300
  // Install dependencies with spinner
301
301
  const pm = d.detectPackageManager();
302
+
303
+ // Replace package manager references in README
304
+ if (pm !== 'pnpm') {
305
+ const readmePath = join(targetDir, 'README.md');
306
+ if (d.existsSync(readmePath)) {
307
+ const run = pm === 'npm' ? 'npm run' : pm;
308
+ const dlx = pm === 'npm' ? 'npx' : 'yarn dlx';
309
+ let readme = d.readFileSync(readmePath, 'utf-8');
310
+ readme = readme.replace(/pnpm dev\b/g, `${run} dev`);
311
+ readme = readme.replace(/pnpm build\b/g, `${run} build`);
312
+ readme = readme.replace(/pnpm start\b/g, `${run} start`);
313
+ readme = readme.replace(/pnpm test\b/g, `${run} test`);
314
+ readme = readme.replace(/pnpm test:unit\b/g, `${run} test:unit`);
315
+ readme = readme.replace(/pnpm test:e2e\b/g, `${run} test:e2e`);
316
+ readme = readme.replace(/pnpm test:visual\b/g, `${run} test:visual`);
317
+ readme = readme.replace(/pnpm test:live\b/g, `${run} test:live`);
318
+ readme = readme.replace(/pnpm test:eval\b/g, `${run} test:eval`);
319
+ readme = readme.replace(/pnpm add\b/g, pm === 'npm' ? 'npm install' : `${pm} add`);
320
+ readme = readme.replace(/pnpm dlx\b/g, dlx);
321
+ d.writeFileSync(readmePath, readme);
322
+ }
323
+ }
302
324
  const s = d.spinner();
303
325
  s.start(`Installing dependencies with ${pm}...`);
304
326
 
@@ -366,30 +388,32 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
366
388
  initialValue: true,
367
389
  });
368
390
  if (!clack.isCancel(installSkill) && installSkill) {
391
+ const dlx = pm === 'yarn' ? 'yarn dlx' : pm === 'npm' ? 'npx' : 'pnpm dlx';
369
392
  try {
370
- d.execSync('pnpm dlx skills add Sunpeak-AI/sunpeak@create-sunpeak-app Sunpeak-AI/sunpeak@test-mcp-server', {
393
+ d.execSync(`${dlx} skills add Sunpeak-AI/sunpeak@create-sunpeak-app Sunpeak-AI/sunpeak@test-mcp-server`, {
371
394
  cwd: targetDir,
372
395
  stdio: 'inherit',
373
396
  });
374
397
  } catch {
375
- d.console.log('Skill install skipped. You can install later with: pnpm dlx skills add Sunpeak-AI/sunpeak@create-sunpeak-app Sunpeak-AI/sunpeak@test-mcp-server');
398
+ d.console.log(`Skill install skipped. You can install later with: ${dlx} skills add Sunpeak-AI/sunpeak@create-sunpeak-app Sunpeak-AI/sunpeak@test-mcp-server`);
376
399
  }
377
400
  }
378
401
  }
379
402
 
403
+ const run = pm === 'npm' ? 'npm run' : pm;
380
404
  d.outro(`Done! To get started:
381
405
 
382
406
  cd ${projectName}
383
- sunpeak dev
407
+ ${run} dev
384
408
 
385
409
  Your project commands:
386
410
 
387
- sunpeak dev # Start dev server + MCP endpoint
388
- sunpeak build # Build for production
389
- sunpeak test # Run unit + e2e tests
390
- sunpeak test --eval # Run LLM evals (configure models in tests/evals/eval.config.ts)
391
- sunpeak test --visual # Run visual regression tests
392
- sunpeak test --live # Run live tests against real AI hosts`);
411
+ ${run} dev # Start dev server + MCP endpoint
412
+ ${run} build # Build for production
413
+ ${run} test # Run unit + e2e tests
414
+ ${run} test:eval # Run LLM evals (configure models in tests/evals/eval.config.ts)
415
+ ${run} test:visual # Run visual regression tests
416
+ ${run} test:live # Run live tests against real AI hosts`);
393
417
  }
394
418
 
395
419
  // Allow running directly