sunpeak 0.18.2 → 0.18.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.
Files changed (76) hide show
  1. package/bin/commands/dev.mjs +6 -2
  2. package/bin/commands/inspect.mjs +405 -5
  3. package/bin/commands/new.mjs +5 -0
  4. package/bin/lib/dev-overlay.mjs +50 -0
  5. package/bin/lib/live/live-config.d.mts +3 -0
  6. package/bin/lib/live/live-config.mjs +3 -1
  7. package/bin/lib/sandbox-server.mjs +11 -1
  8. package/dist/chatgpt/index.cjs +2 -2
  9. package/dist/chatgpt/index.js +2 -2
  10. package/dist/claude/index.cjs +1 -1
  11. package/dist/claude/index.js +1 -1
  12. package/dist/hooks/index.d.ts +1 -0
  13. package/dist/hooks/use-request-teardown.d.ts +21 -0
  14. package/dist/host/chatgpt/index.cjs +1 -1
  15. package/dist/host/chatgpt/index.js +1 -1
  16. package/dist/index.cjs +110 -70
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +68 -31
  20. package/dist/index.js.map +1 -1
  21. package/dist/inspector/hosts.d.ts +12 -0
  22. package/dist/inspector/index.cjs +2 -2
  23. package/dist/inspector/index.js +2 -2
  24. package/dist/inspector/inspector-url.d.ts +13 -0
  25. package/dist/inspector/mcp-app-host.d.ts +3 -0
  26. package/dist/inspector/simple-sidebar.d.ts +1 -1
  27. package/dist/inspector/use-inspector-state.d.ts +2 -0
  28. package/dist/inspector/use-mcp-connection.d.ts +9 -1
  29. package/dist/{inspector-CByJjmPD.cjs → inspector-CKc58UuI.cjs} +557 -136
  30. package/dist/inspector-CKc58UuI.cjs.map +1 -0
  31. package/dist/{inspector-ClhpqKLi.js → inspector-DZrN0kej.js} +556 -135
  32. package/dist/inspector-DZrN0kej.js.map +1 -0
  33. package/dist/{inspector-url-7qhtJwY6.cjs → inspector-url-C3LTKgXt.cjs} +3 -1
  34. package/dist/inspector-url-C3LTKgXt.cjs.map +1 -0
  35. package/dist/{inspector-url-DuEFmxLP.js → inspector-url-CyQcuBI9.js} +3 -1
  36. package/dist/inspector-url-CyQcuBI9.js.map +1 -0
  37. package/dist/mcp/index.cjs +119 -40
  38. package/dist/mcp/index.cjs.map +1 -1
  39. package/dist/mcp/index.js +116 -37
  40. package/dist/mcp/index.js.map +1 -1
  41. package/dist/style.css +12 -0
  42. package/dist/{use-app-X7JbGskk.js → use-app-BNbz1uzj.js} +51 -42
  43. package/dist/use-app-BNbz1uzj.js.map +1 -0
  44. package/dist/{use-app-D2h-aiyr.cjs → use-app-Dqh20JPP.cjs} +93 -72
  45. package/dist/use-app-Dqh20JPP.cjs.map +1 -0
  46. package/package.json +2 -2
  47. package/template/dist/albums/albums.html +3 -3
  48. package/template/dist/albums/albums.json +1 -1
  49. package/template/dist/carousel/carousel.html +3 -3
  50. package/template/dist/carousel/carousel.json +1 -1
  51. package/template/dist/map/map.html +3 -3
  52. package/template/dist/map/map.json +1 -1
  53. package/template/dist/review/review.html +3 -3
  54. package/template/dist/review/review.json +1 -1
  55. package/template/node_modules/.vite/deps/_metadata.json +3 -3
  56. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps.js +51 -42
  57. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps.js.map +1 -1
  58. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_app-bridge.js +56 -50
  59. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_app-bridge.js.map +1 -1
  60. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_react.js +52 -43
  61. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_react.js.map +1 -1
  62. package/template/node_modules/.vite-mcp/deps/_metadata.json +22 -22
  63. package/template/tests/e2e/albums.spec.ts +20 -16
  64. package/template/tests/e2e/carousel.spec.ts +1 -1
  65. package/template/tests/e2e/dev-overlay.spec.ts +118 -0
  66. package/template/tests/e2e/helpers.ts +13 -0
  67. package/template/tests/e2e/map.spec.ts +1 -1
  68. package/template/tests/e2e/review.spec.ts +1 -1
  69. package/template/tests/live/albums.spec.ts +10 -0
  70. package/template/tests/live/playwright.config.ts +1 -1
  71. package/dist/inspector-CByJjmPD.cjs.map +0 -1
  72. package/dist/inspector-ClhpqKLi.js.map +0 -1
  73. package/dist/inspector-url-7qhtJwY6.cjs.map +0 -1
  74. package/dist/inspector-url-DuEFmxLP.js.map +0 -1
  75. package/dist/use-app-D2h-aiyr.cjs.map +0 -1
  76. package/dist/use-app-X7JbGskk.js.map +0 -1
