sunpeak 0.20.62 → 0.20.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,8 @@ Server-agnostic MCP testing framework and full-stack MCP App framework.
20
20
 
21
21
  MCP Apps are cross-platform, meaning sunpeak is a ChatGPT App framework, Claude Connector framework, and more.
22
22
 
23
+ ChatGPT apps are now submitted and published as plugins. The app remains an MCP-backed app, so sunpeak's architecture and runtime do not change. The plugin is the package used for local installation, review, and public distribution.
24
+
23
25
  ```bash
24
26
  npx sunpeak new
25
27
  ```
@@ -149,4 +151,5 @@ Toggle between hosts, themes, display modes, and device types from the sidebar.
149
151
  - [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)
150
152
  - [MCP Apps SDK](https://github.com/modelcontextprotocol/ext-apps)
151
153
  - [ChatGPT Apps SDK Design Guidelines](https://developers.openai.com/apps-sdk/concepts/design-guidelines)
154
+ - [Build ChatGPT Plugins](https://learn.chatgpt.com/docs/build-plugins) · [Submit ChatGPT Plugins](https://learn.chatgpt.com/docs/submit-plugins)
152
155
  - [Troubleshooting](https://sunpeak.ai/docs/app-framework/guides/troubleshooting)
@@ -1032,6 +1032,12 @@ function appendInspectorRequestToken(resourceUrl, requestToken) {
1032
1032
  }
1033
1033
  }
1034
1034
 
1035
+ function isInspectorRequestTokenValid(req, requestToken) {
1036
+ if (!requestToken) return true;
1037
+ const url = new URL(req.url, 'http://localhost');
1038
+ return url.searchParams.get('__sunpeak_token') === requestToken;
1039
+ }
1040
+
1035
1041
  function addInspectorRequestTokenToSimulations(simulations, requestToken) {
1036
1042
  if (!requestToken || !simulations || typeof simulations !== 'object') return simulations;
1037
1043
  return Object.fromEntries(
@@ -2073,12 +2079,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
2073
2079
  }
2074
2080
 
2075
2081
  function requireInspectorRequestToken(req, res) {
2076
- if (!pluginOpts.requestToken) return true;
2077
- const fetchSiteHeader = req.headers['sec-fetch-site'];
2078
- const fetchSite = Array.isArray(fetchSiteHeader) ? fetchSiteHeader[0] : fetchSiteHeader;
2079
- if (fetchSite !== 'cross-site') return true;
2080
- const url = new URL(req.url, 'http://localhost');
2081
- if (url.searchParams.get('__sunpeak_token') === pluginOpts.requestToken) return true;
2082
+ if (isInspectorRequestTokenValid(req, pluginOpts.requestToken)) return true;
2082
2083
  res.writeHead(403, { 'Content-Type': 'text/plain' });
2083
2084
  res.end('Forbidden: missing or invalid inspector request token');
2084
2085
  return false;
@@ -3090,6 +3091,7 @@ export const _securityTestExports = {
3090
3091
  formatSharedAppContextForModel,
3091
3092
  addInspectorRequestTokenToSimulations,
3092
3093
  appendInspectorRequestToken,
3094
+ isInspectorRequestTokenValid,
3093
3095
  normalizeApiKey,
3094
3096
  normalizeModelChatMessages,
3095
3097
  normalizeModelAppContext,
@@ -11,7 +11,7 @@ import { HostPage } from './host-page.mjs';
11
11
  /**
12
12
  * All ChatGPT DOM selectors in one place for easy maintenance.
13
13
  *
14
- * Last verified: 2026-03-24 via live Playwright inspection.
14
+ * Updated: 2026-07-10 for the documented ChatGPT Plugins flow.
15
15
  */
16
16
  const SELECTORS = {
17
17
  // Chat interface
@@ -23,8 +23,9 @@ const SELECTORS = {
23
23
  loggedInIndicator: '[data-testid="accounts-profile-button"]',
24
24
  loginPage: 'button:has-text("Log in")',
25
25
 
26
- // Settings navigation
27
- appsTab: '[role="tab"]:has-text("Apps")',
26
+ // Plugin directory navigation
27
+ createdByYou:
28
+ 'button:has-text("Created by you"), [role="tab"]:has-text("Created by you"), a:has-text("Created by you")',
28
29
  refreshButton: 'button:has-text("Refresh")',
29
30
  reconnectButton: 'button:has-text("Reconnect")',
30
31
 
@@ -40,40 +41,51 @@ const SELECTORS = {
40
41
 
41
42
  const URLS = {
42
43
  base: 'https://chatgpt.com',
43
- settings: 'https://chatgpt.com/#settings/Connectors',
44
+ plugins: 'https://chatgpt.com/plugins',
44
45
  };
45
46
 
47
+ const PLUGIN_SETUP_HINT =
48
+ 'Enable Developer mode from the bottom-left user menu under Settings > Security and login > Developer mode, then add the app from Plugins > +.';
49
+
46
50
  export { SELECTORS as CHATGPT_SELECTORS, URLS as CHATGPT_URLS };
47
51
 
48
52
  export class ChatGPTPage extends HostPage {
49
- get hostId() { return 'chatgpt'; }
50
- get hostName() { return 'ChatGPT'; }
51
- get selectors() { return SELECTORS; }
52
- get urls() { return URLS; }
53
+ get hostId() {
54
+ return 'chatgpt';
55
+ }
56
+ get hostName() {
57
+ return 'ChatGPT';
58
+ }
59
+ get selectors() {
60
+ return SELECTORS;
61
+ }
62
+ get urls() {
63
+ return URLS;
64
+ }
53
65
 
54
66
  /**
55
- * Refresh the MCP server connection in ChatGPT settings.
56
- * Navigates to Settings > Apps, clicks the app entry, clicks Refresh,
67
+ * Refresh the MCP server connection from the ChatGPT Plugins directory.
68
+ * Opens Plugins, finds the developer-mode app, clicks Refresh,
57
69
  * and waits for the success/error toast.
58
70
  */
59
71
  async refreshMcpServer({ tunnelUrl, appName } = {}) {
60
- await this.page.goto(URLS.settings, { waitUntil: 'domcontentloaded' });
72
+ await this.page.goto(URLS.plugins, { waitUntil: 'domcontentloaded' });
61
73
  await this.page.waitForTimeout(3_000);
62
74
 
63
75
  const found = await this._findAndClickRefresh(appName);
64
76
 
65
77
  if (!found) {
66
- const appsTab = this.page.locator(SELECTORS.appsTab);
67
- const hasAppsTab = await appsTab.isVisible().catch(() => false);
68
- if (hasAppsTab) {
69
- await appsTab.click();
78
+ const createdByYou = this.page.locator(SELECTORS.createdByYou).first();
79
+ const hasCreatedByYou = await createdByYou.isVisible().catch(() => false);
80
+ if (hasCreatedByYou) {
81
+ await createdByYou.click();
70
82
  await this.page.waitForTimeout(2_000);
71
83
  const retryFound = await this._findAndClickRefresh(appName);
72
84
  if (!retryFound) {
73
- await this._screenshotAndThrow('refresh-mcp-settings', tunnelUrl);
85
+ await this._screenshotAndThrow('refresh-mcp-plugin', tunnelUrl, PLUGIN_SETUP_HINT);
74
86
  }
75
87
  } else {
76
- await this._screenshotAndThrow('no-apps-tab', tunnelUrl);
88
+ await this._screenshotAndThrow('developer-plugin-not-found', tunnelUrl, PLUGIN_SETUP_HINT);
77
89
  }
78
90
  }
79
91
 
@@ -82,7 +94,7 @@ export class ChatGPTPage extends HostPage {
82
94
  if (hasError) {
83
95
  throw new Error(
84
96
  `MCP server refresh failed in ChatGPT:\n${errorText.trim()}\n\n` +
85
- `Make sure your MCP dev server is running (pnpm dev) and your tunnel is active.`
97
+ `Make sure your MCP dev server is running (pnpm dev) and your tunnel is active.`
86
98
  );
87
99
  }
88
100
 
@@ -107,13 +119,18 @@ export class ChatGPTPage extends HostPage {
107
119
  }
108
120
 
109
121
  // Wait for the outer sandbox iframe
110
- await this.page.locator(SELECTORS.mcpAppOuterIframe).first().waitFor({ state: 'attached', timeout: 30_000 });
122
+ await this.page
123
+ .locator(SELECTORS.mcpAppOuterIframe)
124
+ .first()
125
+ .waitFor({ state: 'attached', timeout: 30_000 });
111
126
 
112
127
  // Wait for the inner frame to appear inside the sandboxed outer iframe.
113
128
  // waitForFunction can't cross the sandbox boundary, so use Playwright's frameLocator instead.
114
129
  const outerFrame = this.page.frameLocator(SELECTORS.mcpAppOuterIframe).first();
115
130
  await outerFrame
116
- .locator(`iframe[name="${SELECTORS.mcpAppInnerFrameName}"], iframe#${SELECTORS.mcpAppInnerFrameName}`)
131
+ .locator(
132
+ `iframe[name="${SELECTORS.mcpAppInnerFrameName}"], iframe#${SELECTORS.mcpAppInnerFrameName}`
133
+ )
117
134
  .waitFor({ state: 'attached', timeout: 15_000 });
118
135
 
119
136
  const appFrame = this.getAppIframe();
@@ -182,13 +199,11 @@ export class ChatGPTPage extends HostPage {
182
199
  return false;
183
200
  };
184
201
 
185
- if (await tryClickRefresh()) return true;
186
-
187
202
  if (appName) {
188
203
  const strategies = [
189
204
  () => this.page.getByText(appName, { exact: true }).first(),
190
- () => this.page.locator(`text=${appName}`).first(),
191
- () => this.page.locator(`a:has-text("${appName}"), [role="button"]:has-text("${appName}")`).first(),
205
+ () => this.page.getByRole('link', { name: appName, exact: true }).first(),
206
+ () => this.page.getByRole('button', { name: appName, exact: true }).first(),
192
207
  ];
193
208
 
194
209
  for (const getLocator of strategies) {
@@ -205,6 +220,6 @@ export class ChatGPTPage extends HostPage {
205
220
  }
206
221
  }
207
222
 
208
- return false;
223
+ return appName ? false : tryClickRefresh();
209
224
  }
210
225
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Runs exactly once before all workers. Two responsibilities:
5
5
  * 1. Authenticate — launch a browser, verify login, wait for user if needed.
6
- * 2. Refresh MCP server navigate to host settings and click Refresh so
6
+ * 2. Refresh MCP server: open the host's app management UI and click Refresh so
7
7
  * all workers start with pre-loaded resources.
8
8
  *
9
9
  * Auth approach:
@@ -150,7 +150,7 @@ export default async function globalSetup() {
150
150
  // Refresh MCP server in the SAME browser session.
151
151
  // This is critical — Cloudflare's cf_clearance cookie is HttpOnly and
152
152
  // won't be in the saved storageState. By refreshing here, the cookie
153
- // is still valid for navigating to settings.
153
+ // is still valid for navigating to the plugin directory.
154
154
  //
155
155
  // This MUST succeed — if the MCP server isn't reachable or the refresh
156
156
  // fails, tests will fail with confusing iframe/timeout errors.
@@ -23,8 +23,7 @@
23
23
  /**
24
24
  * @typedef {Object} HostUrls
25
25
  * @property {string} base - Host base URL (e.g., 'https://chatgpt.com')
26
- * @property {string} settings - MCP settings URL
27
- * @property {string} loginTestId - Test ID for login detection (if using getByTestId)
26
+ * @property {string} [plugins] - Host plugin or app management URL
28
27
  */
29
28
 
30
29
  export class HostPage {
@@ -80,7 +79,8 @@ export class HostPage {
80
79
  if (warnings.length > 0) {
81
80
  console.warn(
82
81
  `\n⚠️ ${this.hostName} DOM may have changed — update selectors in ${this.hostId}-page.mjs:\n` +
83
- warnings.join('\n') + '\n'
82
+ warnings.join('\n') +
83
+ '\n'
84
84
  );
85
85
  }
86
86
 
@@ -135,14 +135,14 @@ export class HostPage {
135
135
  // Not logged in. Wait for the user to authenticate in this browser window.
136
136
  console.log(
137
137
  `\n` +
138
- `╔══════════════════════════════════════════════════════════════╗\n` +
139
- `║ Not logged into ${this.hostName.padEnd(42)}║\n` +
140
- `║ ║\n` +
141
- `║ Please log in at: ${this.urls.base.padEnd(39)}║\n` +
142
- `║ in the browser window that just opened. ║\n` +
143
- `║ ║\n` +
144
- `║ Waiting up to 3 minutes... ║\n` +
145
- `╚══════════════════════════════════════════════════════════════╝\n`
138
+ `╔══════════════════════════════════════════════════════════════╗\n` +
139
+ `║ Not logged into ${this.hostName.padEnd(42)}║\n` +
140
+ `║ ║\n` +
141
+ `║ Please log in at: ${this.urls.base.padEnd(39)}║\n` +
142
+ `║ in the browser window that just opened. ║\n` +
143
+ `║ ║\n` +
144
+ `║ Waiting up to 3 minutes... ║\n` +
145
+ `╚══════════════════════════════════════════════════════════════╝\n`
146
146
  );
147
147
 
148
148
  // Poll for login — the user may need to pass Cloudflare + enter credentials
@@ -160,14 +160,14 @@ export class HostPage {
160
160
 
161
161
  throw new Error(
162
162
  `Login to ${this.hostName} timed out after 3 minutes.\n` +
163
- `Please log in at ${this.urls.base} in the browser window and try again.\n` +
164
- 'If the session expired, delete the .auth/ directory and try again.'
163
+ `Please log in at ${this.urls.base} in the browser window and try again.\n` +
164
+ 'If the session expired, delete the .auth/ directory and try again.'
165
165
  );
166
166
  }
167
167
 
168
168
  /**
169
- * Refresh the MCP server connection in host settings.
170
- * Subclasses must implement this each host has different settings UI.
169
+ * Refresh the MCP server connection in the host's app management UI.
170
+ * Subclasses must implement this because each host has a different flow.
171
171
  *
172
172
  * @param {Object} [options]
173
173
  * @param {string} [options.tunnelUrl] - Tunnel URL for error messages
@@ -228,9 +228,10 @@ export class HostPage {
228
228
  * Capture a debug screenshot and throw with helpful message.
229
229
  * @param {string} context - Context label for the screenshot filename
230
230
  * @param {string} [tunnelUrl] - Tunnel URL for the error message
231
+ * @param {string} [setupHint] - Host-specific setup steps
231
232
  * @protected
232
233
  */
233
- async _screenshotAndThrow(context, tunnelUrl) {
234
+ async _screenshotAndThrow(context, tunnelUrl, setupHint) {
234
235
  const screenshotPath = `/tmp/sunpeak-live-debug-${this.hostId}-${context}.png`;
235
236
  try {
236
237
  await this.page.screenshot({ path: screenshotPath, fullPage: true });
@@ -241,29 +242,34 @@ export class HostPage {
241
242
 
242
243
  try {
243
244
  const buttons = await this.page.locator('button').allTextContents();
244
- console.error('Visible buttons on page:', buttons.filter(t => t.trim()).join(', '));
245
+ console.error('Visible buttons on page:', buttons.filter((t) => t.trim()).join(', '));
245
246
  } catch {
246
247
  // Best effort
247
248
  }
248
249
 
249
250
  throw new Error(
250
- `Could not find Refresh/Reconnect button in ${this.hostName} settings.\n` +
251
- `Make sure your MCP server is added in ${this.hostName} settings` +
252
- (tunnelUrl ? ` with URL: ${tunnelUrl}/mcp` : '') +
253
- `\n\nDebug screenshot: ${screenshotPath}`
251
+ `Could not find Refresh/Reconnect in ${this.hostName} app management.\n` +
252
+ `Make sure your MCP server is added to ${this.hostName}` +
253
+ (tunnelUrl ? ` with URL: ${tunnelUrl}/mcp` : '') +
254
+ (setupHint ? `\n${setupHint}` : '') +
255
+ `\n\nDebug screenshot: ${screenshotPath}`
254
256
  );
255
257
  }
256
258
 
257
259
  /**
258
260
  * Wait for a toast/alert banner and check for errors.
259
- * Many hosts show success/error toasts after settings actions.
261
+ * Many hosts show success/error toasts after app management actions.
260
262
  * @param {Object} [options]
261
263
  * @param {string} [options.alertSelector='[role="alert"]'] - Selector for toast elements
262
264
  * @param {number} [options.timeout=30000] - Max time to wait
263
265
  * @param {number} [options.minTextLength=5] - Minimum text length to consider as a real toast
264
266
  * @protected
265
267
  */
266
- async _waitForToast({ alertSelector = '[role="alert"]', timeout = 30_000, minTextLength = 5 } = {}) {
268
+ async _waitForToast({
269
+ alertSelector = '[role="alert"]',
270
+ timeout = 30_000,
271
+ minTextLength = 5,
272
+ } = {}) {
267
273
  try {
268
274
  await this.page.waitForFunction(
269
275
  ({ selector, minLen }) => {
@@ -275,7 +281,7 @@ export class HostPage {
275
281
  return false;
276
282
  },
277
283
  { selector: alertSelector, minLen: minTextLength },
278
- { timeout },
284
+ { timeout }
279
285
  );
280
286
  } catch {
281
287
  console.warn('No toast detected — assuming success.');
@@ -67,6 +67,14 @@ async function fetchJson(page, path) {
67
67
  return response.json();
68
68
  }
69
69
 
70
+ async function getInspectorRequestToken(page) {
71
+ const baseURL = page.context()._options?.baseURL || '';
72
+ const response = await page.request.get(`${baseURL}/@id/__x00__virtual:sunpeak-inspect-entry`);
73
+ if (!response.ok()) return null;
74
+ const source = await response.text();
75
+ return source.match(/[?&]__sunpeak_token=([^"'&\\]+)/)?.[1] ?? null;
76
+ }
77
+
70
78
  /**
71
79
  * Read the tool result from the inspector's <script id="__tool-result"> element.
72
80
  */
@@ -199,9 +207,10 @@ const test = base.extend({
199
207
 
200
208
  async readResource(uri) {
201
209
  const baseURL = page.context()._options?.baseURL || '';
202
- const response = await page.request.get(
203
- `${baseURL}/__sunpeak/read-resource?uri=${encodeURIComponent(uri)}`
204
- );
210
+ const params = new URLSearchParams({ uri });
211
+ const requestToken = await getInspectorRequestToken(page);
212
+ if (requestToken) params.set('__sunpeak_token', requestToken);
213
+ const response = await page.request.get(`${baseURL}/__sunpeak/read-resource?${params}`);
205
214
  if (!response.ok()) {
206
215
  throw new Error(
207
216
  `readResource(${uri}) returned ${response.status()}: ${await response.text()}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sunpeak",
3
- "version": "0.20.62",
3
+ "version": "0.20.64",
4
4
  "description": "App framework, testing framework, and inspector for MCP Apps.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -76,7 +76,11 @@ pnpm dev
76
76
  ngrok http 8000
77
77
  ```
78
78
 
79
- You can then connect to the tunnel forwarding URL at the `/mcp` path from ChatGPT **in developer mode** to see your UI in action: `User > Settings > Apps > Create`
79
+ If Developer mode is not already enabled, click your user menu in the bottom-left corner and select `Settings > Security and login > Developer mode`.
80
+
81
+ Then return to the ChatGPT homepage and click `Plugins` in the sidebar. Open your app if it is already there, or select the `+` button and connect the tunnel forwarding URL at the `/mcp` path.
82
+
83
+ ChatGPT creates a developer-mode app backed by your MCP server. It appears under `Plugins`, but its MCP App UI and runtime do not change.
80
84
 
81
85
  Once your app is connected, send the name of the app and a tool, like `/sunpeak show review`, to ChatGPT.
82
86
 
@@ -86,8 +90,9 @@ Run automated tests against real ChatGPT with `pnpm test:live`. This opens your
86
90
 
87
91
  **One-time setup:**
88
92
 
89
- 1. Log into [chatgpt.com](https://chatgpt.com) in your browser (Chrome, Arc, Brave, or Edge)
90
- 2. Add your MCP server in ChatGPT settings: `Settings > Apps > Create` with your tunnel URL at `/mcp`
93
+ 1. If Developer mode is not already enabled, click your user menu in the bottom-left corner and select `Settings > Security and login > Developer mode`
94
+ 2. Return to the ChatGPT homepage and click `Plugins` in the sidebar
95
+ 3. Open your app if it is already there, or select `+` and add your tunnel URL at `/mcp`
91
96
 
92
97
  **Run live tests:**
93
98
 
@@ -133,6 +138,8 @@ pnpm start -- --json-logs # Structured JSON logging for production
133
138
 
134
139
  The server includes a `/health` endpoint for load balancer probes and monitoring. See the [Deployment Guide](https://sunpeak.ai/docs/app-framework/guides/deployment) for production operations details (reverse proxy, process management, Docker).
135
140
 
141
+ ChatGPT apps are submitted and published as plugins. Deploy this MCP server first, then create a `With MCP` submission in the [OpenAI Platform plugin portal](https://platform.openai.com/plugins). Submit the MCP server URL directly, not an existing ChatGPT app ID. See the [sunpeak publishing guide](https://sunpeak.ai/docs/app-framework/guides/deployment#publish-to-chatgpt) and OpenAI's [plugin submission guide](https://learn.chatgpt.com/docs/submit-plugins).
142
+
136
143
  ## Add a new UI (Resource)
137
144
 
138
145
  To add a new UI ([MCP Resource](https://sunpeak.ai/docs/mcp-apps/mcp/resources)), create a new directory under `src/resources/` with the following files:
@@ -162,7 +169,7 @@ If your app doesn't render in ChatGPT or Claude:
162
169
 
163
170
  1. **Check your tunnel** is running and pointing to the correct port
164
171
  2. **Restart `pnpm dev`** to clear stale connections
165
- 3. **Refresh or re-add the MCP server** in the host's settings (Settings > MCP Servers)
172
+ 3. **Refresh or re-add the MCP server** from the host's app or plugin management page
166
173
  4. **Hard refresh** the host page (`Cmd+Shift+R` / `Ctrl+Shift+R`)
167
174
  5. **Open a new chat** in the host (cached iframes persist per-conversation)
168
175
 
@@ -175,3 +182,4 @@ Full guide: [sunpeak.ai/docs/app-framework/guides/troubleshooting](https://sunpe
175
182
  - [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)
176
183
  - [MCP Apps SDK](https://github.com/modelcontextprotocol/ext-apps)
177
184
  - [ChatGPT Apps SDK Design Guidelines](https://developers.openai.com/apps-sdk/concepts/design-guidelines)
185
+ - [Build ChatGPT Plugins](https://learn.chatgpt.com/docs/build-plugins) · [Submit ChatGPT Plugins](https://learn.chatgpt.com/docs/submit-plugins)
@@ -12,5 +12,5 @@
12
12
  }
13
13
  },
14
14
  "name": "albums",
15
- "uri": "ui://albums-mr9dz635"
15
+ "uri": "ui://albums-mrf9zhts"
16
16
  }
@@ -12,5 +12,5 @@
12
12
  }
13
13
  },
14
14
  "name": "carousel",
15
- "uri": "ui://carousel-mr9dz635"
15
+ "uri": "ui://carousel-mrf9zhts"
16
16
  }
@@ -18,5 +18,5 @@
18
18
  }
19
19
  },
20
20
  "name": "map",
21
- "uri": "ui://map-mr9dz635"
21
+ "uri": "ui://map-mrf9zhts"
22
22
  }
@@ -12,5 +12,5 @@
12
12
  }
13
13
  },
14
14
  "name": "review",
15
- "uri": "ui://review-mr9dz635"
15
+ "uri": "ui://review-mrf9zhts"
16
16
  }