The stdio proxy's prod mode end to end (tools/mcp/, README.md beside this): a real MCP client (the SDK's, over stdio, as Claude Code starts it) runs tools/mcp/strudel-mcp.mjs with its prod endpoint pointed at a local production Worker, switches with use_environment, and headless Chromium with a virtual passkey signs in on the authorize page the proxy logs. Then prod tools through the proxy, a disconnect and the re-sign-in it starts, the switch back to dev, and the grant revoked when the proxy exits. Exits nonzero on any failed check.
13const SITE = process.env.SITE ?? 'http://localhost:4397'; 14const PROXY = new URL('../mcp/strudel-mcp.mjs', import.meta.url).pathname; 15const { chromium } = await import(process.env.PLAYWRIGHT_MODULE); 16const log = (...a) => console.log('[proxy-e2e]', ...a); 17const fail = (why) => { 18 console.error('[proxy-e2e] FAIL:', why); 19 process.exitCode = 1; 20}; 21const check = (ok, what) => (ok ? log('ok:', what) : fail(what)); 22 23const browser = await chromium.launch(); 24const context = await browser.newContext(); 25const page = await context.newPage(); 26const cdp = await context.newCDPSession(page); 27await cdp.send('WebAuthn.enable'); 28await cdp.send('WebAuthn.addVirtualAuthenticator', { 29 options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true }, 30});
the proxy, as .mcp.json starts it, with prod pointed at the stand-in and dev at nothing (so dev's list is the offline one)
34const transport = new StdioClientTransport({ 35 command: 'node', 36 args: [PROXY], 37 env: { 38 PATH: process.env.PATH, 39 JEVSTRUDEL_PROD_MCP_URL: `${SITE}/jev/mcp`, 40 JEVSTRUDEL_MCP_URL: 'http://localhost:4399/mcp', 41 JEVSTRUDEL_MCP_OPEN_BROWSER: '0', 42 }, 43 stderr: 'pipe', 44}); 45const signInUrls = []; 46let urlWaiter = null; 47transport.stderr.on('data', (chunk) => { 48 for (const line of String(chunk).split('\n').filter(Boolean)) { 49 const m = /signing in to prod: (\S+)/.exec(line); 50 if (m) { 51 signInUrls.push(m[1]); 52 urlWaiter?.(m[1]); 53 } else log('proxy stderr:', line); 54 } 55}); 56const nextSignInUrl = () => new Promise((resolve) => (urlWaiter = resolve));
58const client = new Client({ name: 'proxy-e2e', version: '1.0.0' }); 59let changes = 0; 60client.setNotificationHandler(ToolListChangedNotificationSchema, () => void changes++); 61await client.connect(transport); 62const names = async () => (await client.listTools()).tools.map((t) => t.name); 63const tool = async (name, args = {}) => { 64 const r = await client.callTool({ name, arguments: args }); 65 return { text: r.content?.[0]?.text ?? '', isError: !!r.isError }; 66};
the browser's half of a sign-in: make an account (or be signed in), allow
69async function consent(url, { newAccount }) { 70 check(url.startsWith(`${SITE}/jev/oauth/authorize?`) && /code_challenge_method=S256/.test(url) && /state=/.test(url), `the proxy asks to authorize with PKCE and state: ${new URL(url).pathname}`); 71 const redirect = new URL(new URL(url).searchParams.get('redirect_uri')); 72 check(redirect.hostname === '127.0.0.1' && redirect.port && redirect.pathname === '/callback', `its redirect is a loopback listener: ${redirect.href}`); 73 await page.goto(url); 74 if (newAccount) { 75 await page.fill('#name', 'Proxy E2E'); 76 await Promise.all([page.waitForEvent('load'), page.click('#create button')]); 77 } 78 await page.waitForSelector('button[value=approve]', { timeout: 15000 }); 79 check((await page.textContent('main')).includes('Claude Code (jevstrudel dev proxy)'), 'consent names the proxy'); 80 await page.click('button[value=approve]'); 81 await page.waitForURL(`${redirect.origin}/**`, { timeout: 15000 }); 82 check((await page.textContent('body')).includes('Signed in'), "the proxy's callback page says signed in"); 83}
85try { 86 const devList = await names(); 87 check(devList.includes('use_environment') && devList.includes('play_code') && !devList.includes('list_songs'), `dev by default: ${devList.join(', ')}`); 88 check(/Using dev .*prod: not signed in/.test((await tool('use_environment')).text), 'use_environment reports dev');
switch: the call waits while the browser signs in
91 const urlSeen = nextSignInUrl(); 92 const switching = tool('use_environment', { environment: 'prod' }); 93 await consent(await urlSeen, { newAccount: true }); 94 const switched = await switching; 95 check(!switched.isError && /Switched to prod .*signed in/.test(switched.text), `use_environment prod: ${switched.text.split('\n')[0]}`); 96 await new Promise((r) => setTimeout(r, 200)); 97 check(changes === 1, `tools/list_changed sent once (${changes})`); 98 const prodList = await names(); 99 check(prodList.includes('list_songs') && prodList.includes('publish_song') && prodList.includes('use_environment') && !prodList.includes('get_mcp_status'), `prod's tools: ${prodList.join(', ')}`);
101 const status = await tool('get_status'); 102 check(!status.isError && status.text.includes('Proxy E2E'), `get_status through the proxy: ${status.text.split('\n')[0]}`); 103 const mine = await tool('my_content'); 104 check(!mine.isError && JSON.parse(mine.text).revisions !== undefined, `my_content through the proxy: ${mine.text.replace(/\s+/g, ' ').slice(0, 80)}`);
disconnect the app in the account panel: the next call starts a new sign-in
107 await page.goto(`${SITE}/jev/auth/me`); // off the proxy's closed callback page 108 const apps = await page.evaluate(() => fetch('/jev/me/apps').then((r) => r.json())); 109 check(apps.apps.length === 1, `one connected app: ${JSON.stringify(apps.apps.map((a) => a.app))}`); 110 await page.evaluate((id) => fetch(`/jev/me/apps/${id}`, { method: 'DELETE' }), apps.apps[0].id); 111 const again = nextSignInUrl(); 112 const refused = await tool('get_status'); 113 check(refused.isError && /needs a new sign-in/.test(refused.text) && refused.text.includes(`${SITE}/jev/oauth/authorize?`), `after disconnecting, a clear error with the URL: ${refused.text.split('\n')[0]}`); 114 await consent(await again, { newAccount: false }); 115 await new Promise((r) => setTimeout(r, 300)); 116 const back = await tool('get_status'); 117 check(!back.isError && back.text.includes('Proxy E2E'), 'signed in again, prod answers');
back to dev: the tool list changes back
120 const toDev = await tool('use_environment', { environment: 'dev' }); 121 check(/Switched to dev/.test(toDev.text), toDev.text); 122 const devAgain = await names(); 123 check(devAgain.includes('get_mcp_status') && !devAgain.includes('list_songs'), `dev's tools again: ${devAgain.join(', ')}`); 124 const offline = await tool('play_code', { code: 's("bd")' }); 125 check(offline.isError && /buck2 run \/\/:dev/.test(offline.text), 'dev calls go to dev (down here, so it says how to start it)'); 126 check(changes >= 2, `tools/list_changed on the way back (${changes})`); 127 // and to prod again without a sign-in 128 const toProd = await tool('use_environment', { environment: 'prod' }); 129 check(/Switched to prod/.test(toProd.text) && signInUrls.length === 2, 'back to prod on the same sign-in');
the proxy exits with the session: its grant goes with it
132 await client.close(); 133 await new Promise((r) => setTimeout(r, 1500)); 134 await page.goto(`${SITE}/jev/auth/me`); 135 const after = await page.evaluate(() => fetch('/jev/me/apps').then((r) => r.json())); 136 check(after.apps.length === 0, `the grant is revoked when the proxy exits (${after.apps.length} apps left)`); 137} catch (e) { 138 fail(e.stack ?? e); 139} finally { 140 await client.close().catch(() => {}); 141 await browser.close(); 142}