jevstrudel.git / tools / mcp / proxy.test.mjs
1// proxy.mjs: the environment switch and forwarding, against a fake dev
2// Worker, a fake prod and a fake OAuth session. `node --test tools/mcp`.
3import { test } from 'node:test';
4import assert from 'node:assert/strict';
5import { createProxy, LOCAL_TOOL } from './proxy.mjs';
6
7const DEV = 'http://dev.test/mcp';
8const PROD = 'http://prod.test/jev/mcp';
9const devTools = [{ name: 'play_code' }, { name: 'get_mcp_status' }];
10const prodTools = [{ name: 'play_code' }, { name: 'list_songs' }, { name: 'publish_song' }];
11
12function world({ waitMs = 1000 } = {}) {
13  const sent = [];
14  const requests = [];
15  const prod = { valid: new Set(), status: null, headers: {} };
16  const session = {
17    token: undefined,
18    refreshable: false,
19    signIns: 0,
20    finish: null,
21    accessToken() {
22      return this.token;
23    },
24    async refresh() {
25      if (!this.refreshable) {
26        this.token = undefined;
27        return false;
28      }
29      this.token = `t${Math.random()}`;
30      prod.valid.add(this.token);
31      return true;
32    },
33    // a browser sign-in that completes when the test says so
34    async signIn() {
35      if (this.flow) return this.flow;
36      this.signIns++;
37      let resolve, reject;
38      const done = new Promise((a, b) => ((resolve = a), (reject = b)));
39      this.finish = () => {
40        this.token = 'fresh';
41        prod.valid.add('fresh');
42        this.flow = null;
43        resolve();
44      };
45      this.fail = (e) => {
46        this.flow = null;
47        reject(e);
48      };
49      done.catch(() => {});
50      this.flow = { url: 'http://prod.test/jev/oauth/authorize?x=1&y=2', done, opened: 'opened with a fake' };
51      return this.flow;
52    },
53  };
54  const fetchFn = async (url, init) => {
55    const message = JSON.parse(init.body);
56    requests.push({ url, auth: init.headers.Authorization, message });
57    const answer = (result) => Response.json({ jsonrpc: '2.0', id: message.id, result });
58    if (url === DEV) {
59      if (message.method === 'tools/list') return answer({ tools: devTools });
60      return answer({ content: [{ type: 'text', text: `dev ran ${message.params.name}` }] });
61    }
62    const token = init.headers.Authorization?.replace(/^Bearer /, '');
63    if (!prod.valid.has(token)) return new Response('invalid_token', { status: 401 });
64    if (prod.status) return new Response('too many MCP requests; try again in a minute', { status: prod.status, headers: prod.headers });
65    if (message.method === 'initialize') return answer({ instructions: 'Open the site signed in.' });
66    if (message.method === 'tools/list') return answer({ tools: prodTools });
67    return answer({ content: [{ type: 'text', text: `prod ran ${message.params.name}` }] });
68  };
69  const proxy = createProxy({
70    dev: { endpoint: DEV, fallbackTools: () => [{ name: 'offline' }] },
71    prod: { endpoint: PROD, session },
72    send: (m) => sent.push(m),
73    fetchFn,
74    signInWaitMs: waitMs,
75  });
76  let n = 0;
77  const call = async (name, args = {}) => {
78    const r = await proxy.handle({ jsonrpc: '2.0', id: ++n, method: 'tools/call', params: { name, arguments: args } });
79    return { text: r.result?.content?.[0]?.text ?? '', isError: r.result?.isError ?? false, raw: r };
80  };
81  const list = async () => (await proxy.handle({ jsonrpc: '2.0', id: ++n, method: 'tools/list' })).result.tools.map((t) => t.name);
82  const changed = () => sent.filter((m) => m.method === 'notifications/tools/list_changed').length;
83  return { proxy, session, prod, requests, call, list, changed };
84}
85
86test('dev is the default, with the local tool added to its list', async () => {
87  const w = world();
88  assert.equal(w.proxy.environment(), 'dev');
89  assert.deepEqual(await w.list(), ['play_code', 'get_mcp_status', LOCAL_TOOL.name]);
90  const r = await w.call('play_code', { code: 's("bd")' });
91  assert.equal(r.text, 'dev ran play_code');
92  assert.equal(w.requests.at(-1).url, DEV);
93  assert.equal(w.requests.at(-1).auth, undefined);
94  const status = await w.call('use_environment');
95  assert.match(status.text, /Using dev \(http:\/\/dev\.test\/mcp\)\. prod: not signed in/);
96});
97
98test('switching to prod signs in, then switches, notifies, and forwards with the token', async () => {
99  const w = world();
100  const pending = w.call('use_environment', { environment: 'prod' });
101  await new Promise((r) => setTimeout(r, 20));
102  assert.equal(w.proxy.environment(), 'dev', 'no switch before the sign-in completes');
103  w.session.finish();
104  const r = await pending;
105  assert.equal(r.isError, false);
106  assert.match(r.text, /Switched to prod \(http:\/\/prod\.test\/jev\/mcp\), signed in/);
107  assert.match(r.text, /Open the site signed in\./, "prod's instructions come along");
108  assert.equal(w.proxy.environment(), 'prod');
109  assert.equal(w.changed(), 1);
110  assert.deepEqual(await w.list(), ['play_code', 'list_songs', 'publish_song', LOCAL_TOOL.name]);
111  const played = await w.call('list_songs');
112  assert.equal(played.text, 'prod ran list_songs');
113  assert.equal(w.requests.at(-1).url, PROD);
114  assert.equal(w.requests.at(-1).auth, 'Bearer fresh');
115
116  // and back, keeping the sign-in
117  const back = await w.call('use_environment', { environment: 'dev' });
118  assert.match(back.text, /Switched to dev/);
119  assert.equal(w.changed(), 2);
120  assert.equal((await w.call('play_code', { code: 'x' })).text, 'dev ran play_code');
121  const again = await w.call('use_environment', { environment: 'prod' });
122  assert.match(again.text, /Switched to prod/);
123  assert.equal(w.session.signIns, 1, 'no second sign-in');
124  assert.equal(w.changed(), 3);
125});
126
127test('a sign-in not finished in time answers with the URL, and switches when it completes', async () => {
128  const w = world({ waitMs: 30 });
129  const r = await w.call('use_environment', { environment: 'prod' });
130  assert.equal(r.isError, false);
131  assert.match(r.text, /Still waiting/);
132  assert.ok(r.text.includes('http://prod.test/jev/oauth/authorize?x=1&y=2'), 'the whole URL, to open by hand');
133  assert.equal(w.proxy.environment(), 'dev');
134  w.session.finish();
135  await new Promise((resolve) => setTimeout(resolve, 10));
136  assert.equal(w.proxy.environment(), 'prod');
137  assert.equal(w.changed(), 1);
138});
139
140test('a failed sign-in is a tool error and stays in dev', async () => {
141  const w = world();
142  const pending = w.call('use_environment', { environment: 'prod' });
143  await new Promise((r) => setTimeout(r, 10));
144  w.session.fail(new Error('access_denied'));
145  const r = await pending;
146  assert.equal(r.isError, true);
147  assert.match(r.text, /access_denied/);
148  assert.equal(w.proxy.environment(), 'dev');
149});
150
151test('a 401 refreshes silently and retries', async () => {
152  const w = world();
153  w.session.token = 'old';
154  w.prod.valid.add('old');
155  await w.call('use_environment', { environment: 'prod' });
156  w.prod.valid.delete('old'); // expired
157  w.session.refreshable = true;
158  const r = await w.call('list_songs');
159  assert.equal(r.text, 'prod ran list_songs');
160  assert.equal(w.session.signIns, 0);
161});
162
163test('a 401 with no working refresh starts a new sign-in and says where', async () => {
164  const w = world();
165  w.session.token = 'old';
166  w.prod.valid.add('old');
167  await w.call('use_environment', { environment: 'prod' });
168  w.prod.valid.delete('old'); // the app was disconnected
169  const r = await w.call('publish_song', { title: 't' });
170  assert.equal(r.isError, true);
171  assert.match(r.text, /needs a new sign-in/);
172  assert.ok(r.text.includes('http://prod.test/jev/oauth/authorize?x=1&y=2'));
173  assert.equal(w.session.signIns, 1);
174  w.session.finish();
175  await new Promise((resolve) => setTimeout(resolve, 10));
176  assert.equal((await w.call('publish_song', { title: 't' })).text, 'prod ran publish_song');
177});
178
179test('rate limits and other refusals come back as clear tool errors', async () => {
180  const w = world();
181  w.session.token = 'ok';
182  w.prod.valid.add('ok');
183  await w.call('use_environment', { environment: 'prod' });
184  w.prod.status = 429;
185  w.prod.headers = { 'Retry-After': '60' };
186  const r = await w.call('play_code', { code: 'x' });
187  assert.equal(r.isError, true);
188  assert.equal(r.text, 'jevstrudel (prod) is limiting requests: too many MCP requests; try again in a minute; retry in 60 s');
189  w.prod.status = 403;
190  w.prod.headers = { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="publish"' };
191  const s = await w.call('publish_song');
192  assert.equal(s.isError, true);
193  assert.match(s.text, /did not grant the publish scope/);
194});
195
196test('the poll asks dev, never prod', async () => {
197  const w = world();
198  await w.list();
199  await w.proxy.poll();
200  assert.equal(w.changed(), 0);
201  w.session.token = 'ok';
202  w.prod.valid.add('ok');
203  await w.call('use_environment', { environment: 'prod' });
204  const before = w.requests.length;
205  await w.proxy.poll();
206  assert.equal(w.requests.length, before, 'no request to prod from the poll');
207});
208
209test('an unknown environment is refused', async () => {
210  const w = world();
211  const r = await w.call('use_environment', { environment: 'staging' });
212  assert.equal(r.isError, true);
213});