@@ -412,6 +412,7 @@ if (!Component) {
412
412
  if (import.meta.hot) {
413
413
  import.meta.hot.accept();
414
414
  }
415
+
415
416
  `;
416
417
  }
417
418
  },
@@ -547,11 +548,14 @@ if (import.meta.hot) {
547
548
  if (typeof mod.default !== 'function') {
548
549
  throw new Error(`Tool "${name}" has no default export handler`);
549
550
  }
551
+ const startTime = performance.now();
550
552
  const result = await mod.default(args, {});
553
+ const durationMs = Math.round((performance.now() - startTime) * 10) / 10;
551
554
  if (typeof result === 'string') {
552
- return { content: [{ type: 'text', text: result }] };
555
+ return { content: [{ type: 'text', text: result }], _meta: { _sunpeak: { requestTimeMs: durationMs } } };
553
556
  }
554
- return result;
557
+ const typed = result ?? {};
558
+ return { ...typed, _meta: { ...typed._meta, _sunpeak: { requestTimeMs: durationMs } } };
555
559
  }
556
560
  throw new Error(`Tool "${name}" not found`);
557
561
  },
@@ -20,6 +20,7 @@ const { join, resolve, dirname } = path;
20
20
  import { fileURLToPath, pathToFileURL } from 'url';
21
21
  import { getPort } from '../lib/get-port.mjs';
22
22
  import { startSandboxServer } from '../lib/sandbox-server.mjs';
23
+ import { getDevOverlayScript } from '../lib/dev-overlay.mjs';
23
24
 
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
26
  const SUNPEAK_PKG_DIR = resolve(__dirname, '..', '..');
@@ -76,12 +77,96 @@ Examples:
76
77
  `);
77
78
  }
78
79
 
80
+ /**
81
+ * Create an in-memory OAuth client provider for the inspector.
82
+ * The provider stores tokens, client info, and code verifier in memory.
83
+ * When `redirectToAuthorization()` is called, it stores the URL for retrieval.
84
+ *
85
+ * @param {string} redirectUrl - The callback URL for OAuth redirects
86
+ * @param {{ clientId?: string, clientSecret?: string }} [opts]
87
+ * @returns {{ provider: import('@modelcontextprotocol/sdk/client/auth.js').OAuthClientProvider, getAuthUrl: () => URL | undefined }}
88
+ */
89
+ function createInMemoryOAuthProvider(redirectUrl, opts = {}) {
90
+ let _tokens;
91
+ let _clientInfo;
92
+ let _codeVerifier;
93
+ let _authUrl;
94
+ let _discoveryState;
95
+ // Cryptographic state parameter for CSRF protection on the OAuth callback.
96
+ const _stateParam = crypto.randomUUID();
97
+
98
+ // If pre-registered client credentials were provided, seed the client info
99
+ // so the SDK skips dynamic client registration.
100
+ if (opts.clientId) {
101
+ _clientInfo = {
102
+ client_id: opts.clientId,
103
+ ...(opts.clientSecret ? { client_secret: opts.clientSecret } : {}),
104
+ };
105
+ }
106
+
107
+ const provider = {
108
+ get redirectUrl() {
109
+ return redirectUrl;
110
+ },
111
+ get clientMetadata() {
112
+ return {
113
+ redirect_uris: [new URL(redirectUrl)],
114
+ client_name: 'sunpeak Inspector',
115
+ token_endpoint_auth_method: opts.clientSecret ? 'client_secret_post' : 'none',
116
+ grant_types: ['authorization_code', 'refresh_token'],
117
+ response_types: ['code'],
118
+ };
119
+ },
120
+ // Return the state parameter so the SDK includes it in the authorization URL.
121
+ state() {
122
+ return _stateParam;
123
+ },
124
+ clientInformation() {
125
+ return _clientInfo;
126
+ },
127
+ saveClientInformation(info) {
128
+ _clientInfo = info;
129
+ },
130
+ tokens() {
131
+ return _tokens;
132
+ },
133
+ saveTokens(tokens) {
134
+ _tokens = tokens;
135
+ },
136
+ redirectToAuthorization(url) {
137
+ _authUrl = url;
138
+ },
139
+ saveCodeVerifier(verifier) {
140
+ _codeVerifier = verifier;
141
+ },
142
+ codeVerifier() {
143
+ return _codeVerifier;
144
+ },
145
+ // Cache discovery state so the second auth() call (token exchange)
146
+ // doesn't re-discover metadata from scratch.
147
+ saveDiscoveryState(state) {
148
+ _discoveryState = state;
149
+ },
150
+ discoveryState() {
151
+ return _discoveryState;
152
+ },
153
+ };
154
+
155
+ return {
156
+ provider,
157
+ getAuthUrl: () => _authUrl,
158
+ hasTokens: () => !!_tokens,
159
+ stateParam: _stateParam,
160
+ };
161
+ }
162
+
79
163
  /**
80
164
  * Create an MCP client connection.
81
165
  * @param {string} serverArg - URL or command string
166
+ * @param {{ type?: 'none' | 'bearer' | 'oauth', bearerToken?: string, authProvider?: import('@modelcontextprotocol/sdk/client/auth.js').OAuthClientProvider }} [authConfig]
82
167
  * @returns {Promise<{ client: import('@modelcontextprotocol/sdk/client/index.js').Client, transport: import('@modelcontextprotocol/sdk/types.js').Transport }>}
83
168
  */
