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.
- package/README.md +59 -89
- package/bin/commands/inspect.mjs +142 -13
- package/bin/commands/new.mjs +33 -9
- package/bin/commands/test-init.mjs +113 -100
- package/bin/commands/test.mjs +7 -2
- package/bin/lib/eval/eval-runner.mjs +7 -1
- package/bin/lib/inspect/inspect-config.mjs +1 -1
- package/bin/lib/live/live-config.d.mts +10 -0
- package/bin/lib/live/live-config.mjs +34 -2
- package/bin/lib/test/base-config.mjs +3 -1
- package/bin/lib/test/test-config.mjs +1 -1
- package/bin/sunpeak.js +16 -15
- package/dist/chatgpt/index.cjs +1 -1
- package/dist/chatgpt/index.js +1 -1
- package/dist/claude/index.cjs +1 -1
- package/dist/claude/index.js +1 -1
- package/dist/host/chatgpt/index.cjs +1 -1
- package/dist/host/chatgpt/index.js +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/inspector/index.cjs +1 -1
- package/dist/inspector/index.js +1 -1
- package/dist/{inspector-BBDa5yCm.js → inspector-60Na_Zc4.js} +2 -2
- package/dist/inspector-60Na_Zc4.js.map +1 -0
- package/dist/{inspector-DAA1Wiyh.cjs → inspector-D0qOqYX2.cjs} +2 -2
- package/dist/{inspector-BBDa5yCm.js.map → inspector-D0qOqYX2.cjs.map} +1 -1
- package/dist/mcp/index.cjs +1 -1
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.js +1 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/{use-app-DPkj5Jp_.cjs → use-app-B33mckz4.cjs} +7 -3
- package/dist/use-app-B33mckz4.cjs.map +1 -0
- package/dist/{use-app-Cr0auUa1.js → use-app-kv5GQr0G.js} +7 -3
- package/dist/use-app-kv5GQr0G.js.map +1 -0
- package/package.json +3 -3
- package/template/README.md +21 -23
- package/template/dist/albums/albums.html +1 -1
- package/template/dist/albums/albums.json +1 -1
- package/template/dist/carousel/carousel.html +1 -1
- package/template/dist/carousel/carousel.json +1 -1
- package/template/dist/map/map.html +1 -1
- package/template/dist/map/map.json +1 -1
- package/template/dist/review/review.html +1 -1
- package/template/dist/review/review.json +1 -1
- package/template/node_modules/.vite/deps/_metadata.json +3 -3
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps.js +6 -2
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_app-bridge.js +1 -1
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_app-bridge.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_react.js +6 -2
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_react.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/_metadata.json +22 -22
- package/template/package.json +2 -1
- package/template/tests/e2e/visual.spec.ts +2 -2
- package/dist/inspector-DAA1Wiyh.cjs.map +0 -1
- package/dist/use-app-Cr0auUa1.js.map +0 -1
- package/dist/use-app-DPkj5Jp_.cjs.map +0 -1
package/README.md
CHANGED
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
[](https://www.typescriptlang.org/)
|
|
17
17
|
[](https://reactjs.org/)
|
|
18
18
|
|
|
19
|
-
|
|
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.
|
|
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
|
-
|
|
39
|
+
sunpeak gives you a convention-over-configuration framework with the inspector and testing built in.
|
|
32
40
|
|
|
33
41
|
```bash
|
|
34
|
-
sunpeak
|
|
42
|
+
npx sunpeak new
|
|
35
43
|
```
|
|
36
44
|
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
import { test, expect } from 'sunpeak/test';
|
|
57
|
+
[App framework documentation →](https://sunpeak.ai/docs/mcp-apps-framework)
|
|
55
58
|
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
sunpeak new
|
|
74
|
+
npx sunpeak test
|
|
102
75
|
```
|
|
103
76
|
|
|
104
|
-
|
|
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
|
-
|
|
79
|
+
```ts
|
|
80
|
+
import { test, expect } from 'sunpeak/test';
|
|
107
81
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
+
---
|
|
131
96
|
|
|
132
|
-
|
|
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
|
-
|
|
104
|
+
npx sunpeak inspect --server http://localhost:8000/mcp
|
|
136
105
|
```
|
|
137
106
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/bin/commands/inspect.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
505
|
-
|
|
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(
|
|
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) &&
|
|
1350
|
+
if (isAuthError(err) && resolvedServerUrl.startsWith('http')) {
|
|
1248
1351
|
console.log('Server requires authentication. Negotiating OAuth...');
|
|
1249
1352
|
try {
|
|
1250
|
-
const authProvider = await negotiateOAuth(
|
|
1353
|
+
const authProvider = await negotiateOAuth(resolvedServerUrl);
|
|
1251
1354
|
console.log('OAuth authorized. Reconnecting...');
|
|
1252
|
-
mcpConnection = await createMcpConnection(
|
|
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 =
|
|
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
|
-
|
|
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
|
package/bin/commands/new.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
407
|
+
${run} dev
|
|
384
408
|
|
|
385
409
|
Your project commands:
|
|
386
410
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|