jevstrudel.git / tools / mcp-e2e / proxy.mjs

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.

9import { Client } from '@modelcontextprotocol/sdk/client/index.js';
10import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
11import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
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}