sunpeak 0.19.12 → 0.20.2
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 +2 -2
- package/bin/commands/inspect.mjs +361 -12
- package/bin/commands/test-init.mjs +190 -118
- package/bin/commands/test.mjs +12 -1
- package/bin/lib/eval/eval-runner.mjs +7 -1
- package/bin/lib/inspect/inspect-config.mjs +17 -2
- package/bin/lib/inspect/inspect-server.d.mts +32 -0
- package/bin/lib/inspect/inspect-server.mjs +11 -0
- package/bin/lib/live/live-config.d.mts +10 -0
- package/bin/lib/live/live-config.mjs +34 -2
- package/bin/lib/resolve-bin.mjs +39 -0
- package/bin/lib/test/base-config.mjs +6 -3
- package/bin/lib/test/matchers.mjs +2 -2
- package/bin/lib/test/test-config.mjs +19 -8
- package/bin/lib/test/test-fixtures.d.mts +52 -92
- package/bin/lib/test/test-fixtures.mjs +174 -147
- 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 +4 -4
- 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-D5DckQuU.js → inspector-BBDa5yCm.js} +57 -23
- package/dist/inspector-BBDa5yCm.js.map +1 -0
- package/dist/{inspector-jY9O18z9.cjs → inspector-DAA1Wiyh.cjs} +58 -24
- package/dist/inspector-DAA1Wiyh.cjs.map +1 -0
- package/dist/lib/discovery-cli.cjs +1 -1
- package/dist/mcp/index.cjs +22 -25
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.js +19 -22
- package/dist/mcp/index.js.map +1 -1
- package/dist/{use-app-Bfargfa3.js → use-app-Cr0auUa1.js} +2 -2
- package/dist/{use-app-Bfargfa3.js.map → use-app-Cr0auUa1.js.map} +1 -1
- package/dist/{use-app-CbsBEmwv.cjs → use-app-DPkj5Jp_.cjs} +2 -2
- package/dist/{use-app-CbsBEmwv.cjs.map → use-app-DPkj5Jp_.cjs.map} +1 -1
- package/package.json +17 -11
- package/template/dist/albums/albums.html +4 -4
- package/template/dist/albums/albums.json +1 -1
- package/template/dist/carousel/carousel.html +4 -4
- package/template/dist/carousel/carousel.json +1 -1
- package/template/dist/map/map.html +6 -6
- package/template/dist/map/map.json +1 -1
- package/template/dist/review/review.html +4 -4
- package/template/dist/review/review.json +1 -1
- package/template/node_modules/.bin/vite +2 -2
- package/template/node_modules/.bin/vitest +2 -2
- package/template/node_modules/.vite/deps/_metadata.json +4 -4
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps.js +1 -1
- 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 +1 -1
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_react.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/@testing-library_react.js +4 -4
- package/template/node_modules/.vite-mcp/deps/@testing-library_react.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/_metadata.json +33 -33
- package/template/node_modules/.vite-mcp/deps/{client-CU1wWud4.js → client-B_5CX--u.js} +7 -7
- package/template/node_modules/.vite-mcp/deps/{client-CU1wWud4.js.map → client-B_5CX--u.js.map} +1 -1
- package/template/node_modules/.vite-mcp/deps/embla-carousel-react.js +1 -1
- package/template/node_modules/.vite-mcp/deps/embla-carousel-react.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/react-dom.js +3 -3
- package/template/node_modules/.vite-mcp/deps/react-dom.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/react-dom_client.js +1 -1
- package/template/node_modules/.vite-mcp/deps/react.js +3 -3
- package/template/node_modules/.vite-mcp/deps/react.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/react_jsx-dev-runtime.js +2 -2
- package/template/node_modules/.vite-mcp/deps/react_jsx-dev-runtime.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/react_jsx-runtime.js +2 -2
- package/template/node_modules/.vite-mcp/deps/react_jsx-runtime.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/vitest.js +1024 -622
- package/template/node_modules/.vite-mcp/deps/vitest.js.map +1 -1
- package/template/package.json +6 -6
- package/template/tests/e2e/albums.spec.ts +24 -52
- package/template/tests/e2e/carousel.spec.ts +36 -58
- package/template/tests/e2e/map.spec.ts +35 -56
- package/template/tests/e2e/review.spec.ts +56 -85
- package/template/tests/e2e/visual.spec.ts +14 -12
- package/dist/inspector-D5DckQuU.js.map +0 -1
- package/dist/inspector-jY9O18z9.cjs.map +0 -1
package/README.md
CHANGED
|
@@ -53,8 +53,8 @@ Automatically test any MCP server against replicated ChatGPT and Claude runtimes
|
|
|
53
53
|
```ts
|
|
54
54
|
import { test, expect } from 'sunpeak/test';
|
|
55
55
|
|
|
56
|
-
test('review tool renders title', async ({
|
|
57
|
-
const result = await
|
|
56
|
+
test('review tool renders title', async ({ inspector }) => {
|
|
57
|
+
const result = await inspector.renderTool('review-diff');
|
|
58
58
|
const app = result.app();
|
|
59
59
|
await expect(app.locator('h1:has-text("Refactor")')).toBeVisible();
|
|
60
60
|
});
|
package/bin/commands/inspect.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import * as path from 'path';
|
|
|
18
18
|
const { existsSync, readdirSync, readFileSync } = fs;
|
|
19
19
|
const { join, resolve, dirname } = path;
|
|
20
20
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
21
|
+
import { createServer as createHttpServer } from 'http';
|
|
21
22
|
import { getPort } from '../lib/get-port.mjs';
|
|
22
23
|
import { startSandboxServer } from '../lib/sandbox-server.mjs';
|
|
23
24
|
import { getDevOverlayScript } from '../lib/dev-overlay.mjs';
|
|
@@ -35,6 +36,8 @@ function parseArgs(args) {
|
|
|
35
36
|
simulations: undefined,
|
|
36
37
|
port: undefined,
|
|
37
38
|
name: undefined,
|
|
39
|
+
env: undefined,
|
|
40
|
+
cwd: undefined,
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -47,6 +50,16 @@ function parseArgs(args) {
|
|
|
47
50
|
opts.port = Number(args[++i]);
|
|
48
51
|
} else if (arg === '--name' && i + 1 < args.length) {
|
|
49
52
|
opts.name = args[++i];
|
|
53
|
+
} else if (arg === '--env' && i + 1 < args.length) {
|
|
54
|
+
// Repeatable: --env KEY=VALUE --env KEY2=VALUE2
|
|
55
|
+
const pair = args[++i];
|
|
56
|
+
const eqIdx = pair.indexOf('=');
|
|
57
|
+
if (eqIdx > 0) {
|
|
58
|
+
opts.env = opts.env || {};
|
|
59
|
+
opts.env[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
|
|
60
|
+
}
|
|
61
|
+
} else if (arg === '--cwd' && i + 1 < args.length) {
|
|
62
|
+
opts.cwd = args[++i];
|
|
50
63
|
} else if (arg === '--help' || arg === '-h') {
|
|
51
64
|
printHelp();
|
|
52
65
|
process.exit(0);
|
|
@@ -68,11 +81,14 @@ Options:
|
|
|
68
81
|
--simulations <dir> Simulation JSON directory (opt-in, no default)
|
|
69
82
|
--port, -p <number> Dev server port (default: 3000)
|
|
70
83
|
--name <string> App name in inspector chrome
|
|
84
|
+
--env <KEY=VALUE> Environment variable for stdio servers (repeatable)
|
|
85
|
+
--cwd <path> Working directory for stdio servers
|
|
71
86
|
--help, -h Show this help
|
|
72
87
|
|
|
73
88
|
Examples:
|
|
74
89
|
sunpeak inspect --server http://localhost:8000/mcp
|
|
75
90
|
sunpeak inspect --server "python my_server.py"
|
|
91
|
+
sunpeak inspect --server "python server.py" --env API_KEY=sk-123 --cwd ./backend
|
|
76
92
|
sunpeak inspect --server http://localhost:8000/mcp --simulations tests/simulations
|
|
77
93
|
`);
|
|
78
94
|
}
|
|
@@ -160,11 +176,219 @@ function createInMemoryOAuthProvider(redirectUrl, opts = {}) {
|
|
|
160
176
|
};
|
|
161
177
|
}
|
|
162
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Negotiate OAuth with an MCP server and return an authenticated provider.
|
|
181
|
+
*
|
|
182
|
+
* Handles two cases:
|
|
183
|
+
* 1. Anonymous/auto-approved OAuth: the authorization endpoint redirects
|
|
184
|
+
* immediately back with a code (no user interaction needed).
|
|
185
|
+
* 2. Interactive OAuth: opens the authorization URL in the user's browser
|
|
186
|
+
* and waits for the callback.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} serverUrl - The MCP server URL
|
|
189
|
+
* @returns {Promise<import('@modelcontextprotocol/sdk/client/auth.js').OAuthClientProvider>}
|
|
190
|
+
*/
|
|
191
|
+
async function negotiateOAuth(serverUrl) {
|
|
192
|
+
const { auth } = await import('@modelcontextprotocol/sdk/client/auth.js');
|
|
193
|
+
|
|
194
|
+
// Start a temporary callback server for receiving the OAuth code.
|
|
195
|
+
const callbackPort = await getPort(24681);
|
|
196
|
+
const callbackUrl = `http://localhost:${callbackPort}/oauth/callback`;
|
|
197
|
+
|
|
198
|
+
const oauthState = createInMemoryOAuthProvider(callbackUrl);
|
|
199
|
+
const { provider } = oauthState;
|
|
200
|
+
|
|
201
|
+
// First call to auth() — discovers metadata, registers client, and either
|
|
202
|
+
// returns AUTHORIZED (client_credentials) or REDIRECT (authorization_code).
|
|
203
|
+
const result = await auth(provider, { serverUrl: new URL(serverUrl) });
|
|
204
|
+
|
|
205
|
+
if (result === 'AUTHORIZED') {
|
|
206
|
+
return provider;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// result === 'REDIRECT': we need to follow the authorization URL.
|
|
210
|
+
const authUrl = oauthState.getAuthUrl();
|
|
211
|
+
if (!authUrl) {
|
|
212
|
+
throw new Error('OAuth flow returned REDIRECT but no authorization URL was captured');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Try the anonymous/auto-approved path first: follow the authorization URL
|
|
216
|
+
// without a browser and see if it immediately redirects with a code.
|
|
217
|
+
const code = await tryAnonymousOAuth(authUrl.toString(), callbackUrl);
|
|
218
|
+
if (code) {
|
|
219
|
+
// Complete the flow with the authorization code.
|
|
220
|
+
const tokenResult = await auth(provider, {
|
|
221
|
+
serverUrl: new URL(serverUrl),
|
|
222
|
+
authorizationCode: code,
|
|
223
|
+
});
|
|
224
|
+
if (tokenResult === 'AUTHORIZED') {
|
|
225
|
+
return provider;
|
|
226
|
+
}
|
|
227
|
+
throw new Error('OAuth token exchange failed after anonymous authorization');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Anonymous path didn't work — this server requires interactive login.
|
|
231
|
+
// Start a callback server and open the auth URL in the user's browser.
|
|
232
|
+
const interactiveCode = await waitForInteractiveOAuth(
|
|
233
|
+
authUrl.toString(),
|
|
234
|
+
callbackUrl,
|
|
235
|
+
callbackPort
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const tokenResult = await auth(provider, {
|
|
239
|
+
serverUrl: new URL(serverUrl),
|
|
240
|
+
authorizationCode: interactiveCode,
|
|
241
|
+
});
|
|
242
|
+
if (tokenResult === 'AUTHORIZED') {
|
|
243
|
+
return provider;
|
|
244
|
+
}
|
|
245
|
+
throw new Error('OAuth token exchange failed after interactive authorization');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Try to complete OAuth without user interaction by following redirects.
|
|
250
|
+
* Returns the authorization code if the server auto-approves, or null if
|
|
251
|
+
* the server requires interactive login (returns an HTML page).
|
|
252
|
+
*
|
|
253
|
+
* @param {string} authUrl - The authorization URL
|
|
254
|
+
* @param {string} callbackUrl - The expected callback URL prefix
|
|
255
|
+
* @returns {Promise<string | null>}
|
|
256
|
+
*/
|
|
257
|
+
async function tryAnonymousOAuth(authUrl, callbackUrl) {
|
|
258
|
+
// Follow redirects manually to detect when the server redirects back
|
|
259
|
+
// to our callback URL with a code parameter.
|
|
260
|
+
let url = authUrl;
|
|
261
|
+
const maxRedirects = 10;
|
|
262
|
+
for (let i = 0; i < maxRedirects; i++) {
|
|
263
|
+
const response = await fetch(url, { redirect: 'manual' });
|
|
264
|
+
const location = response.headers.get('location');
|
|
265
|
+
|
|
266
|
+
if (!location) {
|
|
267
|
+
// No redirect — server returned a page (login form). Not auto-approved.
|
|
268
|
+
// Drain the response body to free the socket.
|
|
269
|
+
await response.text().catch(() => {});
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Resolve relative redirects.
|
|
274
|
+
const resolved = new URL(location, url).toString();
|
|
275
|
+
|
|
276
|
+
// Check if the redirect goes to our callback URL.
|
|
277
|
+
if (resolved.startsWith(callbackUrl)) {
|
|
278
|
+
const params = new URL(resolved).searchParams;
|
|
279
|
+
const code = params.get('code');
|
|
280
|
+
if (code) return code;
|
|
281
|
+
const error = params.get('error');
|
|
282
|
+
if (error) {
|
|
283
|
+
throw new Error(`OAuth authorization failed: ${error} — ${params.get('error_description') || ''}`);
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
url = resolved;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Wait for the user to complete an interactive OAuth flow in their browser.
|
|
296
|
+
* Starts a temporary HTTP server to receive the callback, opens the auth URL,
|
|
297
|
+
* and resolves with the authorization code.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} authUrl - The authorization URL to open in the browser
|
|
300
|
+
* @param {string} callbackUrl - Our callback URL
|
|
301
|
+
* @param {number} callbackPort - Port for the callback server
|
|
302
|
+
* @returns {Promise<string>}
|
|
303
|
+
*/
|
|
304
|
+
async function waitForInteractiveOAuth(authUrl, callbackUrl, callbackPort) {
|
|
305
|
+
return new Promise((resolve, reject) => {
|
|
306
|
+
let settled = false;
|
|
307
|
+
const settle = (fn, value) => {
|
|
308
|
+
if (settled) return;
|
|
309
|
+
settled = true;
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
server.close();
|
|
312
|
+
fn(value);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const server = createHttpServer((req, res) => {
|
|
316
|
+
const reqUrl = new URL(req.url, callbackUrl);
|
|
317
|
+
if (!reqUrl.pathname.startsWith('/oauth/callback')) {
|
|
318
|
+
res.writeHead(404);
|
|
319
|
+
res.end('Not found');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const code = reqUrl.searchParams.get('code');
|
|
324
|
+
const error = reqUrl.searchParams.get('error');
|
|
325
|
+
|
|
326
|
+
// Serve a simple page that tells the user they can close the tab.
|
|
327
|
+
const escHtml = (s) => s.replace(/[<>&"']/g, (c) =>
|
|
328
|
+
({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c]
|
|
329
|
+
);
|
|
330
|
+
const message = code
|
|
331
|
+
? 'Authorization complete. You can close this tab.'
|
|
332
|
+
: `Authorization failed: ${escHtml(error || 'unknown error')}`;
|
|
333
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
334
|
+
res.end(`<!DOCTYPE html><html><body><p>${message}</p></body></html>`);
|
|
335
|
+
|
|
336
|
+
if (code) {
|
|
337
|
+
settle(resolve, code);
|
|
338
|
+
} else {
|
|
339
|
+
settle(reject, new Error(`OAuth authorization failed: ${error || 'unknown error'}`));
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
server.on('error', (err) => {
|
|
344
|
+
settle(reject, new Error(`OAuth callback server failed: ${err.message}`));
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
server.listen(callbackPort, async () => {
|
|
348
|
+
console.log('Opening browser for OAuth authorization...');
|
|
349
|
+
// Use execFile with array args to avoid shell injection from the auth URL.
|
|
350
|
+
const { execFile } = await import('child_process');
|
|
351
|
+
const cmd = process.platform === 'darwin' ? 'open' :
|
|
352
|
+
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
353
|
+
execFile(cmd, [authUrl], (err) => {
|
|
354
|
+
if (err) console.error(`Failed to open browser: ${err.message}`);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Timeout after 2 minutes.
|
|
359
|
+
const timer = setTimeout(() => {
|
|
360
|
+
settle(reject, new Error('OAuth authorization timed out (2 minutes)'));
|
|
361
|
+
}, 120_000);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Detect if an error from createMcpConnection is an auth error (401/Unauthorized).
|
|
367
|
+
* @param {Error} err
|
|
368
|
+
* @returns {boolean}
|
|
369
|
+
*/
|
|
370
|
+
function isAuthError(err) {
|
|
371
|
+
// The MCP SDK throws UnauthorizedError for auth failures.
|
|
372
|
+
if (err.constructor?.name === 'UnauthorizedError') return true;
|
|
373
|
+
|
|
374
|
+
// StreamableHTTPError includes a status code in its message.
|
|
375
|
+
// Check for the specific "401" HTTP status pattern, not substring matches.
|
|
376
|
+
const msg = err.message || '';
|
|
377
|
+
if (msg.includes('invalid_token')) return true;
|
|
378
|
+
|
|
379
|
+
// Connection errors (ECONNREFUSED, ETIMEDOUT, etc.) are never auth errors.
|
|
380
|
+
if (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT') || msg.includes('ENOTFOUND')) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
|
|
163
387
|
/**
|
|
164
388
|
* Create an MCP client connection.
|
|
165
389
|
* @param {string} serverArg - URL or command string
|
|
166
|
-
* @param {{ type?: 'none' | 'bearer' | 'oauth', bearerToken?: string, authProvider?: import('@modelcontextprotocol/sdk/client/auth.js').OAuthClientProvider }} [authConfig]
|
|
167
|
-
* @returns {Promise<{ client: import('@modelcontextprotocol/sdk/client/index.js').Client, transport: import('@modelcontextprotocol/sdk/types.js').Transport }>}
|
|
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, serverUrl?: string, stderrOutput?: string[] }>}
|
|
168
392
|
*/
|
|
169
393
|
async function createMcpConnection(serverArg, authConfig) {
|
|
170
394
|
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
@@ -176,6 +400,19 @@ async function createMcpConnection(serverArg, authConfig) {
|
|
|
176
400
|
'@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
177
401
|
);
|
|
178
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
|
+
|
|
179
416
|
const transportOpts = {};
|
|
180
417
|
|
|
181
418
|
if (authConfig?.type === 'bearer' && authConfig.bearerToken) {
|
|
@@ -186,9 +423,9 @@ async function createMcpConnection(serverArg, authConfig) {
|
|
|
186
423
|
transportOpts.authProvider = authConfig.authProvider;
|
|
187
424
|
}
|
|
188
425
|
|
|
189
|
-
const transport = new StreamableHTTPClientTransport(new URL(
|
|
426
|
+
const transport = new StreamableHTTPClientTransport(new URL(finalUrl), transportOpts);
|
|
190
427
|
await client.connect(transport);
|
|
191
|
-
return { client, transport };
|
|
428
|
+
return { client, transport, serverUrl: finalUrl };
|
|
192
429
|
} else {
|
|
193
430
|
// Stdio transport — parse command string
|
|
194
431
|
const parts = serverArg.split(/\s+/);
|
|
@@ -197,9 +434,47 @@ async function createMcpConnection(serverArg, authConfig) {
|
|
|
197
434
|
const { StdioClientTransport } = await import(
|
|
198
435
|
'@modelcontextprotocol/sdk/client/stdio.js'
|
|
199
436
|
);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
437
|
+
|
|
438
|
+
const transportOpts = {
|
|
439
|
+
command,
|
|
440
|
+
args: cmdArgs,
|
|
441
|
+
stderr: 'pipe',
|
|
442
|
+
...(authConfig?.env ? { env: { ...process.env, ...authConfig.env } } : {}),
|
|
443
|
+
...(authConfig?.cwd ? { cwd: authConfig.cwd } : {}),
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const transport = new StdioClientTransport(transportOpts);
|
|
447
|
+
|
|
448
|
+
// Buffer stderr lines so we can surface them on connection failure,
|
|
449
|
+
// while still printing them in real time (preserving the SDK's default
|
|
450
|
+
// 'inherit' behavior for interactive use).
|
|
451
|
+
const stderrOutput = [];
|
|
452
|
+
const MAX_STDERR_LINES = 50;
|
|
453
|
+
if (transport.stderr) {
|
|
454
|
+
transport.stderr.on('data', (chunk) => {
|
|
455
|
+
process.stderr.write(chunk);
|
|
456
|
+
const lines = chunk.toString().split('\n');
|
|
457
|
+
for (const line of lines) {
|
|
458
|
+
if (line) {
|
|
459
|
+
stderrOutput.push(line);
|
|
460
|
+
if (stderrOutput.length > MAX_STDERR_LINES) {
|
|
461
|
+
stderrOutput.shift();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
await client.connect(transport);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
// Attach captured stderr so callers can surface it for diagnostics.
|
|
472
|
+
err._stderrOutput = stderrOutput;
|
|
473
|
+
// Clean up the spawned process so it doesn't linger.
|
|
474
|
+
try { await transport.close(); } catch { /* best-effort */ }
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
477
|
+
return { client, transport, stderrOutput };
|
|
203
478
|
}
|
|
204
479
|
}
|
|
205
480
|
|
|
@@ -239,8 +514,21 @@ async function discoverSimulations(client) {
|
|
|
239
514
|
const uri = tool._meta?.ui?.resourceUri ?? tool._meta?.['ui/resourceUri'];
|
|
240
515
|
if (uri) {
|
|
241
516
|
resource = resourceByUri.get(uri);
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
};
|
|
244
532
|
}
|
|
245
533
|
}
|
|
246
534
|
|
|
@@ -405,6 +693,20 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
|
|
|
405
693
|
}
|
|
406
694
|
});
|
|
407
695
|
|
|
696
|
+
// List resources from connected server
|
|
697
|
+
server.middlewares.use('/__sunpeak/list-resources', async (_req, res) => {
|
|
698
|
+
try {
|
|
699
|
+
const client = getClient();
|
|
700
|
+
const result = await client.listResources();
|
|
701
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
702
|
+
res.end(JSON.stringify(result));
|
|
703
|
+
} catch (err) {
|
|
704
|
+
// Server may not support resources — return empty list
|
|
705
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
706
|
+
res.end(JSON.stringify({ resources: [] }));
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
408
710
|
// Call tool on connected server
|
|
409
711
|
server.middlewares.use('/__sunpeak/call-tool', async (req, res) => {
|
|
410
712
|
if (req.method !== 'POST') {
|
|
@@ -911,6 +1213,8 @@ function readRequestBody(req) {
|
|
|
911
1213
|
* @param {Record<string, string>} [opts.resolveAlias] - Vite resolve aliases (e.g., to map sunpeak imports to source)
|
|
912
1214
|
* @param {object[]} [opts.vitePlugins] - Additional Vite plugins (e.g., Tailwind for source CSS)
|
|
913
1215
|
* @param {object} [opts.viteCssConfig] - Vite css config override (e.g., lightningcss customAtRules)
|
|
1216
|
+
* @param {Record<string, string>} [opts.env] - Extra environment variables for stdio server processes
|
|
1217
|
+
* @param {string} [opts.cwd] - Working directory for stdio server processes
|
|
914
1218
|
*/
|
|
915
1219
|
export async function inspectServer(opts) {
|
|
916
1220
|
const {
|
|
@@ -928,6 +1232,8 @@ export async function inspectServer(opts) {
|
|
|
928
1232
|
resolveAlias,
|
|
929
1233
|
vitePlugins: extraVitePlugins = [],
|
|
930
1234
|
viteCssConfig,
|
|
1235
|
+
env: serverEnv,
|
|
1236
|
+
cwd: serverCwd,
|
|
931
1237
|
} = opts;
|
|
932
1238
|
|
|
933
1239
|
// Load favicon from sunpeak package for the inspector UI.
|
|
@@ -948,14 +1254,51 @@ export async function inspectServer(opts) {
|
|
|
948
1254
|
|
|
949
1255
|
// Connect to the MCP server (with retry for local servers that may still be starting)
|
|
950
1256
|
let mcpConnection;
|
|
1257
|
+
let lastStderrOutput = [];
|
|
1258
|
+
// Track the resolved URL (after following redirects like /mcp → /mcp/).
|
|
1259
|
+
let resolvedServerUrl = serverArg;
|
|
951
1260
|
const maxRetries = 5;
|
|
1261
|
+
const connectionOpts = {};
|
|
1262
|
+
if (serverEnv) connectionOpts.env = serverEnv;
|
|
1263
|
+
if (serverCwd) connectionOpts.cwd = serverCwd;
|
|
952
1264
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
953
1265
|
try {
|
|
954
|
-
mcpConnection = await createMcpConnection(
|
|
1266
|
+
mcpConnection = await createMcpConnection(resolvedServerUrl, connectionOpts);
|
|
1267
|
+
if (mcpConnection.serverUrl) resolvedServerUrl = mcpConnection.serverUrl;
|
|
955
1268
|
break;
|
|
956
1269
|
} catch (err) {
|
|
1270
|
+
// Capture stderr from the failed connection attempt for diagnostics.
|
|
1271
|
+
if (err._stderrOutput?.length) {
|
|
1272
|
+
lastStderrOutput = err._stderrOutput;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// If the server requires OAuth, negotiate it and retry once.
|
|
1276
|
+
if (isAuthError(err) && resolvedServerUrl.startsWith('http')) {
|
|
1277
|
+
console.log('Server requires authentication. Negotiating OAuth...');
|
|
1278
|
+
try {
|
|
1279
|
+
const authProvider = await negotiateOAuth(resolvedServerUrl);
|
|
1280
|
+
console.log('OAuth authorized. Reconnecting...');
|
|
1281
|
+
mcpConnection = await createMcpConnection(resolvedServerUrl, {
|
|
1282
|
+
...connectionOpts,
|
|
1283
|
+
type: 'oauth',
|
|
1284
|
+
authProvider,
|
|
1285
|
+
});
|
|
1286
|
+
if (mcpConnection.serverUrl) resolvedServerUrl = mcpConnection.serverUrl;
|
|
1287
|
+
break;
|
|
1288
|
+
} catch (oauthErr) {
|
|
1289
|
+
console.error(`OAuth negotiation failed: ${oauthErr.message}`);
|
|
1290
|
+
process.exit(1);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
957
1294
|
if (attempt === maxRetries) {
|
|
958
1295
|
console.error(`Failed to connect to MCP server: ${err.message}`);
|
|
1296
|
+
if (lastStderrOutput.length) {
|
|
1297
|
+
console.error('\nServer stderr output:');
|
|
1298
|
+
for (const line of lastStderrOutput) {
|
|
1299
|
+
console.error(` ${line}`);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
959
1302
|
process.exit(1);
|
|
960
1303
|
}
|
|
961
1304
|
console.log(`Connection attempt ${attempt}/${maxRetries} failed, retrying...`);
|
|
@@ -1020,7 +1363,7 @@ export async function inspectServer(opts) {
|
|
|
1020
1363
|
</body>
|
|
1021
1364
|
</html>`;
|
|
1022
1365
|
|
|
1023
|
-
const inspectorServerUrl =
|
|
1366
|
+
const inspectorServerUrl = resolvedServerUrl;
|
|
1024
1367
|
|
|
1025
1368
|
// Create the Vite server.
|
|
1026
1369
|
// Use the sunpeak package dir as root to avoid scanning the user's project
|
|
@@ -1128,8 +1471,12 @@ export async function inspectServer(opts) {
|
|
|
1128
1471
|
],
|
|
1129
1472
|
server: {
|
|
1130
1473
|
port,
|
|
1474
|
+
// Listen on all interfaces so both 127.0.0.1 (used by Playwright tests)
|
|
1475
|
+
// and localhost (used by interactive browsing) connect successfully.
|
|
1476
|
+
// Without this, Vite defaults to localhost which may resolve to IPv6-only
|
|
1477
|
+
// (::1) on macOS, causing ECONNREFUSED for IPv4 clients.
|
|
1478
|
+
host: '0.0.0.0',
|
|
1131
1479
|
open: open ?? (!process.env.CI && !process.env.SUNPEAK_LIVE_TEST),
|
|
1132
|
-
allowedHosts: 'all',
|
|
1133
1480
|
},
|
|
1134
1481
|
optimizeDeps: {
|
|
1135
1482
|
// Only pre-bundle React — the virtual entry module imports sunpeak from
|
|
@@ -1195,5 +1542,7 @@ export async function inspect(args) {
|
|
|
1195
1542
|
simulationsDir,
|
|
1196
1543
|
port: opts.port,
|
|
1197
1544
|
name: opts.name,
|
|
1545
|
+
env: opts.env,
|
|
1546
|
+
cwd: opts.cwd,
|
|
1198
1547
|
});
|
|
1199
1548
|
}
|