jevstrudel.git / tools / mcp-e2e / person.mjs
1// The e2e scripts' shared half (README.md beside it): a listener with an
2// account, an MCP client authorized as them, and their signed-in tab.
3import { createServer } from 'node:http';
4import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
6import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
7
8export const SITE = process.env.SITE ?? 'http://localhost:4391';
9export const MCP = new URL('/jev/mcp', SITE);
10const { chromium } = await import(process.env.PLAYWRIGHT_MODULE);
11export const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
12export const log = (...a) => console.log('[e2e]', ...a);
13export const fail = (why) => {
14  console.error('[e2e] FAIL:', why);
15  process.exitCode = 1;
16};
17export const check = (ok, what) => (ok ? log('ok:', what) : fail(what));
18
19class Provider {
20  constructor(name, port) {
21    this.redirect = `http://127.0.0.1:${port}/callback`;
22    this.name = name;
23    this.store = {};
24  }
25  get redirectUrl() {
26    return this.redirect;
27  }
28  get clientMetadata() {
29    return {
30      client_name: this.name,
31      redirect_uris: [this.redirect],
32      grant_types: ['authorization_code', 'refresh_token'],
33      response_types: ['code'],
34      token_endpoint_auth_method: 'none',
35    };
36  }
37  clientInformation() {
38    return this.store.client;
39  }
40  saveClientInformation(c) {
41    this.store.client = c;
42  }
43  tokens() {
44    return this.store.tokens;
45  }
46  saveTokens(t) {
47    this.store.tokens = t;
48  }
49  redirectToAuthorization(url) {
50    this.authUrl = url;
51  }
52  saveCodeVerifier(v) {
53    this.store.verifier = v;
54  }
55  codeVerifier() {
56    return this.store.verifier;
57  }
58}
59
60// One listener: a browser with a passkey, an account made on the authorize
61// page, an MCP client with its token, and the site open signed in as `session`.
62export async function person(name, session, port) {
63  const context = await browser.newContext();
64  const page = await context.newPage();
65  page.on('pageerror', (e) => log(`${name} page error:`, e.message));
66  page.on('websocket', (w) => {
67    if (!w.url().includes('/jev/me/tabs')) return;
68    log(`${name} tab socket open`);
69    w.on('close', () => log(`${name} tab socket closed`));
70    w.on('socketerror', (e) => log(`${name} tab socket error`, e));
71  });
72  const cdp = await context.newCDPSession(page);
73  await cdp.send('WebAuthn.enable');
74  await cdp.send('WebAuthn.addVirtualAuthenticator', {
75    options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },
76  });
77
78  // 1. the MCP client tries, is told to authorize
79  const provider = new Provider(`E2E AI for ${name}`, port);
80  let transport = new StreamableHTTPClientTransport(MCP, { authProvider: provider });
81  let client = new Client({ name: 'e2e', version: '1.0.0' });
82  try {
83    await client.connect(transport);
84    fail('connected without authorizing');
85  } catch (e) {
86    check(e instanceof UnauthorizedError && provider.authUrl, `${name}: the client is sent to authorize (${provider.authUrl?.pathname})`);
87  }
88
89  // 2. the browser: sign up on the authorize page with a passkey, then consent
90  // the client's loopback redirect, as Claude Code listens on one
91  let callback = null;
92  const server = createServer((req, res) => {
93    callback = new URL(req.url, `http://127.0.0.1:${port}`);
94    res.end('done');
95  });
96  await new Promise((r) => server.listen(port, '127.0.0.1', r));
97  await page.goto(provider.authUrl.href);
98  check(await page.getByText('sign in with a passkey').isVisible(), `${name}: the authorize page asks to sign in`);
99  await page.fill('#name', name);
100  await Promise.all([page.waitForEvent('load'), page.click('#create button')]);
101  await page.waitForSelector('button[value=approve]', { timeout: 15000 });
102  const consent = await page.textContent('main');
103  check(consent.includes(`E2E AI for ${name}`) && consent.includes(`Signed in as ${name}`), `${name}: consent names the app and the account`);
104  page.on('console', (m) => log(`${name} console:`, m.text()));
105  page.on('response', (r) => r.url().includes('/jev/oauth/') && log(`${name} response:`, r.status(), r.request().method(), r.headers()['location'] ?? ''));
106  await page.click('button[value=approve]');
107  try {
108    await page.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 15000 });
109  } catch (e) {
110    log('stuck at', page.url(), (await page.textContent('body')).slice(0, 300));
111    throw e;
112  }
113  check(callback?.searchParams.get('code'), `${name}: redirected back with a code`);
114  check(callback?.searchParams.get('iss') === SITE, `${name}: the redirect carries iss`);
115  server.close();
116  await transport.finishAuth(callback.searchParams.get('code'));
117
118  // 3. connected
119  transport = new StreamableHTTPClientTransport(MCP, { authProvider: provider });
120  client = new Client({ name: 'e2e', version: '1.0.0' });
121  await client.connect(transport);
122  log(`${name}: connected; server ${JSON.stringify(client.getServerVersion())}, protocol ${transport.protocolVersion}`);
123
124  // 4. the tab, signed in by the same browser
125  await page.goto(`${SITE}/?session=${session}`);
126  try {
127    // the jev panel's mcp tab says when the AI is connected to this tab
128    await page.click('[data-testid=jev-panel] button:has-text("mcp")', { timeout: 60000 });
129    await page.waitForFunction(
130      (s) => document.querySelector('[data-testid=jev-my-tab]')?.textContent.includes(s),
131      session,
132      { timeout: 60000 },
133    );
134  } catch (e) {
135    const probe = await page.evaluate(async () => {
136      const me = await fetch('/jev/auth/me').then((r) => r.json());
137      const ws = await new Promise((resolve) => {
138        const s = new WebSocket(`ws://${location.host}/jev/me/tabs?session_id=probe1`);
139        s.onopen = () => resolve('open');
140        s.onerror = () => resolve('error');
141        s.onclose = (e) => resolve(`closed ${e.code}`);
142      });
143      return { me, ws, account: document.querySelector('[data-testid=jev-account]')?.textContent };
144    });
145    log('tab probe:', JSON.stringify(probe));
146    throw e;
147  }
148  await page.mouse.click(400, 400);
149  check((await page.textContent('[data-testid=jev-my-tab]')).includes(session), `${name}: the mcp tab shows 🤖 ${session}`);
150  const tool = async (name, args = {}) => {
151    const r = await client.callTool({ name, arguments: args });
152    return { text: r.content?.[0]?.text ?? '', isError: !!r.isError };
153  };
154  const budget = () => page.evaluate(() => fetch('/jev/auth/me').then((r) => r.json()).then((b) => b.budget?.used ?? null));
155  return { page, context, client, tool, budget, provider };
156}