@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 +1 -1
- package/src/server/http.test.ts +3 -2
- package/src/server/http.ts +6 -0
- package/src/server/setup-page.test.ts +325 -0
- package/src/server/setup-page.ts +365 -0
package/package.json
CHANGED
package/src/server/http.test.ts
CHANGED
|
@@ -173,9 +173,10 @@ describe('handleRequest — /health adversarial paths', () => {
|
|
|
173
173
|
expect(res.status).toBe(404);
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
-
test('GET / (root) →
|
|
176
|
+
test('GET / (root) → 200 setup page (login form)', async () => {
|
|
177
177
|
const res = await dispatch(req('/'));
|
|
178
|
-
expect(res.status).toBe(
|
|
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
|
|
package/src/server/http.ts
CHANGED
|
@@ -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, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/'/g, ''');
|
|
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 <service> 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
|
+
}
|