84
- async function createMcpConnection(serverArg) {
169
+ async function createMcpConnection(serverArg, authConfig) {
85
170
  const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
86
171
  const client = new Client({ name: 'sunpeak-inspector', version: '1.0.0' });
87
172
 
@@ -90,7 +175,18 @@ async function createMcpConnection(serverArg) {
90
175
  const { StreamableHTTPClientTransport } = await import(
91
176
  '@modelcontextprotocol/sdk/client/streamableHttp.js'
92
177
  );
93
- const transport = new StreamableHTTPClientTransport(new URL(serverArg));
178
+
179
+ const transportOpts = {};
180
+
181
+ if (authConfig?.type === 'bearer' && authConfig.bearerToken) {
182
+ transportOpts.requestInit = {
183
+ headers: { Authorization: `Bearer ${authConfig.bearerToken}` },
184
+ };
185
+ } else if (authConfig?.type === 'oauth' && authConfig.authProvider) {
186
+ transportOpts.authProvider = authConfig.authProvider;
187
+ }
188
+
189
+ const transport = new StreamableHTTPClientTransport(new URL(serverArg), transportOpts);
94
190
  await client.connect(transport);
95
191
  return { client, transport };
96
192
  } else {
@@ -283,6 +379,16 @@ root.render(
283
379
  * @param {{ callToolDirect?: (name: string, args: Record<string, unknown>) => Promise<object>, simulationsDir?: string | null }} [pluginOpts]
284
380
  */
285
381
  function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
382
+ // In-memory OAuth state keyed by server URL, persisted across reconnects.
383
+ /** @type {Map<string, { provider: any, getAuthUrl: () => URL | undefined, hasTokens: () => boolean, stateParam: string }>} */
384
+ const oauthProviders = new Map();
385
+ // Map OAuth state parameter → { serverUrl, oauthState } for CSRF-safe callback matching.
386
+ // Stores a direct reference to the provider that initiated the flow, so even if
387
+ // oauthProviders[serverUrl] is overwritten by a concurrent flow, the callback
388
+ // still completes with the correct provider (which holds the right codeVerifier
389
+ // and clientInformation).
390
+ /** @type {Map<string, { serverUrl: string, oauthState: any }>} */
391
+ const pendingOAuthFlows = new Map();
286
392
  return {
287
393
  name: 'sunpeak-inspect-endpoints',
288
394
  configureServer(server) {
@@ -421,8 +527,21 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
421
527
  // Close old connection (best effort)
422
528
  try { await getClient().close(); } catch { /* ignore */ }
423
529
 
530
+ // Build auth config from request
531
+ const authConfig = parsed.auth;
532
+ let connectionAuth;
533
+ if (authConfig?.type === 'bearer' && authConfig.bearerToken) {
534
+ connectionAuth = { type: 'bearer', bearerToken: authConfig.bearerToken };
535
+ } else if (authConfig?.type === 'oauth') {
536
+ // Reuse existing OAuth provider if we have one for this server
537
+ const existing = oauthProviders.get(url);
538
+ if (existing?.hasTokens()) {
539
+ connectionAuth = { type: 'oauth', authProvider: existing.provider };
540
+ }
541
+ }
542
+
424
543
  // Create new connection
425
- const newConnection = await createMcpConnection(url);
544
+ const newConnection = await createMcpConnection(url, connectionAuth);
426
545
  setClient(newConnection.client);
427
546
 
428
547
  // Discover tools and resources from the new server
@@ -439,6 +558,273 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
439
558
  }
440
559
  });
441
560
 
561
+ // ── OAuth flow endpoints ──
562
+
563
+ // Start OAuth: discover metadata, register client, return authorization URL
564
+ server.middlewares.use('/__sunpeak/oauth/start', async (req, res) => {
565
+ if (req.method !== 'POST') {
566
+ res.writeHead(405);
567
+ res.end('Method not allowed');
568
+ return;
569
+ }
570
+
571
+ const body = await readRequestBody(req);
572
+ let parsed;
573
+ try { parsed = JSON.parse(body); } catch {
574
+ res.writeHead(400, { 'Content-Type': 'application/json' });
575
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
576
+ return;
577
+ }
578
+
579
+ const { url: serverUrl, scope, clientId, clientSecret } = parsed;
580
+ if (!serverUrl) {
581
+ res.writeHead(400, { 'Content-Type': 'application/json' });
582
+ res.end(JSON.stringify({ error: 'Missing url' }));
583
+ return;
584
+ }
585
+
586
+ try {
587
+ // Determine callback URL from the Vite server's address
588
+ const addr = server.httpServer?.address();
589
+ const port = typeof addr === 'object' && addr ? addr.port : 3000;
590
+ const callbackUrl = `http://localhost:${port}/__sunpeak/oauth/callback`;
591
+
592
+ // Check if we already have a working provider with tokens for this server.
593
+ // If so, try to connect directly before creating a fresh provider.
594
+ const existingState = oauthProviders.get(serverUrl);
595
+ if (existingState?.hasTokens()) {
596
+ try {
597
+ // Close old connection (best effort)
598
+ try { await getClient().close(); } catch { /* ignore */ }
599
+
600
+ const newConnection = await createMcpConnection(serverUrl, {
601
+ type: 'oauth',
602
+ authProvider: existingState.provider,
603
+ });
604
+ setClient(newConnection.client);
605
+ const simulations = await discoverSimulations(newConnection.client);
606
+ if (pluginOpts.simulationsDir) {
607
+ mergeSimulationFixtures(pluginOpts.simulationsDir, simulations);
608
+ }
609
+ res.writeHead(200, { 'Content-Type': 'application/json' });
610
+ res.end(JSON.stringify({ status: 'authorized', simulations }));
611
+ return;
612
+ } catch {
613
+ // Tokens may be expired, fall through to fresh auth below
614
+ }
615
+ }
616
+
617
+ // Always create a fresh provider for an explicit Authorize click.
618
+ // This ensures the user's current credentials (or lack thereof) are
619
+ // used, not stale ones from a previous attempt.
620
+ const oauthState = createInMemoryOAuthProvider(callbackUrl, { clientId, clientSecret });
621
+ oauthProviders.set(serverUrl, oauthState);
622
+
623
+ // Run the SDK auth flow — will call redirectToAuthorization() if needed
624
+ const { auth } = await import('@modelcontextprotocol/sdk/client/auth.js');
625
+ const result = await auth(oauthState.provider, {
626
+ serverUrl,
627
+ scope,
628
+ });
629
+
630
+ if (result === 'REDIRECT') {
631
+ const authUrl = oauthState.getAuthUrl();
632
+ if (!authUrl) {
633
+ throw new Error('OAuth flow requested redirect but no authorization URL was generated');
634
+ }
635
+ // Register the state parameter so the callback can find the right provider.
636
+ // Clean up any stale pending flows for the same server URL first
637
+ // (e.g., user closed the popup without completing the previous attempt).
638
+ for (const [key, val] of pendingOAuthFlows) {
639
+ if (val.serverUrl === serverUrl) pendingOAuthFlows.delete(key);
640
+ }
641
+ pendingOAuthFlows.set(oauthState.stateParam, { serverUrl, oauthState });
642
+ res.writeHead(200, { 'Content-Type': 'application/json' });
643
+ res.end(JSON.stringify({ status: 'redirect', authUrl: authUrl.toString() }));
644
+ } else {
645
+ // AUTHORIZED — tokens were already available (shouldn't normally happen on first call)
646
+ try { await getClient().close(); } catch { /* ignore */ }
647
+ const newConnection = await createMcpConnection(serverUrl, {
648
+ type: 'oauth',
649
+ authProvider: oauthState.provider,
650
+ });
651
+ setClient(newConnection.client);
652
+ const simulations = await discoverSimulations(newConnection.client);
653
+ if (pluginOpts.simulationsDir) {
654
+ mergeSimulationFixtures(pluginOpts.simulationsDir, simulations);
655
+ }
656
+ res.writeHead(200, { 'Content-Type': 'application/json' });
657
+ res.end(JSON.stringify({ status: 'authorized', simulations }));
658
+ }
659
+ } catch (err) {
660
+ res.writeHead(500, { 'Content-Type': 'application/json' });
661
+ res.end(JSON.stringify({ error: err.message }));
662
+ }
663
+ });
664
+
665
+ // OAuth callback: serves an HTML page that sends the code back to the inspector.
666
+ // The state parameter is validated server-side in /__sunpeak/oauth/complete.
667
+ server.middlewares.use('/__sunpeak/oauth/callback', async (req, res) => {
668
+ // Parse code + state from query params
669
+ const reqUrl = new URL(req.url, 'http://localhost');
670
+ const code = reqUrl.searchParams.get('code');
671
+ const state = reqUrl.searchParams.get('state');
672
+ const error = reqUrl.searchParams.get('error');
673
+ const errorDescription = reqUrl.searchParams.get('error_description');
674
+
675
+ // Escape values for safe embedding in <script> — JSON.stringify alone
676
+ // doesn't escape "</script>" sequences which would break out of the tag.
677
+ const safeJson = (val) => JSON.stringify(val).replace(/</g, '\\u003c');
678
+
679
+ const html = `<!DOCTYPE html>
680
+ <html><head><title>OAuth Callback</title></head>
681
+ <body>
682
+ <script>
683
+ (function() {
684
+ var code = ${safeJson(code)};
685
+ var state = ${safeJson(state)};
686
+ var error = ${safeJson(error)};
687
+ var errorDescription = ${safeJson(errorDescription)};
688
+
689
+ // Use our own origin as the postMessage targetOrigin to prevent leaking data cross-origin.
690
+ var origin = location.origin;
691
+
692
+ // Send a message to the opener window. Uses postMessage when window.opener is
693
+ // available, falls back to BroadcastChannel for OAuth providers that set
694
+ // Cross-Origin-Opener-Policy (COOP) which nullifies window.opener.
695
+ function notify(msg) {
696
+ if (window.opener) {
697
+ window.opener.postMessage(msg, origin);
698
+ } else if (typeof BroadcastChannel !== 'undefined') {
699
+ var bc = new BroadcastChannel('sunpeak-oauth');
700
+ bc.postMessage(msg);
701
+ bc.close();
702
+ }
703
+ }
704
+
705
+ if (error) {
706
+ notify({ type: 'sunpeak-oauth-callback', error: error, errorDescription: errorDescription });
707
+ document.body.textContent = 'Authorization failed: ' + (errorDescription || error);
708
+ setTimeout(function() { window.close(); }, 2000);
709
+ return;
710
+ }
711
+
712
+ if (!code) {
713
+ document.body.textContent = 'No authorization code received.';
714
+ return;
715
+ }
716
+
717
+ document.body.textContent = 'Completing authorization...';
718
+
719
+ // Post the code + state to the server to exchange for tokens.
720
+ // The state is validated server-side to prevent CSRF.
721
+ fetch('/__sunpeak/oauth/complete', {
722
+ method: 'POST',
723
+ headers: { 'Content-Type': 'application/json' },
724
+ body: JSON.stringify({ code: code, state: state })
725
+ })
726
+ .then(function(res) { return res.json(); })
727
+ .then(function(data) {
728
+ if (data.error) {
729
+ notify({ type: 'sunpeak-oauth-callback', error: data.error });
730
+ document.body.textContent = 'Authorization failed: ' + data.error;
731
+ } else {
732
+ notify({ type: 'sunpeak-oauth-callback', success: true, simulations: data.simulations });
733
+ document.body.textContent = 'Authorized! You can close this window.';
734
+ }
735
+ setTimeout(function() { window.close(); }, 1000);
736
+ })
737
+ .catch(function(err) {
738
+ notify({ type: 'sunpeak-oauth-callback', error: err.message });
739
+ document.body.textContent = 'Error: ' + err.message;
740
+ });
741
+ })();
742
+ </script>
743
+ </body></html>`;
744
+
745
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
746
+ res.end(html);
747
+ });
748
+
749
+ // Complete OAuth: exchange authorization code for tokens and connect
750
+ server.middlewares.use('/__sunpeak/oauth/complete', async (req, res) => {
751
+ if (req.method !== 'POST') {
752
+ res.writeHead(405);
753
+ res.end('Method not allowed');
754
+ return;
755
+ }
756
+
757
+ const body = await readRequestBody(req);
758
+ let parsed;
759
+ try { parsed = JSON.parse(body); } catch {
760
+ res.writeHead(400, { 'Content-Type': 'application/json' });
761
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
762
+ return;
763
+ }
764
+
765
+ const { code, state } = parsed;
766
+ if (!code) {
767
+ res.writeHead(400, { 'Content-Type': 'application/json' });
768
+ res.end(JSON.stringify({ error: 'Missing authorization code' }));
769
+ return;
770
+ }
771
+ if (!state) {
772
+ res.writeHead(400, { 'Content-Type': 'application/json' });
773
+ res.end(JSON.stringify({ error: 'Missing state parameter' }));
774
+ return;
775
+ }
776
+
777
+ // Look up the provider via the state parameter (CSRF protection).
778
+ // Uses the direct provider reference from the pending flow, not the
779
+ // oauthProviders map, so concurrent flows for the same server URL
780
+ // don't clobber each other's codeVerifier/clientInformation.
781
+ const pending = pendingOAuthFlows.get(state);
782
+ pendingOAuthFlows.delete(state); // Consume — single-use
783
+
784
+ if (!pending) {
785
+ res.writeHead(400, { 'Content-Type': 'application/json' });
786
+ res.end(JSON.stringify({ error: 'Invalid or expired OAuth state. Start the flow again.' }));
787
+ return;
788
+ }
789
+
790
+ const { serverUrl, oauthState } = pending;
791
+
792
+ try {
793
+ // Exchange the code for tokens
794
+ const { auth } = await import('@modelcontextprotocol/sdk/client/auth.js');
795
+ const result = await auth(oauthState.provider, {
796
+ serverUrl,
797
+ authorizationCode: code,
798
+ });
799
+
800
+ if (result !== 'AUTHORIZED') {
801
+ throw new Error('Token exchange did not result in authorization');
802
+ }
803
+
804
+ // Store the now-authorized provider so reconnects can reuse tokens.
805
+ oauthProviders.set(serverUrl, oauthState);
806
+
807
+ // Create MCP connection with the authorized provider
808
+ try { await getClient().close(); } catch { /* ignore */ }
809
+ const newConnection = await createMcpConnection(serverUrl, {
810
+ type: 'oauth',
811
+ authProvider: oauthState.provider,
812
+ });
813
+ setClient(newConnection.client);
814
+
815
+ const simulations = await discoverSimulations(newConnection.client);
816
+ if (pluginOpts.simulationsDir) {
817
+ mergeSimulationFixtures(pluginOpts.simulationsDir, simulations);
818
+ }
819
+
820
+ res.writeHead(200, { 'Content-Type': 'application/json' });
821
+ res.end(JSON.stringify({ status: 'ok', simulations }));
822
+ } catch (err) {
823
+ res.writeHead(500, { 'Content-Type': 'application/json' });
824
+ res.end(JSON.stringify({ error: err.message }));
825
+ }
826
+ });
827
+
442
828
  // Read resource from connected server
443
829
  server.middlewares.use('/__sunpeak/read-resource', async (req, res) => {
444
830
  const url = new URL(req.url, 'http://localhost');
@@ -460,9 +846,23 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
460
846
  }
461
847
 
462
848
  const mimeType = content.mimeType || 'text/html';
463
- res.writeHead(200, { 'Content-Type': `${mimeType}; charset=utf-8` });
849
+ res.writeHead(200, {
850
+ 'Content-Type': `${mimeType}; charset=utf-8`,
851
+ 'X-Content-Type-Options': 'nosniff',
852
+ });
464
853
  if (typeof content.text === 'string') {
465
- res.end(content.text);
854
+ const stripOverlay = url.searchParams.get('devOverlay') === 'false';
855
+ let text = content.text;
856
+ if (stripOverlay) {
857
+ // Strip dev overlay (e.g., for e2e tests)
858
+ text = text.replace(/<script>(?:(?!<\/script>)[\s\S])*?__sunpeak-dev-timing(?:(?!<\/script>)[\s\S])*?<\/script>/g, '');
859
+ } else if (process.env.SUNPEAK_DEV_OVERLAY !== 'false' && !text.includes('__sunpeak-dev-timing') && text.includes('</body>')) {
860
+ // Inject dev overlay into resources from non-sunpeak servers.
861
+ // The overlay shows resource served timestamp and tool timing (from
862
+ // _meta._sunpeak.requestTimeMs on the PostMessage tool-result notification).
863
+ text = text.replace('</body>', `${getDevOverlayScript(Date.now(), null)}\n</body>`);
864
+ }
865
+ res.end(text);
466
866
  } else if (content.blob) {
467
867
  res.end(Buffer.from(content.blob, 'base64'));
468
868
  } else {
@@ -175,6 +175,11 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
175
175
  return false;
176
176
  }
177
177
 
178
+ // Skip framework-internal test files (dev overlay tests are for sunpeak development, not user projects)
179
+ if ((src.includes('/tests/e2e/') || src.includes('/tests/live/')) && name.startsWith('dev-')) {
180
+ return false;
181
+ }
182
+
178
183
  // Skip deps.json files (build-time metadata, not needed in scaffolded projects)
179
184
  if (name === 'deps.json' && src.includes('/resources/')) {
180
185
  return false;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Generate an inline script that shows a dev overlay with resource served timestamp
3
+ * and tool call request timing.
4
+ *
5
+ * The resource timestamp is baked into the HTML at readResource time. Tool timing
6
+ * arrives two ways:
7
+ * 1. Baked-in `toolMs` from readResource time (works when the tool call precedes the
8
+ * resource read, which is the case for Claude and the inspector's initial render).
9
+ * 2. `_meta._sunpeak.requestTimeMs` on the tool result PostMessage (handles inspector
10
+ * Re-run and hosts that pass `_meta` through to the resource iframe).
11
+ *
12
+ * @param {number} servedAt - Unix timestamp (ms) when the resource HTML was generated/served.
13
+ * @param {number | null} toolMs - Most recent tool call duration (ms), or null if no call yet.
14
+ * @returns {string} HTML script tag with the dev overlay.
15
+ */
16
+ export function getDevOverlayScript(servedAt, toolMs) {
17
+ return `<script>
18
+ (function(){
19
+ var servedAt=${servedAt};
20
+ var el=null,hidden=false,lastMs=${toolMs ?? 'null'};
21
+ function fmt(ts){var d=new Date(ts);var h=d.getHours(),m=d.getMinutes(),s=d.getSeconds();return (h<10?'0':'')+h+':'+(m<10?'0':'')+m+':'+(s<10?'0':'')+s}
22
+ function make(){
23
+ var existing=document.getElementById('__sunpeak-dev-timing');
24
+ if(existing)return existing;
25
+ var b=document.createElement('button');b.id='__sunpeak-dev-timing';
26
+ b.style.cssText='position:fixed;bottom:8px;right:8px;z-index:2147483647;display:grid;grid-template-columns:auto auto;gap:0 6px;align-items:baseline;padding:5px 8px;border-radius:6px;border:1px solid rgba(128,128,128,0.25);background:rgba(0,0,0,0.75);backdrop-filter:blur(8px);color:#e5e5e5;font-size:11px;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;line-height:1.4;cursor:pointer;user-select:none;opacity:0.85;transition:opacity 150ms;';
27
+ b.onmouseenter=function(){b.style.opacity='1'};
28
+ b.onmouseleave=function(){b.style.opacity='0.85'};
29
+ b.onclick=function(){hidden=!hidden;upd()};
30
+ document.body.appendChild(b);return b;
31
+ }
32
+ function upd(){
33
+ if(!el)el=make();
34
+ if(hidden){el.title='Show dev info';el.innerHTML='<span style="grid-column:1/-1;font-size:9px;text-align:center">DEV</span>';return}
35
+ var h='';
36
+ h+='<span style="text-align:right;color:rgba(255,255,255,0.5);white-space:nowrap">Resource:</span><span style="white-space:nowrap">'+fmt(servedAt)+'</span>';
37
+ if(lastMs!=null)h+='<span style="text-align:right;color:rgba(255,255,255,0.5);white-space:nowrap">Tool:</span><span style="white-space:nowrap">'+(lastMs%1===0?lastMs:lastMs.toFixed(1))+'ms</span>';
38
+ el.title='Hide dev info';el.innerHTML=h;
39
+ }
40
+ upd();
41
+ window.addEventListener('message',function(e){
42
+ var d=e.data;if(!d||typeof d!=='object')return;
43
+ if(d.method!=='ui/notifications/tool-result')return;
44
+ var p=d.params;if(!p)return;
45
+ var ms=p._meta&&p._meta._sunpeak&&p._meta._sunpeak.requestTimeMs;
46
+ if(typeof ms==='number'){lastMs=ms;upd()}
47
+ });
48
+ })();
49
+ </script>`;
50
+ }
@@ -23,6 +23,9 @@ export interface LiveConfigOptions {
23
23
  /** Browser permissions to grant (e.g., ['geolocation']). */
24
24
  permissions?: string[];
25
25
 
26
+ /** Show the dev overlay (resource timestamp + tool timing) in resources. Default: true */
27
+ devOverlay?: boolean;
28
+
26
29
  /** Additional Playwright `use` options, merged with defaults. */
27
30
  use?: Record<string, unknown>;
28
31
  }
@@ -32,6 +32,7 @@ const GLOBAL_SETUP_PATH = join(__dirname, 'global-setup.mjs');
32
32
  * @param {string} [options.timezoneId] - Timezone (e.g., 'America/New_York')
33
33
  * @param {{ latitude: number, longitude: number }} [options.geolocation] - Geolocation coordinates
34
34
  * @param {string[]} [options.permissions] - Browser permissions to grant (e.g., ['geolocation'])
35
+ * @param {boolean} [options.devOverlay=true] - Show the dev overlay (resource timestamp + tool timing) in resources
35
36
  * @param {Object} [options.use] - Additional Playwright `use` options (merged with defaults)
36
37
  */
37
38
  export function createLiveConfig(hostOptions, options = {}) {
@@ -40,6 +41,7 @@ export function createLiveConfig(hostOptions, options = {}) {
40
41
  testDir = '.',
41
42
  authDir,
42
43
  vitePort = getPortSync(3456),
44
+ devOverlay = true,
43
45
  colorScheme,
44
46
  viewport,
45
47
  locale,
@@ -89,7 +91,7 @@ export function createLiveConfig(hostOptions, options = {}) {
89
91
  },
90
92
  ],
91
93
  webServer: {
92
- command: `SUNPEAK_LIVE_TEST=1 SUNPEAK_SANDBOX_PORT=${getPortSync(24680)} pnpm dev -- --prod-resources --port ${vitePort}`,
94
+ command: `SUNPEAK_LIVE_TEST=1 SUNPEAK_SANDBOX_PORT=${getPortSync(24680)}${devOverlay ? '' : ' SUNPEAK_DEV_OVERLAY=false'} pnpm dev -- --prod-resources --port ${vitePort}`,
93
95
  url: `http://localhost:${vitePort}/health`,
94
96
  reuseExistingServer: !process.env.CI,
95
97
  timeout: 60_000,
@@ -97,7 +97,7 @@ function generateProxyHtml(theme, platform) {
97
97
  <head>
98
98
  <meta name="color-scheme" content="${colorScheme}" />
99
99
  <style>
100
- html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
100
+ html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: transparent; }
101
101
  iframe { border: none; width: 100%; height: 100%; display: block; }
102
102
  </style>
103
103
  </head>
@@ -168,6 +168,16 @@ iframe { border: none; width: 100%; height: 100%; display: block; }
168
168
  return;
169
169
  }
170
170
 
171
+ // Sync color-scheme on the inner iframe element when theme changes.
172
+ // This ensures prefers-color-scheme resolves correctly inside the app.
173
+ // Important: do NOT set color-scheme on the proxy's own document —
174
+ // changing it from the initial 'dark' causes Chrome to re-evaluate
175
+ // the CSS Canvas as opaque white, blocking the host's conversation
176
+ // background from showing through the transparent proxy.
177
+ if (data.method === 'ui/notifications/host-context-changed' && data.params && data.params.theme) {
178
+ if (innerFrame) innerFrame.style.colorScheme = data.params.theme;
179
+ }
180
+
171
181
  // Forward all other messages to the inner iframe
172
182
  if (innerWindow) {
173
183
  try { innerWindow.postMessage(data, '*'); } catch(e) { /* detached */ }
@@ -1,8 +1,8 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../chunk-9hOWP6kD.cjs");
3
3
  require("../protocol-jbxhzcnS.cjs");
4
- const require_inspector = require("../inspector-CByJjmPD.cjs");
5
- const require_inspector_url = require("../inspector-url-7qhtJwY6.cjs");
4
+ const require_inspector = require("../inspector-CKc58UuI.cjs");
5
+ const require_inspector_url = require("../inspector-url-C3LTKgXt.cjs");
6
6
  const require_discovery = require("../discovery-Clu4uHp1.cjs");
7
7
  //#region src/chatgpt/index.ts
8
8
  var chatgpt_exports = /* @__PURE__ */ require_chunk.__exportAll({
@@ -1,7 +1,7 @@
1
1
  import { r as __exportAll } from "../chunk-D6g4UhsZ.js";
2
2
  import "../protocol-DJmRaBzO.js";
3
- import { _ as McpAppHost, d as ThemeProvider, f as useThemeContext, g as extractResourceCSP, h as IframeResource, n as resolveServerToolResult, t as Inspector, v as SCREEN_WIDTHS } from "../inspector-ClhpqKLi.js";
4
- import { t as createInspectorUrl } from "../inspector-url-DuEFmxLP.js";
3
+ import { _ as McpAppHost, d as ThemeProvider, f as useThemeContext, g as extractResourceCSP, h as IframeResource, n as resolveServerToolResult, t as Inspector, v as SCREEN_WIDTHS } from "../inspector-DZrN0kej.js";
4
+ import { t as createInspectorUrl } from "../inspector-url-CyQcuBI9.js";
5
5
  import { c as toPascalCase, i as findResourceKey, n as extractSimulationKey, r as findResourceDirs, s as getComponentName, t as extractResourceKey } from "../discovery-Cgoegt62.js";
6
6
  //#region src/chatgpt/index.ts
7
7
  var chatgpt_exports = /* @__PURE__ */ __exportAll({
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  require("../chunk-9hOWP6kD.cjs");
3
3
  require("../protocol-jbxhzcnS.cjs");
4
- const require_inspector = require("../inspector-CByJjmPD.cjs");
4
+ const require_inspector = require("../inspector-CKc58UuI.cjs");
5
5
  exports.Inspector = require_inspector.Inspector;
@@ -1,3 +1,3 @@
1
1
  import "../protocol-DJmRaBzO.js";
2
- import { t as Inspector } from "../inspector-ClhpqKLi.js";
2
+ import { t as Inspector } from "../inspector-DZrN0kej.js";
3
3
  export { Inspector };
@@ -36,6 +36,7 @@ export type { OpenLinkParams } from './use-open-link';
36
36
  export { useReadServerResource } from './use-read-server-resource';
37
37
  export type { ReadServerResourceParams, ReadServerResourceResult, } from './use-read-server-resource';
38
38
  export { useRequestDisplayMode } from './use-request-display-mode';
39
+ export { useRequestTeardown } from './use-request-teardown';
39
40
  export type { AppDisplayMode } from './use-request-display-mode';
40
41
  export { useSendLog } from './use-send-log';
41
42
  export type { LogLevel, SendLogParams } from './use-send-log';