@plosson/agentio 0.7.5 → 0.8.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plosson/agentio",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -173,9 +173,10 @@ describe('handleRequest — /health adversarial paths', () => {
173
173
  expect(res.status).toBe(404);
174
174
  });
175
175
 
176
- test('GET / (root) → 404', async () => {
176
+ test('GET / (root) → 200 setup page (login form)', async () => {
177
177
  const res = await dispatch(req('/'));
178
- expect(res.status).toBe(404);
178
+ expect(res.status).toBe(200);
179
+ expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
179
180
  });
180
181
  });
181
182
 
@@ -1,6 +1,7 @@
1
1
  import { handleMcpRequest } from './mcp-http';
2
2
  import type { OAuthStore } from './oauth-store';
3
3
  import { requireBearer, routeOAuth } from './oauth';
4
+ import { routeSetup } from './setup-page';
4
5
 
5
6
  /**
6
7
  * Context passed to every fetch handler invocation. Built once at boot in
@@ -35,6 +36,11 @@ export async function handleRequest(
35
36
  return jsonResponse({ ok: true });
36
37
  }
37
38
 
39
+ // Root setup page — operator-facing UI (API-key gated) for picking
40
+ // profiles and generating the MCP URL.
41
+ const setupResponse = await routeSetup(req, ctx);
42
+ if (setupResponse) return setupResponse;
43
+
38
44
  // OAuth metadata + endpoints (Phase 3c onward).
39
45
  const oauthResponse = await routeOAuth(req, ctx);
40
46
  if (oauthResponse) return oauthResponse;
@@ -0,0 +1,325 @@
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import { createHmac } from 'crypto';
3
+
4
+ /**
5
+ * Tests for the operator-facing setup page at `/`. Covers:
6
+ * - unauthenticated GET renders the login form
7
+ * - POST credential validation (bad key → 401; good key → 302 + Set-Cookie)
8
+ * - cookie attributes (HttpOnly, SameSite, Path, Max-Age; Secure only on https)
9
+ * - HMAC-cookie authentication: valid cookie → profiles page; stale cookie → login form
10
+ * - only services with ≥1 configured profile appear on the page
11
+ * - embedded JSON payload escapes `<` to avoid `</script>` injection
12
+ *
13
+ * The page calls `listProfiles()` from ../config/config-manager, which reads
14
+ * the real ~/.config/agentio/config.json. Bun caches `os.homedir()` at
15
+ * process start, so HOME overrides don't work here — instead we use
16
+ * `mock.module` to replace `listProfiles` with a controllable stub.
17
+ */
18
+
19
+ const API_KEY = 'srv_test_setup_page_api_key';
20
+ const COOKIE_NAME = 'agentio_setup';
21
+ const COOKIE_MAGIC = 'agentio-setup-v1';
22
+
23
+ function expectedCookieValue(apiKey: string): string {
24
+ return createHmac('sha256', apiKey).update(COOKIE_MAGIC).digest('base64url');
25
+ }
26
+
27
+ interface FakeProfile {
28
+ service: string;
29
+ profiles: { name: string; readOnly?: boolean }[];
30
+ }
31
+
32
+ // Mutable container the mock consults on each call.
33
+ let currentProfiles: FakeProfile[] = [];
34
+
35
+ function setProfiles(fixture: Record<string, string[]>): void {
36
+ currentProfiles = Object.entries(fixture).map(([service, names]) => ({
37
+ service,
38
+ profiles: names.map((n) => ({ name: n })),
39
+ }));
40
+ }
41
+
42
+ type HandleRequest = (typeof import('./http'))['handleRequest'];
43
+ type CreateOAuthStore = (typeof import('./oauth-store'))['createOAuthStore'];
44
+ type ServerContext = import('./http').ServerContext;
45
+
46
+ let handleRequest: HandleRequest;
47
+ let createOAuthStore: CreateOAuthStore;
48
+ let ctx: ServerContext;
49
+
50
+ beforeAll(async () => {
51
+ // Load the real config-manager first so we can preserve every export
52
+ // other modules in the graph depend on (CONFIG_DIR, loadConfig, etc.),
53
+ // then override ONLY listProfiles with a controllable stub. This has
54
+ // to happen BEFORE the module-under-test is imported — once './http'
55
+ // loads, the dependency is bound.
56
+ const realConfig = await import('../config/config-manager');
57
+ mock.module('../config/config-manager', () => ({
58
+ ...realConfig,
59
+ listProfiles: async (service?: string) => {
60
+ if (service) {
61
+ return currentProfiles.filter((s) => s.service === service);
62
+ }
63
+ return currentProfiles;
64
+ },
65
+ }));
66
+
67
+ ({ handleRequest } = await import('./http'));
68
+ ({ createOAuthStore } = await import('./oauth-store'));
69
+ ctx = {
70
+ apiKey: API_KEY,
71
+ oauthStore: createOAuthStore({ save: async () => {} }),
72
+ };
73
+ });
74
+
75
+ beforeEach(() => {
76
+ currentProfiles = [];
77
+ });
78
+
79
+ afterEach(() => {
80
+ currentProfiles = [];
81
+ });
82
+
83
+ function req(
84
+ method: string,
85
+ path: string,
86
+ init: { cookie?: string; xfProto?: string; body?: string; contentType?: string } = {}
87
+ ): Request {
88
+ const headers = new Headers();
89
+ if (init.cookie) headers.set('cookie', init.cookie);
90
+ if (init.xfProto) headers.set('x-forwarded-proto', init.xfProto);
91
+ if (init.contentType) headers.set('content-type', init.contentType);
92
+ return new Request(`http://localhost:9999${path}`, {
93
+ method,
94
+ headers,
95
+ body: init.body,
96
+ });
97
+ }
98
+
99
+ function formBody(fields: Record<string, string>): string {
100
+ return new URLSearchParams(fields).toString();
101
+ }
102
+
103
+ const FORM = 'application/x-www-form-urlencoded';
104
+
105
+ /* ------------------------------------------------------------------ */
106
+ /* unauthenticated GET */
107
+ /* ------------------------------------------------------------------ */
108
+
109
+ describe('GET / — unauthenticated', () => {
110
+ test('renders HTML login form with 200', async () => {
111
+ const res = await handleRequest(req('GET', '/'), ctx);
112
+ expect(res.status).toBe(200);
113
+ expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
114
+ const html = await res.text();
115
+ expect(html).toContain('<title>agentio MCP setup</title>');
116
+ expect(html).toContain('name="api_key"');
117
+ expect(html).toContain('type="password"');
118
+ expect(html).toContain('action="/"');
119
+ });
120
+
121
+ test('does NOT set a cookie on the login form render', async () => {
122
+ const res = await handleRequest(req('GET', '/'), ctx);
123
+ expect(res.headers.get('set-cookie')).toBeNull();
124
+ });
125
+
126
+ test('does NOT leak profile data to an unauthenticated caller', async () => {
127
+ setProfiles({ gmail: ['work-secret', 'personal-secret'] });
128
+ const res = await handleRequest(req('GET', '/'), ctx);
129
+ const html = await res.text();
130
+ expect(html).not.toContain('work-secret');
131
+ expect(html).not.toContain('personal-secret');
132
+ });
133
+ });
134
+
135
+ /* ------------------------------------------------------------------ */
136
+ /* POST login */
137
+ /* ------------------------------------------------------------------ */
138
+
139
+ describe('POST / — login', () => {
140
+ test('missing api_key → 401 with error message, no cookie', async () => {
141
+ const res = await handleRequest(
142
+ req('POST', '/', { contentType: FORM, body: formBody({}) }),
143
+ ctx
144
+ );
145
+ expect(res.status).toBe(401);
146
+ expect(await res.text()).toContain('Invalid API key');
147
+ expect(res.headers.get('set-cookie')).toBeNull();
148
+ });
149
+
150
+ test('wrong api_key → 401 with error message, no cookie', async () => {
151
+ const res = await handleRequest(
152
+ req('POST', '/', {
153
+ contentType: FORM,
154
+ body: formBody({ api_key: 'not-the-right-key' }),
155
+ }),
156
+ ctx
157
+ );
158
+ expect(res.status).toBe(401);
159
+ expect(await res.text()).toContain('Invalid API key');
160
+ expect(res.headers.get('set-cookie')).toBeNull();
161
+ });
162
+
163
+ test('correct api_key → 302 to /, sets cookie with expected attributes', async () => {
164
+ const res = await handleRequest(
165
+ req('POST', '/', {
166
+ contentType: FORM,
167
+ body: formBody({ api_key: API_KEY }),
168
+ }),
169
+ ctx
170
+ );
171
+ expect(res.status).toBe(302);
172
+ expect(res.headers.get('location')).toBe('/');
173
+
174
+ const setCookie = res.headers.get('set-cookie');
175
+ expect(setCookie).not.toBeNull();
176
+ expect(setCookie).toContain(`${COOKIE_NAME}=`);
177
+ expect(setCookie).toContain('HttpOnly');
178
+ expect(setCookie).toContain('SameSite=Strict');
179
+ expect(setCookie).toContain('Path=/');
180
+ expect(setCookie).toMatch(/Max-Age=\d+/);
181
+
182
+ // Cookie value is deterministic HMAC(apiKey, magic).
183
+ expect(setCookie).toContain(expectedCookieValue(API_KEY));
184
+ });
185
+
186
+ test('Secure flag added when x-forwarded-proto=https', async () => {
187
+ const res = await handleRequest(
188
+ req('POST', '/', {
189
+ contentType: FORM,
190
+ xfProto: 'https',
191
+ body: formBody({ api_key: API_KEY }),
192
+ }),
193
+ ctx
194
+ );
195
+ expect(res.headers.get('set-cookie')).toContain('Secure');
196
+ });
197
+
198
+ test('Secure flag NOT added on plain http', async () => {
199
+ const res = await handleRequest(
200
+ req('POST', '/', {
201
+ contentType: FORM,
202
+ body: formBody({ api_key: API_KEY }),
203
+ }),
204
+ ctx
205
+ );
206
+ expect(res.headers.get('set-cookie')).not.toContain('Secure');
207
+ });
208
+ });
209
+
210
+ /* ------------------------------------------------------------------ */
211
+ /* authenticated GET */
212
+ /* ------------------------------------------------------------------ */
213
+
214
+ describe('GET / — authenticated (valid cookie)', () => {
215
+ const validCookie = () => `${COOKIE_NAME}=${expectedCookieValue(API_KEY)}`;
216
+
217
+ test('valid cookie → renders the profiles page', async () => {
218
+ setProfiles({ gmail: ['work'] });
219
+ const res = await handleRequest(
220
+ req('GET', '/', { cookie: validCookie() }),
221
+ ctx
222
+ );
223
+ expect(res.status).toBe(200);
224
+ const html = await res.text();
225
+ expect(html).toContain('MCP URL');
226
+ expect(html).toContain('Claude Code command');
227
+ expect(html).toContain('type="checkbox"');
228
+ });
229
+
230
+ test('only services with ≥1 profile appear (empty services hidden)', async () => {
231
+ setProfiles({
232
+ gmail: ['work', 'personal'],
233
+ slack: ['team'],
234
+ jira: [], // empty — should NOT render a section
235
+ });
236
+ const res = await handleRequest(
237
+ req('GET', '/', { cookie: validCookie() }),
238
+ ctx
239
+ );
240
+ const html = await res.text();
241
+ expect(html).toContain('<h2>gmail</h2>');
242
+ expect(html).toContain('value="gmail:work"');
243
+ expect(html).toContain('value="gmail:personal"');
244
+ expect(html).toContain('<h2>slack</h2>');
245
+ expect(html).toContain('value="slack:team"');
246
+ expect(html).not.toContain('<h2>jira</h2>');
247
+ });
248
+
249
+ test('no configured profiles → empty-state message, no checkboxes', async () => {
250
+ setProfiles({});
251
+ const res = await handleRequest(
252
+ req('GET', '/', { cookie: validCookie() }),
253
+ ctx
254
+ );
255
+ const html = await res.text();
256
+ expect(html).toContain('No profiles configured');
257
+ expect(html).not.toContain('type="checkbox"');
258
+ });
259
+
260
+ test('embedded JSON does not contain `</` that would close the script tag', async () => {
261
+ setProfiles({ gmail: ['work'] });
262
+ const res = await handleRequest(
263
+ req('GET', '/', { cookie: validCookie() }),
264
+ ctx
265
+ );
266
+ const html = await res.text();
267
+ const marker = 'id="page-data"';
268
+ const scriptIdx = html.indexOf(marker);
269
+ expect(scriptIdx).toBeGreaterThan(-1);
270
+ const open = html.indexOf('>', scriptIdx) + 1;
271
+ const close = html.indexOf('</script>', open);
272
+ const dataJson = html.slice(open, close);
273
+ // No `</` inside the JSON — that's what the `\\u003c` escape guarantees.
274
+ expect(dataJson).not.toContain('</');
275
+ // The data block IS the JSON payload with origin set.
276
+ expect(JSON.parse(dataJson)).toEqual({ origin: 'http://localhost:9999' });
277
+ });
278
+
279
+ test('stale cookie (HMAC of a different api key) → renders login form', async () => {
280
+ setProfiles({ gmail: ['work'] });
281
+ const staleCookie = `${COOKIE_NAME}=${expectedCookieValue('some-old-key')}`;
282
+ const res = await handleRequest(
283
+ req('GET', '/', { cookie: staleCookie }),
284
+ ctx
285
+ );
286
+ expect(res.status).toBe(200);
287
+ const html = await res.text();
288
+ expect(html).toContain('name="api_key"');
289
+ expect(html).not.toContain('MCP URL');
290
+ });
291
+
292
+ test('malformed cookie header does not crash', async () => {
293
+ const res = await handleRequest(
294
+ req('GET', '/', { cookie: 'garbage;;===;' }),
295
+ ctx
296
+ );
297
+ expect(res.status).toBe(200);
298
+ expect(await res.text()).toContain('name="api_key"');
299
+ });
300
+
301
+ test('origin reflects x-forwarded-host/proto (behind a proxy)', async () => {
302
+ setProfiles({ gmail: ['work'] });
303
+ const headers = new Headers();
304
+ headers.set('cookie', validCookie());
305
+ headers.set('x-forwarded-proto', 'https');
306
+ headers.set('x-forwarded-host', 'mcp.example.com');
307
+ const res = await handleRequest(
308
+ new Request('http://localhost:9999/', { method: 'GET', headers }),
309
+ ctx
310
+ );
311
+ const html = await res.text();
312
+ expect(html).toContain('{"origin":"https://mcp.example.com"}');
313
+ });
314
+ });
315
+
316
+ /* ------------------------------------------------------------------ */
317
+ /* method dispatch */
318
+ /* ------------------------------------------------------------------ */
319
+
320
+ describe('method dispatch at /', () => {
321
+ test('DELETE / falls through to 404 (not handled by setup page)', async () => {
322
+ const res = await handleRequest(req('DELETE', '/'), ctx);
323
+ expect(res.status).toBe(404);
324
+ });
325
+ });
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Setup page served at `/` — lets an operator (who holds the API key) pick
3
+ * services/profiles and generate the MCP URL + `claude mcp add` snippet.
4
+ *
5
+ * Auth model: a small login form asks for the API key, then we set an
6
+ * HttpOnly cookie whose value is HMAC(apiKey, magic). Every request
7
+ * re-derives that HMAC and compares. If the key rotates, old cookies
8
+ * stop validating — no session store needed.
9
+ */
10
+
11
+ import { createHmac } from 'crypto';
12
+
13
+ import { listProfiles } from '../config/config-manager';
14
+ import type { ServerContext } from './http';
15
+ import { constantTimeEquals, getRequestOrigin } from './oauth';
16
+
17
+ const COOKIE_NAME = 'agentio_setup';
18
+ const COOKIE_MAGIC = 'agentio-setup-v1';
19
+ const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 12; // 12h
20
+
21
+ function escapeHtml(s: string): string {
22
+ return s
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&#39;');
28
+ }
29
+
30
+ function signApiKey(apiKey: string): string {
31
+ return createHmac('sha256', apiKey).update(COOKIE_MAGIC).digest('base64url');
32
+ }
33
+
34
+ function parseCookies(header: string | null): Record<string, string> {
35
+ const out: Record<string, string> = {};
36
+ if (!header) return out;
37
+ for (const part of header.split(';')) {
38
+ const [name, ...rest] = part.trim().split('=');
39
+ if (!name) continue;
40
+ try {
41
+ out[name] = decodeURIComponent(rest.join('='));
42
+ } catch {
43
+ out[name] = rest.join('=');
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function isAuthenticated(req: Request, ctx: ServerContext): boolean {
50
+ const cookies = parseCookies(req.headers.get('cookie'));
51
+ const token = cookies[COOKIE_NAME];
52
+ if (!token) return false;
53
+ return constantTimeEquals(token, signApiKey(ctx.apiKey));
54
+ }
55
+
56
+ function isHttps(req: Request): boolean {
57
+ const forwarded = req.headers.get('forwarded');
58
+ if (forwarded && /proto=https/i.test(forwarded)) return true;
59
+ const xfp = req.headers.get('x-forwarded-proto');
60
+ if (xfp && xfp.toLowerCase() === 'https') return true;
61
+ return new URL(req.url).protocol === 'https:';
62
+ }
63
+
64
+ function buildCookieHeader(value: string, secure: boolean): string {
65
+ const parts = [
66
+ `${COOKIE_NAME}=${encodeURIComponent(value)}`,
67
+ 'HttpOnly',
68
+ 'SameSite=Strict',
69
+ 'Path=/',
70
+ `Max-Age=${COOKIE_MAX_AGE_SECONDS}`,
71
+ ];
72
+ if (secure) parts.push('Secure');
73
+ return parts.join('; ');
74
+ }
75
+
76
+ /* ------------------------------------------------------------------ */
77
+ /* HTML */
78
+ /* ------------------------------------------------------------------ */
79
+
80
+ const BASE_CSS = `
81
+ :root { color-scheme: light dark; }
82
+ body {
83
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
84
+ max-width: 640px;
85
+ margin: 6vh auto;
86
+ padding: 0 24px;
87
+ line-height: 1.5;
88
+ }
89
+ h1 { font-size: 1.4rem; margin-bottom: 0.4rem; }
90
+ h2 {
91
+ font-size: 0.85rem;
92
+ margin: 0 0 0.5rem;
93
+ text-transform: uppercase;
94
+ letter-spacing: 0.05em;
95
+ color: #666;
96
+ }
97
+ .meta { color: #666; font-size: 0.9rem; margin-bottom: 1.5rem; }
98
+ .meta code { font-size: 0.85rem; }
99
+ form { display: flex; flex-direction: column; gap: 0.75rem; }
100
+ label { font-weight: 600; }
101
+ input[type=password], input[type=text], textarea {
102
+ padding: 0.6rem 0.75rem;
103
+ font-size: 0.95rem;
104
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
105
+ border: 1px solid #888;
106
+ border-radius: 6px;
107
+ width: 100%;
108
+ box-sizing: border-box;
109
+ background: transparent;
110
+ color: inherit;
111
+ }
112
+ textarea { resize: vertical; }
113
+ button {
114
+ padding: 0.6rem 1rem;
115
+ font-size: 1rem;
116
+ font-weight: 600;
117
+ background: #2563eb;
118
+ color: white;
119
+ border: none;
120
+ border-radius: 6px;
121
+ cursor: pointer;
122
+ }
123
+ button:hover { background: #1d4ed8; }
124
+ .error {
125
+ background: #fee;
126
+ color: #900;
127
+ padding: 0.6rem 0.75rem;
128
+ border-radius: 6px;
129
+ border: 1px solid #fcc;
130
+ margin: 0;
131
+ }
132
+ `;
133
+
134
+ const PROFILES_CSS = `
135
+ .services { display: flex; flex-direction: column; gap: 0.8rem; }
136
+ section.service {
137
+ border: 1px solid #ddd;
138
+ border-radius: 6px;
139
+ padding: 0.6rem 0.9rem;
140
+ }
141
+ label.profile {
142
+ display: block;
143
+ font-weight: normal;
144
+ padding: 0.15rem 0;
145
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
146
+ font-size: 0.9rem;
147
+ cursor: pointer;
148
+ }
149
+ label.profile input { margin-right: 0.5rem; }
150
+ .output { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 1.4rem; }
151
+ .output label { font-weight: 600; font-size: 0.9rem; }
152
+ .output .row { display: flex; gap: 0.5rem; }
153
+ .output .row input, .output .row textarea { flex: 1; }
154
+ .output button {
155
+ align-self: flex-start;
156
+ font-size: 0.85rem;
157
+ padding: 0.5rem 0.9rem;
158
+ }
159
+ @media (prefers-color-scheme: dark) {
160
+ section.service { border-color: #444; }
161
+ input[type=password], input[type=text], textarea { border-color: #555; }
162
+ }
163
+ `;
164
+
165
+ function loginFormHtml(errorMessage?: string): string {
166
+ const errorBlock = errorMessage
167
+ ? `<p class="error">${escapeHtml(errorMessage)}</p>`
168
+ : '';
169
+ return `<!doctype html>
170
+ <html lang="en">
171
+ <head>
172
+ <meta charset="utf-8">
173
+ <title>agentio MCP setup</title>
174
+ <style>${BASE_CSS}</style>
175
+ </head>
176
+ <body>
177
+ <h1>agentio MCP setup</h1>
178
+ <p class="meta">Enter your agentio API key to continue.</p>
179
+ ${errorBlock}
180
+ <form method="post" action="/">
181
+ <label for="api_key">agentio API key</label>
182
+ <input id="api_key" name="api_key" type="password" autocomplete="off" autofocus required>
183
+ <button type="submit">Continue</button>
184
+ </form>
185
+ </body>
186
+ </html>`;
187
+ }
188
+
189
+ interface ServiceProfileList {
190
+ service: string;
191
+ profiles: string[];
192
+ }
193
+
194
+ function profilesPageHtml(origin: string, configured: ServiceProfileList[]): string {
195
+ const dataJson = JSON.stringify({ origin }).replace(/</g, '\\u003c');
196
+
197
+ const sectionsHtml = configured
198
+ .map((s) => {
199
+ const items = s.profiles
200
+ .map((p) => {
201
+ const value = `${s.service}:${p}`;
202
+ return ` <label class="profile"><input type="checkbox" name="sp" value="${escapeHtml(
203
+ value
204
+ )}"> ${escapeHtml(p)}</label>`;
205
+ })
206
+ .join('\n');
207
+ return ` <section class="service">
208
+ <h2>${escapeHtml(s.service)}</h2>
209
+ ${items}
210
+ </section>`;
211
+ })
212
+ .join('\n');
213
+
214
+ const emptyMessage =
215
+ configured.length === 0
216
+ ? `<p class="meta">No profiles configured yet. Add some with <code>agentio &lt;service&gt; profile add</code> in the CLI that owns this server.</p>`
217
+ : '';
218
+
219
+ const body =
220
+ configured.length === 0
221
+ ? emptyMessage
222
+ : `<div class="services">
223
+ ${sectionsHtml}
224
+ </div>
225
+ <div class="output">
226
+ <label for="url">MCP URL</label>
227
+ <div class="row"><input id="url" type="text" readonly></div>
228
+ <button id="copy-url" type="button">Copy URL</button>
229
+ </div>
230
+ <div class="output">
231
+ <label for="snippet">Claude Code command</label>
232
+ <div class="row"><textarea id="snippet" readonly rows="2"></textarea></div>
233
+ <button id="copy-snippet" type="button">Copy command</button>
234
+ </div>`;
235
+
236
+ const script =
237
+ configured.length === 0
238
+ ? ''
239
+ : `<script id="page-data" type="application/json">${dataJson}</script>
240
+ <script>
241
+ (function() {
242
+ const data = JSON.parse(document.getElementById('page-data').textContent);
243
+ const urlEl = document.getElementById('url');
244
+ const snipEl = document.getElementById('snippet');
245
+ const boxes = document.querySelectorAll('input[type=checkbox][name=sp]');
246
+
247
+ function recompute() {
248
+ const selected = Array.from(boxes).filter(b => b.checked).map(b => b.value);
249
+ const base = data.origin + '/mcp';
250
+ const url = selected.length
251
+ ? base + '?services=' + encodeURIComponent(selected.join(','))
252
+ : base;
253
+ urlEl.value = url;
254
+ snipEl.value = 'claude mcp add --scope local --transport http agentio "' + url + '"';
255
+ }
256
+
257
+ boxes.forEach(b => b.addEventListener('change', recompute));
258
+ recompute();
259
+
260
+ function wireCopy(btnId, targetId) {
261
+ const btn = document.getElementById(btnId);
262
+ btn.addEventListener('click', async () => {
263
+ const target = document.getElementById(targetId);
264
+ try {
265
+ await navigator.clipboard.writeText(target.value);
266
+ } catch {
267
+ target.select();
268
+ document.execCommand && document.execCommand('copy');
269
+ }
270
+ const old = btn.textContent;
271
+ btn.textContent = 'Copied!';
272
+ setTimeout(() => { btn.textContent = old; }, 1200);
273
+ });
274
+ }
275
+ wireCopy('copy-url', 'url');
276
+ wireCopy('copy-snippet', 'snippet');
277
+ })();
278
+ </script>`;
279
+
280
+ return `<!doctype html>
281
+ <html lang="en">
282
+ <head>
283
+ <meta charset="utf-8">
284
+ <title>agentio MCP setup</title>
285
+ <style>${BASE_CSS}${PROFILES_CSS}</style>
286
+ </head>
287
+ <body>
288
+ <h1>agentio MCP setup</h1>
289
+ <p class="meta">Tick the profiles to expose, then copy the MCP URL or the <code>claude mcp add</code> command.</p>
290
+ ${body}
291
+ ${script}
292
+ </body>
293
+ </html>`;
294
+ }
295
+
296
+ /* ------------------------------------------------------------------ */
297
+ /* handlers */
298
+ /* ------------------------------------------------------------------ */
299
+
300
+ async function handleSetupGet(
301
+ req: Request,
302
+ ctx: ServerContext
303
+ ): Promise<Response> {
304
+ if (!isAuthenticated(req, ctx)) {
305
+ return new Response(loginFormHtml(), {
306
+ status: 200,
307
+ headers: { 'content-type': 'text/html; charset=utf-8' },
308
+ });
309
+ }
310
+
311
+ const origin = getRequestOrigin(req);
312
+ const all = await listProfiles();
313
+ const configured: ServiceProfileList[] = all
314
+ .filter((s) => s.profiles.length > 0)
315
+ .map((s) => ({
316
+ service: s.service,
317
+ profiles: s.profiles.map((p) => p.name),
318
+ }));
319
+
320
+ return new Response(profilesPageHtml(origin, configured), {
321
+ status: 200,
322
+ headers: { 'content-type': 'text/html; charset=utf-8' },
323
+ });
324
+ }
325
+
326
+ async function handleSetupPost(
327
+ req: Request,
328
+ ctx: ServerContext
329
+ ): Promise<Response> {
330
+ let form: URLSearchParams;
331
+ try {
332
+ const body = await req.text();
333
+ form = new URLSearchParams(body);
334
+ } catch {
335
+ return new Response(loginFormHtml('Could not read form body.'), {
336
+ status: 400,
337
+ headers: { 'content-type': 'text/html; charset=utf-8' },
338
+ });
339
+ }
340
+
341
+ const apiKey = form.get('api_key') ?? '';
342
+ if (!apiKey || !constantTimeEquals(apiKey, ctx.apiKey)) {
343
+ return new Response(loginFormHtml('Invalid API key.'), {
344
+ status: 401,
345
+ headers: { 'content-type': 'text/html; charset=utf-8' },
346
+ });
347
+ }
348
+
349
+ const cookie = buildCookieHeader(signApiKey(ctx.apiKey), isHttps(req));
350
+ return new Response(null, {
351
+ status: 302,
352
+ headers: { location: '/', 'set-cookie': cookie },
353
+ });
354
+ }
355
+
356
+ export async function routeSetup(
357
+ req: Request,
358
+ ctx: ServerContext
359
+ ): Promise<Response | null> {
360
+ const url = new URL(req.url);
361
+ if (url.pathname !== '/') return null;
362
+ if (req.method === 'GET') return handleSetupGet(req, ctx);
363
+ if (req.method === 'POST') return handleSetupPost(req, ctx);
364
+ return null;
365
+ }