web-comments 0.1.0 → 1.0.1

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.
@@ -0,0 +1,895 @@
1
+ import {
2
+ CommentsError
3
+ } from "../chunks/chunk-RRSB326L.js";
4
+ import "../chunks/chunk-WSFCRVEQ.js";
5
+
6
+ // src/connectors/mcp/pages.ts
7
+ function escapeHtml(value) {
8
+ return value.replace(
9
+ /[&<>"']/g,
10
+ (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]
11
+ );
12
+ }
13
+ var STYLE = `
14
+ :root { color-scheme: light dark; --bg: #f8f8f8; --card: #fff; --ink: #131313; --muted: #505050; --line: #e1e1e1; --accent: #3b63fb; --accent-ink: #fff; --quiet: #e9e9e9; }
15
+ @media (prefers-color-scheme: dark) { :root { --bg: #111; --card: #1d1d1d; --ink: #f2f2f2; --muted: #b0b0b0; --line: #333; --accent: #5d89ff; --quiet: #2c2c2c; } }
16
+ * { box-sizing: border-box; }
17
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px; background: var(--bg); color: var(--ink);
18
+ font: 15px/1.5 adobe-clean, "Source Sans Pro", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
19
+ main { width: 100%; max-width: 400px; background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 28px; }
20
+ .product { margin: 0 0 20px; font-size: 13px; color: var(--muted); }
21
+ h1 { margin: 0 0 8px; font-size: 22px; line-height: 1.25; font-weight: 700; }
22
+ p { margin: 0 0 16px; color: var(--muted); }
23
+ ul { margin: 0 0 20px; padding: 0 0 0 20px; color: var(--muted); }
24
+ li { margin: 4px 0; }
25
+ .who { display: flex; align-items: center; gap: 12px; margin: 0 0 20px; padding: 12px; border-radius: 10px; background: var(--quiet); }
26
+ .who img, .who .initial { width: 32px; height: 32px; border-radius: 50%; flex: none; object-fit: cover; }
27
+ .who .initial { display: grid; place-items: center; background: var(--accent); color: var(--accent-ink); font-weight: 700; }
28
+ .who strong { display: block; color: var(--ink); }
29
+ .who span { font-size: 13px; color: var(--muted); }
30
+ .actions { display: flex; gap: 12px; justify-content: flex-end; }
31
+ button, a.button { font: inherit; font-weight: 700; border-radius: 999px; padding: 7px 18px; border: 2px solid transparent; cursor: pointer; text-decoration: none; }
32
+ button.primary, a.button { background: var(--accent); color: var(--accent-ink); }
33
+ button.secondary { background: transparent; color: var(--ink); border-color: var(--line); }
34
+ button:focus-visible, a.button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
35
+ `;
36
+ function shell(title, product, body) {
37
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title><style>${STYLE}</style></head><body><main><p class="product">${escapeHtml(product)}</p>${body}</main></body></html>`;
38
+ }
39
+ function consentPage(input) {
40
+ const { who } = input;
41
+ const avatar = who.avatarUrl?.startsWith("https://") ? `<img src="${escapeHtml(who.avatarUrl)}" alt="">` : `<div class="initial" aria-hidden="true">${escapeHtml((who.name.trim()[0] ?? "?").toUpperCase())}</div>`;
42
+ return shell(
43
+ `Allow ${input.app}?`,
44
+ input.product,
45
+ `<h1>Allow ${escapeHtml(input.app)} to use comments on ${escapeHtml(input.host)}?</h1>
46
+ <p>It will be able to, as you:</p>
47
+ <ul><li>read comment threads, with their screenshots and the elements they're on</li><li>reply to them, and resolve or reopen them</li></ul>
48
+ <div class="who">${avatar}<div><strong>${escapeHtml(who.name)}</strong>${who.email ? `<span>${escapeHtml(who.email)}</span>` : ""}</div></div>
49
+ <p>You'll go back to ${escapeHtml(input.appHost)}. Only allow apps you trust.</p>
50
+ <form method="post" action="${escapeHtml(input.action)}" class="actions">
51
+ <input type="hidden" name="consent" value="${escapeHtml(input.consentId)}">
52
+ <button type="submit" name="decision" value="deny" class="secondary">Cancel</button>
53
+ <button type="submit" name="decision" value="allow" class="primary">Allow</button>
54
+ </form>`
55
+ );
56
+ }
57
+ function messagePage(product, title, message, link) {
58
+ return shell(
59
+ title,
60
+ product,
61
+ `<h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p>${link ? `<div class="actions"><a class="button" href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></div>` : ""}`
62
+ );
63
+ }
64
+
65
+ // src/connectors/mcp/oauth.ts
66
+ var SCOPE = "comments";
67
+ var CODE_TTL = 5 * 60;
68
+ var CONSENT_TTL = 10 * 60;
69
+ var TOKEN_PREFIX = { access: "wcm_at_", refresh: "wcm_rt_" };
70
+ function endpoints(base) {
71
+ return {
72
+ resource: base,
73
+ issuer: base,
74
+ authorize: `${base}/authorize`,
75
+ token: `${base}/token`,
76
+ register: `${base}/register`,
77
+ revoke: `${base}/revoke`,
78
+ jwks: `${base}/jwks`,
79
+ resourceMetadata: `${base}/.well-known/oauth-protected-resource`
80
+ };
81
+ }
82
+ var CORS = {
83
+ "access-control-allow-origin": "*",
84
+ "access-control-allow-methods": "GET, POST, OPTIONS",
85
+ "access-control-allow-headers": "authorization, content-type, mcp-protocol-version, mcp-session-id, last-event-id",
86
+ "access-control-expose-headers": "www-authenticate, mcp-session-id",
87
+ "access-control-max-age": "86400"
88
+ };
89
+ function json(body, status = 200, headers = {}) {
90
+ return Response.json(body, {
91
+ status,
92
+ headers: { ...CORS, "cache-control": "no-store", ...headers }
93
+ });
94
+ }
95
+ function oauthError(error, description, status = 400) {
96
+ return json({ error, error_description: description }, status);
97
+ }
98
+ function protectedResourceMetadata(base) {
99
+ const at = endpoints(base);
100
+ return json({
101
+ resource: at.resource,
102
+ authorization_servers: [at.issuer],
103
+ scopes_supported: [SCOPE],
104
+ bearer_methods_supported: ["header"],
105
+ resource_name: "web-comments"
106
+ });
107
+ }
108
+ function authorizationServerMetadata(base, oidc) {
109
+ const at = endpoints(base);
110
+ return json({
111
+ issuer: at.issuer,
112
+ authorization_endpoint: at.authorize,
113
+ token_endpoint: at.token,
114
+ registration_endpoint: at.register,
115
+ revocation_endpoint: at.revoke,
116
+ scopes_supported: [SCOPE],
117
+ response_types_supported: ["code"],
118
+ response_modes_supported: ["query"],
119
+ grant_types_supported: ["authorization_code", "refresh_token"],
120
+ token_endpoint_auth_methods_supported: ["none"],
121
+ revocation_endpoint_auth_methods_supported: ["none"],
122
+ code_challenge_methods_supported: ["S256"],
123
+ authorization_response_iss_parameter_supported: true,
124
+ client_id_metadata_document_supported: true,
125
+ ...oidc ? {
126
+ jwks_uri: at.jwks,
127
+ subject_types_supported: ["public"],
128
+ id_token_signing_alg_values_supported: ["none"]
129
+ } : {}
130
+ });
131
+ }
132
+ function jwks() {
133
+ return json({ keys: [] });
134
+ }
135
+ function randomToken(bytes = 32) {
136
+ return base64url(crypto.getRandomValues(new Uint8Array(bytes)));
137
+ }
138
+ function base64url(bytes) {
139
+ let binary = "";
140
+ for (const byte of bytes) binary += String.fromCharCode(byte);
141
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
142
+ }
143
+ async function sha256(text2) {
144
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text2));
145
+ return base64url(new Uint8Array(hash));
146
+ }
147
+ async function readJson(storage, key) {
148
+ const value = await storage.get(key);
149
+ return value === null ? null : JSON.parse(value);
150
+ }
151
+ async function grantOf(storage, token2) {
152
+ if (!token2.startsWith(TOKEN_PREFIX.access)) return null;
153
+ const record = await readJson(storage, `token:${await sha256(token2)}`);
154
+ if (record?.kind !== "access") return null;
155
+ const { kind: _, ...grant } = record;
156
+ return grant;
157
+ }
158
+ function looksLikeOurToken(token2) {
159
+ return token2.startsWith(TOKEN_PREFIX.access) || token2.startsWith(TOKEN_PREFIX.refresh);
160
+ }
161
+ async function issueTokens(storage, grant, options) {
162
+ const access = TOKEN_PREFIX.access + randomToken();
163
+ const refresh = TOKEN_PREFIX.refresh + randomToken();
164
+ await storage.set(
165
+ `token:${await sha256(access)}`,
166
+ JSON.stringify({ kind: "access", ...grant }),
167
+ { ttlSeconds: options.accessTokenTtl }
168
+ );
169
+ await storage.set(
170
+ `token:${await sha256(refresh)}`,
171
+ JSON.stringify({ kind: "refresh", ...grant }),
172
+ { ttlSeconds: options.refreshTokenTtl }
173
+ );
174
+ return json({
175
+ access_token: access,
176
+ token_type: "Bearer",
177
+ expires_in: options.accessTokenTtl,
178
+ refresh_token: refresh,
179
+ scope: grant.scope
180
+ });
181
+ }
182
+ function isLoopback(url) {
183
+ return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
184
+ }
185
+ function allowedRedirect(uri) {
186
+ if (typeof uri !== "string" || uri.length > 2048) return false;
187
+ let url;
188
+ try {
189
+ url = new URL(uri);
190
+ } catch {
191
+ return false;
192
+ }
193
+ if (url.hash) return false;
194
+ if (url.protocol === "https:") return true;
195
+ if (url.protocol === "http:") return isLoopback(url);
196
+ return !["javascript:", "data:", "file:", "blob:", "about:", "vbscript:"].includes(url.protocol);
197
+ }
198
+ function registered(client, uri) {
199
+ if (client.redirect_uris.includes(uri)) return true;
200
+ let asked;
201
+ try {
202
+ asked = new URL(uri);
203
+ } catch {
204
+ return false;
205
+ }
206
+ if (asked.protocol !== "http:" || !isLoopback(asked)) return false;
207
+ return client.redirect_uris.some((known) => {
208
+ const url = new URL(known);
209
+ if (url.protocol !== "http:" || !isLoopback(url)) return false;
210
+ url.port = asked.port;
211
+ return url.href === asked.href;
212
+ });
213
+ }
214
+ function text(value, max) {
215
+ return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : void 0;
216
+ }
217
+ async function register(request, storage) {
218
+ let body;
219
+ try {
220
+ body = await request.json();
221
+ } catch {
222
+ return oauthError("invalid_client_metadata", "send the client's metadata as JSON");
223
+ }
224
+ const uris = body.redirect_uris;
225
+ if (!Array.isArray(uris) || uris.length === 0 || uris.length > 10 || !uris.every(allowedRedirect)) {
226
+ return oauthError(
227
+ "invalid_redirect_uri",
228
+ "redirect_uris must be HTTPS, loopback HTTP or an app's own scheme"
229
+ );
230
+ }
231
+ const client = {
232
+ client_id: `wcm_client_${randomToken(16)}`,
233
+ redirect_uris: uris,
234
+ ...text(body.client_name, 100) ? { client_name: text(body.client_name, 100) } : {},
235
+ ...text(body.client_uri, 500) ? { client_uri: text(body.client_uri, 500) } : {}
236
+ };
237
+ await storage.set(`client:${client.client_id}`, JSON.stringify(client));
238
+ return json(
239
+ {
240
+ ...client,
241
+ client_id_issued_at: Math.floor(Date.now() / 1e3),
242
+ token_endpoint_auth_method: "none",
243
+ grant_types: ["authorization_code", "refresh_token"],
244
+ response_types: ["code"],
245
+ scope: SCOPE
246
+ },
247
+ 201
248
+ );
249
+ }
250
+ async function clientOf(storage, clientId) {
251
+ if (!clientId.startsWith("https://")) {
252
+ return readJson(storage, `client:${clientId}`);
253
+ }
254
+ const cached = await readJson(storage, `cimd:${clientId}`);
255
+ if (cached) return cached;
256
+ let document;
257
+ try {
258
+ const response = await fetch(clientId, {
259
+ headers: { accept: "application/json" },
260
+ redirect: "error",
261
+ signal: AbortSignal.timeout(5e3)
262
+ });
263
+ if (!response.ok) return null;
264
+ const body = await response.text();
265
+ if (body.length > 1e4) return null;
266
+ document = JSON.parse(body);
267
+ } catch {
268
+ return null;
269
+ }
270
+ const uris = document.redirect_uris;
271
+ if (document.client_id !== clientId) return null;
272
+ if (!Array.isArray(uris) || uris.length === 0 || !uris.every(allowedRedirect)) return null;
273
+ const client = {
274
+ client_id: clientId,
275
+ redirect_uris: uris,
276
+ ...text(document.client_name, 100) ? { client_name: text(document.client_name, 100) } : {},
277
+ ...text(document.client_uri, 500) ? { client_uri: text(document.client_uri, 500) } : {}
278
+ };
279
+ await storage.set(`cimd:${clientId}`, JSON.stringify(client), { ttlSeconds: 24 * 60 * 60 });
280
+ return client;
281
+ }
282
+ function clientName(client) {
283
+ if (client.client_name) return client.client_name;
284
+ try {
285
+ return new URL(client.client_uri ?? client.client_id).hostname;
286
+ } catch {
287
+ return "An app";
288
+ }
289
+ }
290
+ function redirectWith(uri, issuer, params) {
291
+ const url = new URL(uri);
292
+ for (const [key, value] of Object.entries(params))
293
+ if (value !== null) url.searchParams.set(key, value);
294
+ url.searchParams.set("iss", issuer);
295
+ return new Response(null, {
296
+ status: 302,
297
+ headers: { location: url.href, "cache-control": "no-store" }
298
+ });
299
+ }
300
+ var PAGE_HEADERS = {
301
+ "content-type": "text/html; charset=utf-8",
302
+ "cache-control": "no-store",
303
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; frame-ancestors 'none'; base-uri 'none'",
304
+ "x-frame-options": "DENY",
305
+ // Not no-referrer: under it, browsers send the consent form's Origin as "null".
306
+ "referrer-policy": "same-origin",
307
+ "x-content-type-options": "nosniff"
308
+ };
309
+ function fromThisOrigin(request) {
310
+ const site = request.headers.get("sec-fetch-site");
311
+ if (site) return site === "same-origin";
312
+ const origin = request.headers.get("origin");
313
+ return origin === null || origin === new URL(request.url).origin;
314
+ }
315
+ function page(html, status = 200) {
316
+ return new Response(html, { status, headers: PAGE_HEADERS });
317
+ }
318
+ async function authorize(request, context, options) {
319
+ const at = endpoints(context.url);
320
+ const storage = context.storage;
321
+ const url = new URL(request.url);
322
+ if (request.method === "POST") return decide(request, context, options);
323
+ const query = url.searchParams;
324
+ const clientId = query.get("client_id") ?? "";
325
+ const redirectUri = query.get("redirect_uri") ?? "";
326
+ const client = clientId ? await clientOf(storage, clientId) : null;
327
+ if (!client) {
328
+ return page(
329
+ messagePage(
330
+ options.name,
331
+ "Unknown app",
332
+ "This app isn't registered here. Try connecting it again."
333
+ ),
334
+ 400
335
+ );
336
+ }
337
+ if (!registered(client, redirectUri)) {
338
+ return page(
339
+ messagePage(
340
+ options.name,
341
+ "Unknown address",
342
+ "This app asked to come back to an address it didn't register."
343
+ ),
344
+ 400
345
+ );
346
+ }
347
+ const state = query.get("state");
348
+ const fail = (error, description) => redirectWith(redirectUri, at.issuer, { error, error_description: description, state });
349
+ if (query.get("response_type") !== "code") {
350
+ return fail("unsupported_response_type", "only the authorization code flow is supported");
351
+ }
352
+ const codeChallenge = query.get("code_challenge") ?? "";
353
+ if (query.get("code_challenge_method") !== "S256" || !/^[A-Za-z0-9_-]{43,128}$/.test(codeChallenge)) {
354
+ return fail("invalid_request", "PKCE with S256 is required");
355
+ }
356
+ const resource = query.get("resource") ?? at.resource;
357
+ if (resource !== at.resource) return fail("invalid_target", `the resource is ${at.resource}`);
358
+ const who = await context.identify(request);
359
+ if (!who) {
360
+ const login = context.loginUrl(request);
361
+ if (login) return Response.redirect(new URL(login, request.url).href, 302);
362
+ return page(
363
+ messagePage(
364
+ options.name,
365
+ "Sign in first",
366
+ `Sign in to ${url.host} in this browser, then come back to this page.`,
367
+ { label: "Try again", href: url.href }
368
+ ),
369
+ 401
370
+ );
371
+ }
372
+ const consentId = randomToken(18);
373
+ const consent = {
374
+ clientId,
375
+ redirectUri,
376
+ state,
377
+ codeChallenge,
378
+ resource,
379
+ scope: SCOPE,
380
+ identityId: who.id
381
+ };
382
+ await storage.set(`consent:${consentId}`, JSON.stringify(consent), { ttlSeconds: CONSENT_TTL });
383
+ return page(
384
+ consentPage({
385
+ product: options.name,
386
+ host: url.host,
387
+ app: clientName(client),
388
+ appHost: new URL(redirectUri).host || new URL(redirectUri).protocol,
389
+ who,
390
+ action: at.authorize,
391
+ consentId
392
+ })
393
+ );
394
+ }
395
+ async function decide(request, context, options) {
396
+ const at = endpoints(context.url);
397
+ if (!fromThisOrigin(request)) {
398
+ return page(
399
+ messagePage(options.name, "Not allowed", "This approval didn't come from this site."),
400
+ 403
401
+ );
402
+ }
403
+ const form = await request.formData();
404
+ const consentId = String(form.get("consent") ?? "");
405
+ const consent = consentId ? await readJson(context.storage, `consent:${consentId}`) : null;
406
+ if (!consent) {
407
+ return page(
408
+ messagePage(options.name, "This request expired", "Go back to the app and connect again."),
409
+ 400
410
+ );
411
+ }
412
+ await context.storage.delete(`consent:${consentId}`);
413
+ const who = await context.identify(request);
414
+ if (!who || who.id !== consent.identityId) {
415
+ return page(
416
+ messagePage(
417
+ options.name,
418
+ "Signed in as someone else",
419
+ "Go back to the app and connect again."
420
+ ),
421
+ 403
422
+ );
423
+ }
424
+ if (form.get("decision") !== "allow") {
425
+ return redirectWith(consent.redirectUri, at.issuer, {
426
+ error: "access_denied",
427
+ error_description: "the request was declined",
428
+ state: consent.state
429
+ });
430
+ }
431
+ const client = await clientOf(context.storage, consent.clientId);
432
+ if (!client) {
433
+ return page(
434
+ messagePage(options.name, "Unknown app", "This app isn't registered here anymore."),
435
+ 400
436
+ );
437
+ }
438
+ const code = randomToken();
439
+ const grant = {
440
+ identity: who,
441
+ clientId: consent.clientId,
442
+ clientName: clientName(client),
443
+ site: context.site,
444
+ resource: consent.resource,
445
+ scope: consent.scope
446
+ };
447
+ await context.storage.set(
448
+ `code:${await sha256(code)}`,
449
+ JSON.stringify({
450
+ ...grant,
451
+ redirectUri: consent.redirectUri,
452
+ codeChallenge: consent.codeChallenge
453
+ }),
454
+ { ttlSeconds: CODE_TTL }
455
+ );
456
+ return redirectWith(consent.redirectUri, at.issuer, { code, state: consent.state });
457
+ }
458
+ async function token(request, context, options) {
459
+ const at = endpoints(context.url);
460
+ const storage = context.storage;
461
+ let params;
462
+ const type = request.headers.get("content-type") ?? "";
463
+ if (type.includes("application/x-www-form-urlencoded")) {
464
+ params = new URLSearchParams(await request.text());
465
+ } else if (type.includes("application/json")) {
466
+ params = new URLSearchParams(
467
+ await request.json().catch(() => ({}))
468
+ );
469
+ } else {
470
+ return oauthError("invalid_request", "send the request as application/x-www-form-urlencoded");
471
+ }
472
+ const clientId = params.get("client_id") ?? "";
473
+ const resource = params.get("resource");
474
+ if (resource !== null && resource !== at.resource) {
475
+ return oauthError("invalid_target", `the resource is ${at.resource}`);
476
+ }
477
+ if (params.get("grant_type") === "authorization_code") {
478
+ const code = params.get("code") ?? "";
479
+ const key = `code:${await sha256(code)}`;
480
+ const record = code ? await readJson(storage, key) : null;
481
+ if (record) await storage.delete(key);
482
+ if (!record || record.clientId !== clientId || record.site !== context.site) {
483
+ return oauthError("invalid_grant", "the code is unknown, used or expired");
484
+ }
485
+ if (params.get("redirect_uri") !== record.redirectUri) {
486
+ return oauthError("invalid_grant", "redirect_uri doesn't match the authorization request");
487
+ }
488
+ const verifier = params.get("code_verifier") ?? "";
489
+ if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier) || await sha256(verifier) !== record.codeChallenge) {
490
+ return oauthError("invalid_grant", "code_verifier doesn't match the code challenge");
491
+ }
492
+ const { redirectUri: _, codeChallenge: __, ...grant } = record;
493
+ return issueTokens(storage, grant, options);
494
+ }
495
+ if (params.get("grant_type") === "refresh_token") {
496
+ const refresh = params.get("refresh_token") ?? "";
497
+ const key = `token:${await sha256(refresh)}`;
498
+ const record = refresh.startsWith(TOKEN_PREFIX.refresh) ? await readJson(storage, key) : null;
499
+ if (record?.kind !== "refresh" || record.clientId !== clientId || record.site !== context.site) {
500
+ return oauthError("invalid_grant", "the refresh token is unknown, used or expired");
501
+ }
502
+ await storage.delete(key);
503
+ const { kind: _, ...grant } = record;
504
+ return issueTokens(storage, grant, options);
505
+ }
506
+ return oauthError("unsupported_grant_type", "use authorization_code or refresh_token");
507
+ }
508
+ async function revoke(request, storage) {
509
+ const params = new URLSearchParams(await request.text());
510
+ const value = params.get("token") ?? "";
511
+ if (looksLikeOurToken(value)) await storage.delete(`token:${await sha256(value)}`);
512
+ return json({});
513
+ }
514
+ async function handleOAuth(request, path, context, options) {
515
+ if (request.method === "OPTIONS" && path !== "/authorize") {
516
+ return new Response(null, { status: 204, headers: CORS });
517
+ }
518
+ switch (path) {
519
+ case "/.well-known/oauth-protected-resource":
520
+ return protectedResourceMetadata(context.url);
521
+ case "/.well-known/oauth-authorization-server":
522
+ return authorizationServerMetadata(context.url, false);
523
+ case "/.well-known/openid-configuration":
524
+ return authorizationServerMetadata(context.url, true);
525
+ case "/jwks":
526
+ return jwks();
527
+ case "/authorize":
528
+ if (request.method !== "GET" && request.method !== "POST") break;
529
+ return authorize(request, context, options);
530
+ case "/register":
531
+ if (request.method !== "POST") break;
532
+ return register(request, context.storage);
533
+ case "/token":
534
+ if (request.method !== "POST") break;
535
+ return token(request, context, options);
536
+ case "/revoke":
537
+ if (request.method !== "POST") break;
538
+ return revoke(request, context.storage);
539
+ default:
540
+ return null;
541
+ }
542
+ return json({ error: "invalid_request", error_description: "method not allowed" }, 405, {
543
+ allow: path === "/authorize" ? "GET, POST" : "POST"
544
+ });
545
+ }
546
+
547
+ // src/connectors/mcp/index.ts
548
+ var VERSION = "1.0.1";
549
+ var PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
550
+ function mcp(options = {}) {
551
+ const oauth = {
552
+ name: options.name ?? "web-comments",
553
+ accessTokenTtl: options.accessTokenTtl ?? 60 * 60,
554
+ refreshTokenTtl: options.refreshTokenTtl ?? 30 * 24 * 60 * 60
555
+ };
556
+ return {
557
+ name: "mcp",
558
+ rootPaths: (basePath) => [
559
+ `/.well-known/oauth-protected-resource${basePath}/mcp`,
560
+ `/.well-known/oauth-authorization-server${basePath}/mcp`,
561
+ `/.well-known/openid-configuration${basePath}/mcp`
562
+ ],
563
+ async fetch(request, context) {
564
+ const { pathname } = new URL(request.url);
565
+ const ownPath = new URL(context.url).pathname;
566
+ let path;
567
+ if (pathname === ownPath || pathname.startsWith(`${ownPath}/`)) {
568
+ path = pathname.slice(ownPath.length) || "/";
569
+ } else {
570
+ path = pathname.slice(0, pathname.length - ownPath.length);
571
+ }
572
+ if (path === "/") return serve(request, context, oauth);
573
+ return await handleOAuth(request, path, context, oauth) ?? Response.json({ error: "not_found" }, { status: 404, headers: CORS });
574
+ }
575
+ };
576
+ }
577
+ var rpcError = (id, code, message) => ({
578
+ jsonrpc: "2.0",
579
+ id: id ?? null,
580
+ error: { code, message }
581
+ });
582
+ async function serve(request, context, options) {
583
+ if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
584
+ if (request.method !== "POST") {
585
+ return new Response(null, { status: 405, headers: { ...CORS, allow: "POST, OPTIONS" } });
586
+ }
587
+ const actor = await authenticate(request, context);
588
+ if (!actor) {
589
+ const given = request.headers.get("authorization");
590
+ const at = endpoints(context.url);
591
+ return Response.json(
592
+ { error: "invalid_token", error_description: "sign in with OAuth to use comments" },
593
+ {
594
+ status: 401,
595
+ headers: {
596
+ ...CORS,
597
+ "www-authenticate": `Bearer resource_metadata="${at.resourceMetadata}", scope="comments"${given ? ', error="invalid_token"' : ""}`
598
+ }
599
+ }
600
+ );
601
+ }
602
+ let body;
603
+ try {
604
+ body = await request.json();
605
+ } catch {
606
+ return Response.json(rpcError(null, -32700, "Parse error"), { status: 400, headers: CORS });
607
+ }
608
+ const batch = Array.isArray(body);
609
+ const messages = batch ? body : [body];
610
+ const actions = context.as(actor);
611
+ const replies = [];
612
+ for (const message of messages) {
613
+ const reply = await answer(message, actions, context, options);
614
+ if (reply) replies.push(reply);
615
+ }
616
+ if (replies.length === 0) return new Response(null, { status: 202, headers: CORS });
617
+ return Response.json(batch ? replies : replies[0], { headers: CORS });
618
+ }
619
+ async function authenticate(request, context) {
620
+ const header = request.headers.get("authorization") ?? "";
621
+ const token2 = /^Bearer\s+(.+)$/i.exec(header)?.[1]?.trim();
622
+ if (token2 && looksLikeOurToken(token2)) {
623
+ const grant = await grantOf(context.storage, token2);
624
+ if (!grant || grant.site !== context.site || grant.resource !== endpoints(context.url).resource) {
625
+ return null;
626
+ }
627
+ return {
628
+ ...grant.identity,
629
+ name: `${grant.identity.name} (${grant.clientName})`,
630
+ kind: "agent"
631
+ };
632
+ }
633
+ return context.identify(request);
634
+ }
635
+ async function answer(message, actions, context, options) {
636
+ if (message?.jsonrpc !== "2.0" || typeof message.method !== "string") {
637
+ return message && typeof message === "object" && "id" in message && !("result" in message || "error" in message) ? rpcError(message.id, -32600, "Invalid Request") : null;
638
+ }
639
+ const { id, method, params = {} } = message;
640
+ const isNotification = id === void 0;
641
+ const ok = (result) => isNotification ? null : { jsonrpc: "2.0", id, result };
642
+ switch (method) {
643
+ case "initialize": {
644
+ const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
645
+ return ok({
646
+ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
647
+ capabilities: { tools: { listChanged: false } },
648
+ serverInfo: { name: options.name, title: "Comments", version: VERSION },
649
+ instructions: "Comments people left on this site's pages, each pinned to an element. List the open ones, read one (with its screenshots, the element's selector and HTML), make the change it asks for, then reply saying what you did and resolve it."
650
+ });
651
+ }
652
+ case "ping":
653
+ return ok({});
654
+ case "tools/list":
655
+ return ok({ tools: TOOLS.map(({ run: _, ...tool }) => tool) });
656
+ case "tools/call": {
657
+ const tool = TOOLS.find((t) => t.name === params.name);
658
+ if (!tool)
659
+ return isNotification ? null : rpcError(id, -32602, `Unknown tool: ${String(params.name)}`);
660
+ const args = params.arguments ?? {};
661
+ try {
662
+ return ok(await tool.run(args, actions, context));
663
+ } catch (error) {
664
+ if (error instanceof CommentsError || error instanceof ToolInputError || error instanceof Error) {
665
+ return ok({ isError: true, content: [{ type: "text", text: error.message }] });
666
+ }
667
+ throw error;
668
+ }
669
+ }
670
+ default:
671
+ if (method.startsWith("notifications/")) return null;
672
+ return isNotification ? null : rpcError(id, -32601, `Method not found: ${method}`);
673
+ }
674
+ }
675
+ var ToolInputError = class extends Error {
676
+ };
677
+ function stringArg(args, name, required = false) {
678
+ const value = args[name];
679
+ if (value === void 0 || value === null || value === "") {
680
+ if (required) throw new ToolInputError(`${name} is required`);
681
+ return void 0;
682
+ }
683
+ if (typeof value !== "string") throw new ToolInputError(`${name} must be a string`);
684
+ return value;
685
+ }
686
+ function summary(thread) {
687
+ const [first, ...replies] = thread.comments;
688
+ return {
689
+ id: thread.id,
690
+ status: thread.status,
691
+ url: thread.url,
692
+ page: thread.page,
693
+ element: {
694
+ tag: thread.anchor.tag,
695
+ selector: thread.anchor.selector,
696
+ ...thread.anchor.text ? { text: thread.anchor.text } : {}
697
+ },
698
+ author: thread.author.name,
699
+ comment: first?.body ?? "",
700
+ replies: replies.length,
701
+ createdAt: thread.createdAt,
702
+ updatedAt: thread.updatedAt,
703
+ links: thread.links
704
+ };
705
+ }
706
+ function describe(thread) {
707
+ const lines = [
708
+ `Thread ${thread.id} (${thread.status})`,
709
+ `Page: ${thread.url}`,
710
+ `Element: <${thread.anchor.tag}> ${thread.anchor.selector}${thread.anchor.text ? ` "${thread.anchor.text}"` : ""}`
711
+ ];
712
+ if (thread.context) {
713
+ const { element, page: page2 } = thread.context;
714
+ lines.push(
715
+ `Page title: ${page2.title}`,
716
+ `Viewport: ${page2.viewport.width}x${page2.viewport.height}, scrolled to ${page2.scroll.x},${page2.scroll.y}`,
717
+ `Element box: ${Math.round(element.rect.width)}x${Math.round(element.rect.height)} at ${Math.round(element.rect.x)},${Math.round(element.rect.y)}`,
718
+ `Element HTML:
719
+ ${element.html}`
720
+ );
721
+ }
722
+ for (const link of thread.links)
723
+ lines.push(`${link.connector}: ${link.title ?? link.url} ${link.url}`);
724
+ lines.push("", "Comments:");
725
+ for (const comment of thread.comments) {
726
+ lines.push(`- ${comment.author.name} (${comment.createdAt}, ${comment.id}): ${comment.body}`);
727
+ }
728
+ return lines.join("\n");
729
+ }
730
+ function base64(bytes) {
731
+ const view = new Uint8Array(bytes);
732
+ let binary = "";
733
+ for (let i = 0; i < view.length; i += 32768) {
734
+ binary += String.fromCharCode(...view.subarray(i, i + 32768));
735
+ }
736
+ return btoa(binary);
737
+ }
738
+ var THREAD_ID = { type: "string", description: "The thread's id, from list_comment_threads." };
739
+ var TOOLS = [
740
+ {
741
+ name: "list_comment_threads",
742
+ title: "List comment threads",
743
+ description: "Comment threads people left on this site's pages: what was said, on which page and element, by whom. Open ones by default.",
744
+ inputSchema: {
745
+ type: "object",
746
+ properties: {
747
+ status: { type: "string", enum: ["open", "resolved", "all"], default: "open" },
748
+ page: {
749
+ type: "string",
750
+ description: "Only this page's threads: its page key, which by default is the path, e.g. /settings."
751
+ },
752
+ limit: { type: "integer", minimum: 1, maximum: 100, default: 50 }
753
+ },
754
+ additionalProperties: false
755
+ },
756
+ annotations: { readOnlyHint: true, openWorldHint: false },
757
+ async run(args, actions) {
758
+ const status = stringArg(args, "status") ?? "open";
759
+ if (!["open", "resolved", "all"].includes(status)) {
760
+ throw new ToolInputError('status must be "open", "resolved" or "all"');
761
+ }
762
+ const limit = args.limit === void 0 ? 50 : Number(args.limit);
763
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
764
+ throw new ToolInputError("limit must be a whole number from 1 to 100");
765
+ }
766
+ const page2 = stringArg(args, "page");
767
+ const threads = await actions.listThreads({
768
+ ...page2 ? { page: page2 } : {},
769
+ ...status === "all" ? {} : { status },
770
+ limit
771
+ });
772
+ const list = threads.map(summary);
773
+ const text2 = list.length === 0 ? `No ${status === "all" ? "" : `${status} `}comment threads.` : list.map(
774
+ (t) => `${t.id} [${t.status}] ${t.url}
775
+ on <${t.element.tag}> ${t.element.selector}${t.element.text ? ` "${t.element.text}"` : ""}
776
+ ${t.author}: ${t.comment}${t.replies ? `
777
+ (${t.replies} ${t.replies === 1 ? "reply" : "replies"})` : ""}`
778
+ ).join("\n\n");
779
+ return { content: [{ type: "text", text: text2 }], structuredContent: { threads: list } };
780
+ }
781
+ },
782
+ {
783
+ name: "get_comment_thread",
784
+ title: "Read a comment thread",
785
+ description: "One thread in full: every comment, the page and element it's on (selector, HTML, box), and screenshots of the element in its surroundings and of the whole viewport as the commenter saw them.",
786
+ inputSchema: {
787
+ type: "object",
788
+ properties: {
789
+ thread_id: THREAD_ID,
790
+ screenshots: { type: "boolean", default: true, description: "Include the screenshots." }
791
+ },
792
+ required: ["thread_id"],
793
+ additionalProperties: false
794
+ },
795
+ annotations: { readOnlyHint: true, openWorldHint: false },
796
+ async run(args, actions, context) {
797
+ const thread = await actions.getThread(stringArg(args, "thread_id", true));
798
+ if (!thread) throw new ToolInputError("No such thread.");
799
+ const content = [{ type: "text", text: describe(thread) }];
800
+ if (args.screenshots !== false) {
801
+ for (const kind of ["element", "page"]) {
802
+ const shot = await context.screenshot(thread, kind);
803
+ if (!shot) continue;
804
+ content.push(
805
+ {
806
+ type: "text",
807
+ text: kind === "element" ? "The element, outlined:" : "The whole viewport:"
808
+ },
809
+ { type: "image", data: base64(shot.body), mimeType: shot.contentType }
810
+ );
811
+ }
812
+ }
813
+ return { content };
814
+ }
815
+ },
816
+ {
817
+ name: "reply_to_comment_thread",
818
+ title: "Reply to a comment thread",
819
+ description: "Adds a reply to a thread, as you on behalf of the person who connected you.",
820
+ inputSchema: {
821
+ type: "object",
822
+ properties: {
823
+ thread_id: THREAD_ID,
824
+ body: { type: "string", description: "The reply, plain text." }
825
+ },
826
+ required: ["thread_id", "body"],
827
+ additionalProperties: false
828
+ },
829
+ annotations: {
830
+ readOnlyHint: false,
831
+ destructiveHint: false,
832
+ idempotentHint: false,
833
+ openWorldHint: false
834
+ },
835
+ async run(args, actions) {
836
+ const { comment } = await actions.reply(
837
+ stringArg(args, "thread_id", true),
838
+ stringArg(args, "body", true)
839
+ );
840
+ return { content: [{ type: "text", text: `Replied (${comment.id}).` }] };
841
+ }
842
+ },
843
+ {
844
+ name: "resolve_comment_thread",
845
+ title: "Resolve a comment thread",
846
+ description: "Marks a thread resolved, optionally replying first (say what was changed). Do this once what it asks for is done.",
847
+ inputSchema: {
848
+ type: "object",
849
+ properties: {
850
+ thread_id: THREAD_ID,
851
+ reply: { type: "string", description: "A reply to leave before resolving it." }
852
+ },
853
+ required: ["thread_id"],
854
+ additionalProperties: false
855
+ },
856
+ annotations: {
857
+ readOnlyHint: false,
858
+ destructiveHint: false,
859
+ idempotentHint: true,
860
+ openWorldHint: false
861
+ },
862
+ async run(args, actions) {
863
+ const threadId = stringArg(args, "thread_id", true);
864
+ const reply = stringArg(args, "reply");
865
+ if (reply) await actions.reply(threadId, reply);
866
+ await actions.setStatus(threadId, "resolved");
867
+ return { content: [{ type: "text", text: `Resolved ${threadId}.` }] };
868
+ }
869
+ },
870
+ {
871
+ name: "reopen_comment_thread",
872
+ title: "Reopen a comment thread",
873
+ description: "Marks a resolved thread open again.",
874
+ inputSchema: {
875
+ type: "object",
876
+ properties: { thread_id: THREAD_ID },
877
+ required: ["thread_id"],
878
+ additionalProperties: false
879
+ },
880
+ annotations: {
881
+ readOnlyHint: false,
882
+ destructiveHint: false,
883
+ idempotentHint: true,
884
+ openWorldHint: false
885
+ },
886
+ async run(args, actions) {
887
+ const threadId = stringArg(args, "thread_id", true);
888
+ await actions.setStatus(threadId, "open");
889
+ return { content: [{ type: "text", text: `Reopened ${threadId}.` }] };
890
+ }
891
+ }
892
+ ];
893
+ export {
894
+ mcp
895
+ };