can2cup 0.10.2 → 0.10.4

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,627 @@
1
+ /**
2
+ * Remote MCP connector — Streamable HTTP + OAuth 2.1.
3
+ *
4
+ * Why: can2cup today is a local stdio MCP server, so being reachable costs the
5
+ * user Node, Claude Code, a terminal, and a window kept open. A remote connector
6
+ * costs them a pasted URL and a login, and works in Claude, ChatGPT, Perplexity,
7
+ * Grok and Le Chat alike. That is the difference between "people who run CLIs"
8
+ * and "people with an AI subscription".
9
+ *
10
+ * TWO CUSTODY TIERS, AND THE DIFFERENCE IS DISCLOSED RATHER THAN HIDDEN.
11
+ * local the ed25519 key never left its owner's machine. The relay cannot sign
12
+ * for it, so this surface is read-only for that agent and says why.
13
+ * hosted the relay holds the key. Weaker attribution — the operator could sign
14
+ * as this agent — but the alternative for someone with nothing installed
15
+ * is no agent at all. Custody is reported by can2cup_whoami and by the
16
+ * public, unauthenticated GET /hosted/:pub, so a counterparty can always
17
+ * weigh what a given signature is worth instead of being misled by it.
18
+ *
19
+ * The brake is the mandate, not a hardcoded blocklist: the same rules the local
20
+ * client enforces before signing run here before a hosted envelope is signed, and
21
+ * the default mandate (max_commit_amount 0, may_grant empty) means nothing binding
22
+ * leaves until its owner deliberately widens it.
23
+ *
24
+ * Auth: the OAuth authorization step reuses the LINE binding rather than inventing
25
+ * a second identity system. The human types `/link` to the can2cup bot, gets a short
26
+ * code, and pastes it into the consent page; that code resolves to their LINE
27
+ * userId, and to the agent bound to it — or mints one if there is none, which is
28
+ * what makes this an entrance rather than a second screen.
29
+ */
30
+ import {
31
+ type MsgType, PROTOCOL_VERSION, checkMandate, lineDeepLink, pubFromPriv, randomHex, signHex, signRequestHeaders, signingBytes,
32
+ } from "../protocol/index.js";
33
+ // The browser entry is the one that bundles for a Worker: the package's Node entry
34
+ // pulls in `fs` for file output, which we never use.
35
+ // @ts-expect-error no bundled types on this path
36
+ import QR from "qrcode/lib/browser.js";
37
+
38
+ export const MCP_SERVER_VERSION = "0.4.14";
39
+
40
+ /**
41
+ * v0.4.10: a hosted agent may send any message type. The previous build also kept
42
+ * a hardcoded blocklist on top of the mandate, which meant two overlapping rules
43
+ * and an agent that felt broken ("why can I not agree to anything?"). One rule is
44
+ * better: the mandate decides, exactly as it does for a local agent, and the
45
+ * default mandate is conservative enough (max_commit_amount 0, may_grant empty)
46
+ * that nothing binding gets out until its owner deliberately widens it. Custody
47
+ * stays disclosed at GET /hosted/:pub, so a counterparty can still weigh what a
48
+ * hosted signature is worth.
49
+ */
50
+ const SENDABLE: readonly MsgType[] = [
51
+ "text", "question", "proposal", "counter", "accept", "reject", "withdraw", "escalate",
52
+ "grant", "revoke", "attachment", "close",
53
+ ];
54
+
55
+ /** Conservative starting rules for a hosted agent, mirroring the local mandate.json template. */
56
+ const DEFAULT_HOSTED_MANDATE: HostedMandate = {
57
+ never_disclose: ["sk-live-", "sk-ant-", "ghp_", "glpat-", "xoxb-", "-----BEGIN"],
58
+ max_commit_amount: 0,
59
+ may_grant: [],
60
+ max_grant_hours: 2,
61
+ };
62
+
63
+ /** Versions we can speak. We echo the client's if we know it, else offer our latest. */
64
+ const KNOWN_PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
65
+ const LATEST_PROTOCOL = KNOWN_PROTOCOLS[0];
66
+
67
+ const CODE_TTL_MS = 10 * 60_000;
68
+
69
+ export interface McpBinding { pub: string; name: string; userId: string }
70
+ export interface McpRoom { name: string; state: string; lastSeq: number; participants: Record<string, string> }
71
+ export interface HostedKey { priv: string; userId: string; createdAt: string }
72
+ export interface HostedMandate {
73
+ never_disclose: string[];
74
+ max_commit_amount: number | null;
75
+ currency?: string;
76
+ may_grant: string[];
77
+ max_grant_hours: number;
78
+ }
79
+ /** Per-room capability a hosted agent earned by joining. */
80
+ export interface HostedCap { cap: string; name: string }
81
+
82
+ /** Everything this surface needs from BridgeDO, so the module stays storage-agnostic. */
83
+ export interface McpDeps {
84
+ get<T>(k: string): Promise<T | undefined>;
85
+ put(k: string, v: unknown): Promise<void>;
86
+ del(k: string): Promise<void>;
87
+ bindingByUser(userId: string): Promise<McpBinding | undefined>;
88
+ bindingByPub(pub: string): Promise<McpBinding | undefined>;
89
+ roomsFor(pub: string): Promise<Record<string, McpRoom>>;
90
+ pendingInbox(pub: string): Promise<number>;
91
+ awayAt(pub: string): Promise<string | undefined>;
92
+ /** Creates the binding both ways, exactly as the LINE /link flow does. */
93
+ bind(userId: string, pub: string, name: string): Promise<void>;
94
+ isPaused(pub: string): Promise<boolean>;
95
+ /** Talk to a RoomDO directly through its binding rather than over the network.
96
+ * A loopback fetch to our own hostname is a real round trip that depends on the
97
+ * external name being reachable from inside the Worker — under `wrangler dev`
98
+ * with a route configured it left the machine entirely and hit production. It
99
+ * also skips the worker key check on room creation, which is correct: this is
100
+ * an internal caller, not an untrusted one. */
101
+ roomCall(roomId: string, subpath: string, init?: RequestInit): Promise<Response>;
102
+ newRoomId(): string;
103
+ /** The LINE official account id, for the one-tap deep link on the consent page. */
104
+ lineOa(): string | undefined;
105
+ /** Operator-set quotas for the hosted tier. */
106
+ limits(): { roomsPerDay: number };
107
+ }
108
+
109
+ export const hostedKeyOf = (d: McpDeps, pub: string) => d.get<HostedKey>(`hosted:${pub}`);
110
+ const capsOf = async (d: McpDeps, pub: string) => (await d.get<Record<string, HostedCap>>(`hcap:${pub}`)) ?? {};
111
+
112
+ /**
113
+ * Mint an agent identity the relay holds the key for.
114
+ *
115
+ * This is the custody tier, and it is a real trade, not a shortcut: the relay can
116
+ * sign as this agent, so this agent's messages carry weaker attribution than one
117
+ * signed on its owner's machine. It exists because the alternative for a person
118
+ * with no laptop install is no agent at all. Custody is disclosed by whoami and
119
+ * by the public /hosted/:pub endpoint, so a reader of a transcript can always
120
+ * tell which tier signed an entry. What it may actually say is decided by the
121
+ * mandate, which starts conservative.
122
+ */
123
+ export async function createHostedAgent(d: McpDeps, userId: string): Promise<McpBinding> {
124
+ const priv = randomHex(32);
125
+ const pub = pubFromPriv(priv);
126
+ const name = `agent-${pub.slice(0, 6)}`;
127
+ await d.put(`hosted:${pub}`, { priv, userId, createdAt: new Date().toISOString() } as HostedKey);
128
+ await d.put(`hmandate:${pub}`, DEFAULT_HOSTED_MANDATE);
129
+ await d.bind(userId, pub, name);
130
+ return { pub, name, userId };
131
+ }
132
+
133
+ interface StoredClient { name: string; redirectUris: string[]; at: number }
134
+ interface StoredCode { clientId: string; pub: string; challenge: string; redirectUri: string; at: number }
135
+ interface StoredToken { pub: string; clientId: string; at: number }
136
+
137
+ // ------------------------------------------------------------------ oauth ---
138
+
139
+ /** RFC 9728. Tells a connector which authorization server guards this resource. */
140
+ export const protectedResourceMetadata = (origin: string) => ({
141
+ resource: `${origin}/mcp`,
142
+ authorization_servers: [origin],
143
+ bearer_methods_supported: ["header"],
144
+ scopes_supported: ["can2cup"],
145
+ });
146
+
147
+ /** RFC 8414. */
148
+ export const authorizationServerMetadata = (origin: string) => ({
149
+ issuer: origin,
150
+ authorization_endpoint: `${origin}/oauth/authorize`,
151
+ token_endpoint: `${origin}/oauth/token`,
152
+ registration_endpoint: `${origin}/oauth/register`,
153
+ response_types_supported: ["code"],
154
+ grant_types_supported: ["authorization_code"],
155
+ code_challenge_methods_supported: ["S256"], // PKCE is required, plain is not accepted
156
+ token_endpoint_auth_methods_supported: ["none"], // public clients only
157
+ scopes_supported: ["can2cup"],
158
+ });
159
+
160
+ const b64url = (b: ArrayBuffer): string => {
161
+ let s = "";
162
+ for (const x of new Uint8Array(b)) s += String.fromCharCode(x);
163
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
164
+ };
165
+
166
+ async function s256(verifier: string): Promise<string> {
167
+ return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
168
+ }
169
+
170
+ /** RFC 7591 dynamic client registration. Connectors register themselves, so there
171
+ * is nothing for the user to copy; we only keep what we need to validate a redirect. */
172
+ export async function register(d: McpDeps, body: unknown): Promise<Response> {
173
+ const b = (body ?? {}) as { client_name?: string; redirect_uris?: unknown };
174
+ const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u): u is string => typeof u === "string") : [];
175
+ if (uris.length === 0) return Response.json({ error: "invalid_client_metadata", error_description: "redirect_uris is required" }, { status: 400 });
176
+ const clientId = randomHex(16);
177
+ await d.put(`oauth:client:${clientId}`, { name: b.client_name ?? "", redirectUris: uris, at: Date.now() } as StoredClient);
178
+ return Response.json({
179
+ client_id: clientId,
180
+ client_name: b.client_name ?? "",
181
+ redirect_uris: uris,
182
+ grant_types: ["authorization_code"],
183
+ response_types: ["code"],
184
+ token_endpoint_auth_method: "none",
185
+ }, { status: 201 });
186
+ }
187
+
188
+ /** The LINE deep link with "/link" prefilled — the message the bot answers with a code. */
189
+ const lineLink = (oa: string) => lineDeepLink(oa, "/link");
190
+
191
+ /** Rendered server-side as inline SVG: this page is nearly always open on a desktop
192
+ * browser (you add a connector on a computer) while LINE lives on the phone, so a
193
+ * tap-through deep link reaches nothing. A QR crosses that gap. Never let QR
194
+ * trouble break the page — the typed-code path still works without it. */
195
+ async function qrSvg(target: string): Promise<string | undefined> {
196
+ try {
197
+ return await (QR as { toString: (t: string, o: object) => Promise<string> })
198
+ .toString(target, { type: "svg", margin: 1, width: 168, errorCorrectionLevel: "M" });
199
+ } catch { return undefined; }
200
+ }
201
+
202
+ const page = (origin: string, clientName: string, q: URLSearchParams, error?: string, lineOa?: string, qr?: string) => `<!doctype html>
203
+ <html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
204
+ <title>連結 can2cup</title><style>
205
+ :root{color-scheme:light dark;--bg:#fff;--fg:#18181b;--mut:#71717a;--line:#e4e4e7;--acc:#2563eb;--err:#dc2626}
206
+ @media(prefers-color-scheme:dark){:root{--bg:#18181b;--fg:#fafafa;--mut:#a1a1aa;--line:#3f3f46;--acc:#60a5fa;--err:#f87171}}
207
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 system-ui,-apple-system,"Noto Sans TC",sans-serif;display:grid;place-items:center;min-height:100vh;padding:24px}
208
+ .card{width:100%;max-width:420px;border:1px solid var(--line);border-radius:14px;padding:28px}
209
+ h1{font-size:19px;margin:0 0 6px}p{color:var(--mut);margin:0 0 18px}
210
+ ol{color:var(--mut);padding-left:20px;margin:0 0 18px}li{margin:6px 0}
211
+ code{background:var(--line);padding:1px 6px;border-radius:5px;color:var(--fg)}
212
+ input{width:100%;padding:11px 13px;font:inherit;font-variant-numeric:tabular-nums;letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--line);border-radius:9px;background:transparent;color:var(--fg)}
213
+ button{width:100%;margin-top:14px;padding:11px;font:inherit;font-weight:600;border:0;border-radius:9px;background:var(--acc);color:#fff;cursor:pointer}
214
+ .qr{text-align:center;margin:0 0 4px}.qr svg{width:168px;height:168px;background:#fff;padding:8px;border-radius:10px}
215
+ .cap{color:var(--mut);font-size:13px;text-align:center;margin:0 0 14px}
216
+ .line{display:inline-block;text-decoration:none;color:#06c755;font-weight:600}
217
+ .err{color:var(--err);margin:0 0 14px}.f{color:var(--mut);font-size:13px;margin:16px 0 0}
218
+ .step{font-weight:600;margin:0 0 8px}
219
+ </style></head><body><div class="card">
220
+ <h1>把 can2cup 連給${clientName ? " " + clientName.replace(/[<>&]/g, "") : "這個 app"}</h1>
221
+ <p>讓這個 app 裡的 AI 能替你在 can2cup 房間裡跟別人的 agent 對話。</p>
222
+ ${error ? `<p class="err">${error}</p>` : ""}
223
+ ${qr ? `<p class="step">① 用手機 LINE 掃這個 QR</p><div class="qr">${qr}</div>
224
+ <p class="cap">傳聲罐罐的聊天會打開,<code>/link</code> 已經填好 —— 按送出就好。<br>
225
+ ${lineOa ? `已經在手機上看這頁?<a class="line" href="${lineLink(lineOa)}">直接開啟 LINE</a>` : ""}</p>
226
+ <p class="step">② 把機器人回你的代碼貼到下面</p>`
227
+ : `<ol><li>在 LINE 對 <strong>傳聲罐罐 can2cup</strong> 傳 <code>/link</code></li><li>把機器人回你的代碼貼到下面</li></ol>`}
228
+ <form method="post" action="${origin}/oauth/authorize?${q.toString()}">
229
+ <input name="code" placeholder="AB12-CD34" autocomplete="off" autocapitalize="characters" required autofocus>
230
+ <button type="submit">連結</button></form>
231
+ <p class="f"><a href="${origin}/terms" style="color:var(--mut)">服務條款與濫用通報</a></p>
232
+ <p class="f">代碼 10 分鐘內有效。<br>
233
+ 還沒有 can2cup agent 的話,這一步會替你建立一個,<strong>金鑰由這台 relay 保管</strong> —— 方便,但它的訊息歸屬強度比裝在自己電腦上的弱,而且<strong>不能代你做出承諾</strong>(accept / grant)。想要完整強度就在自己機器上裝 can2cup。</p>
234
+ </div></body></html>`;
235
+
236
+ export async function authorizeGet(d: McpDeps, url: URL, origin: string): Promise<Response> {
237
+ const q = url.searchParams;
238
+ const clientId = q.get("client_id") ?? "";
239
+ const client = await d.get<StoredClient>(`oauth:client:${clientId}`);
240
+ if (!client) return new Response("unknown client_id", { status: 400 });
241
+ if (!client.redirectUris.includes(q.get("redirect_uri") ?? "")) return new Response("redirect_uri not registered for this client", { status: 400 });
242
+ if (q.get("code_challenge_method") !== "S256" || !q.get("code_challenge")) return new Response("PKCE with S256 is required", { status: 400 });
243
+ const oa = d.lineOa();
244
+ return new Response(page(origin, client.name, q, undefined, oa, oa ? await qrSvg(lineLink(oa)) : undefined), { headers: { "content-type": "text/html; charset=utf-8" } });
245
+ }
246
+
247
+ export async function authorizePost(d: McpDeps, url: URL, form: FormData, origin: string): Promise<Response> {
248
+ const q = url.searchParams;
249
+ const clientId = q.get("client_id") ?? "";
250
+ const redirectUri = q.get("redirect_uri") ?? "";
251
+ const client = await d.get<StoredClient>(`oauth:client:${clientId}`);
252
+ if (!client || !client.redirectUris.includes(redirectUri)) return new Response("bad client", { status: 400 });
253
+
254
+ const typed = String(form.get("code") ?? "").trim().toUpperCase().replace(/\s+/g, "");
255
+ const rec = typed ? await d.get<{ userId: string; at: number }>(`pcode:${typed}`) : undefined;
256
+ const oa = d.lineOa();
257
+ const qr = oa ? await qrSvg(lineLink(oa)) : undefined;
258
+ const fail = (m: string) => new Response(page(origin, client.name, q, m, oa, qr), { status: 400, headers: { "content-type": "text/html; charset=utf-8" } });
259
+ if (!rec) return fail("這個代碼無效。請在 LINE 重新輸入 /link 取得新的。");
260
+ if (Date.now() - rec.at > CODE_TTL_MS) return fail("代碼已過期(10 分鐘)。請重新取得。");
261
+ // No agent yet? Make one. This is the whole point of the connector: a person
262
+ // with nothing installed must be able to finish this flow.
263
+ const binding = (await d.bindingByUser(rec.userId)) ?? (await createHostedAgent(d, rec.userId));
264
+
265
+ // One-shot: the code cannot be replayed into a second authorization.
266
+ await d.del(`pcode:${typed}`);
267
+ const code = randomHex(24);
268
+ await d.put(`oauth:code:${code}`, {
269
+ clientId, pub: binding.pub, challenge: q.get("code_challenge")!, redirectUri, at: Date.now(),
270
+ } as StoredCode);
271
+
272
+ const back = new URL(redirectUri);
273
+ back.searchParams.set("code", code);
274
+ if (q.get("state")) back.searchParams.set("state", q.get("state")!);
275
+ return Response.redirect(back.toString(), 302);
276
+ }
277
+
278
+ export async function token(d: McpDeps, form: URLSearchParams): Promise<Response> {
279
+ const bad = (e: string, desc: string) => Response.json({ error: e, error_description: desc }, { status: 400 });
280
+ if (form.get("grant_type") !== "authorization_code") return bad("unsupported_grant_type", "only authorization_code is supported");
281
+ const code = form.get("code") ?? "";
282
+ const rec = await d.get<StoredCode>(`oauth:code:${code}`);
283
+ if (!rec) return bad("invalid_grant", "unknown or already-used code");
284
+ await d.del(`oauth:code:${code}`); // single use, whatever happens next
285
+ if (Date.now() - rec.at > 60_000) return bad("invalid_grant", "code expired");
286
+ if (rec.clientId !== (form.get("client_id") ?? "")) return bad("invalid_grant", "code was issued to a different client");
287
+ if (rec.redirectUri !== (form.get("redirect_uri") ?? "")) return bad("invalid_grant", "redirect_uri mismatch");
288
+ const verifier = form.get("code_verifier") ?? "";
289
+ if (!verifier || (await s256(verifier)) !== rec.challenge) return bad("invalid_grant", "PKCE verification failed");
290
+
291
+ const access = randomHex(32);
292
+ await d.put(`oauth:tok:${access}`, { pub: rec.pub, clientId: rec.clientId, at: Date.now() } as StoredToken);
293
+ return Response.json({ access_token: access, token_type: "Bearer", scope: "can2cup" });
294
+ }
295
+
296
+ // -------------------------------------------------------------------- mcp ---
297
+
298
+ const NONE = { type: "object", properties: {}, additionalProperties: false } as const;
299
+
300
+ const TOOLS = [
301
+ {
302
+ name: "can2cup_whoami",
303
+ description:
304
+ "Who this agent is on can2cup: its public key, display name, who holds its signing key, whether a "
305
+ + "principal is linked on LINE, and how many principal instructions are waiting.",
306
+ inputSchema: NONE,
307
+ },
308
+ {
309
+ name: "can2cup_rooms",
310
+ description:
311
+ "The can2cup rooms this agent is in, with each room's name, open/closed state, last sequence number "
312
+ + "and participants.",
313
+ inputSchema: NONE,
314
+ },
315
+ {
316
+ name: "can2cup_join",
317
+ description:
318
+ "Join a room from an invite link the other party gave you (looks like https://<relay>/j/<id>#<secret>). "
319
+ + "Hosted agents only — an agent whose key lives on its owner's machine must join from there.",
320
+ inputSchema: {
321
+ type: "object",
322
+ properties: { invite: { type: "string", description: "the full invite link, including the part after #" } },
323
+ required: ["invite"], additionalProperties: false,
324
+ },
325
+ },
326
+ {
327
+ name: "can2cup_create_room",
328
+ description:
329
+ "Open a new room and get an invite link to hand to the other party out of band (LINE, mail, in person). "
330
+ + "Their agent joins with can2cup_join. Hosted agents only.",
331
+ inputSchema: {
332
+ type: "object",
333
+ properties: { name: { type: "string", description: "what this conversation is about" } },
334
+ additionalProperties: false,
335
+ },
336
+ },
337
+ {
338
+ name: "can2cup_invite",
339
+ description: "Get the invite link for a room you are already in, to give to someone else.",
340
+ inputSchema: {
341
+ type: "object",
342
+ properties: { room: { type: "string", description: "room id (12 hex)" } },
343
+ required: ["room"], additionalProperties: false,
344
+ },
345
+ },
346
+ {
347
+ name: "can2cup_send",
348
+ description:
349
+ "Say something in a room. `type` carries the intent. Your principal's mandate is checked before "
350
+ + "anything is signed: a blocked message is never sent and you are told why.",
351
+ inputSchema: {
352
+ type: "object",
353
+ properties: {
354
+ room: { type: "string", description: "room id (12 hex)" },
355
+ type: { type: "string", enum: [...SENDABLE], description: "message intent (default text)" },
356
+ text: { type: "string", description: "what to say" },
357
+ amount: { type: "number", description: "figure attached to a proposal, counter or accept" },
358
+ scope: { type: "string", description: "for a grant: what is being authorised, e.g. \"read:logs/*\"" },
359
+ expiresHours: { type: "number", description: "for a grant: how long it lasts" },
360
+ ref: { type: "number", description: "for a revoke: the seq of the grant being withdrawn" },
361
+ url: { type: "string", description: "for an attachment: an https URL (the relay never stores bytes)" },
362
+ },
363
+ required: ["room", "text"], additionalProperties: false,
364
+ },
365
+ },
366
+ {
367
+ name: "can2cup_history",
368
+ description: "Read a room's transcript from a sequence number onwards.",
369
+ inputSchema: {
370
+ type: "object",
371
+ properties: {
372
+ room: { type: "string", description: "room id (12 hex)" },
373
+ since: { type: "number", description: "return messages after this seq (default 0)" },
374
+ },
375
+ required: ["room"], additionalProperties: false,
376
+ },
377
+ },
378
+ ] as const;
379
+
380
+ const WRITE_TOOLS = ["can2cup_join", "can2cup_send", "can2cup_create_room", "can2cup_invite"];
381
+
382
+ const rpcErr = (id: unknown, code: number, message: string) =>
383
+ Response.json({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
384
+
385
+ const text = (id: unknown, s: string) =>
386
+ Response.json({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: s }] } });
387
+
388
+ /**
389
+ * The outbound gate, for hosted agents only. The local client enforces the full
390
+ * mandate from mandate.json before anything is signed; a hosted agent has no
391
+ * local client, so the same rules have to live here or the easy tier ships with
392
+ * no brake at all — which is the one thing this product must not do.
393
+ */
394
+ async function mandateBlock(d: McpDeps, pub: string, type: MsgType, body: Record<string, unknown>): Promise<string | null> {
395
+ if (await d.isPaused(pub)) return "your principal has paused this agent's outbound messages (/resume in LINE).";
396
+ const m = { ...DEFAULT_HOSTED_MANDATE, ...((await d.get<HostedMandate>(`hmandate:${pub}`)) ?? {}) };
397
+ const msg = checkMandate(m, type, body);
398
+ // A blocked verdict gets the explicit "NOT SENT." suffix here: a hosted agent has no
399
+ // local client output to make that unmistakable, the tool result is all it sees.
400
+ return msg && (msg.startsWith("blocked by mandate") ? `${msg} NOT SENT.` : msg);
401
+ }
402
+
403
+ const j = async (res: Response): Promise<Record<string, unknown>> => {
404
+ const t = await res.text();
405
+ try { return t ? JSON.parse(t) : {}; } catch { return { error: t.slice(0, 200) }; }
406
+ };
407
+
408
+ async function callTool(d: McpDeps, pub: string, name: string, args: Record<string, unknown>, origin: string): Promise<string> {
409
+ const binding = await d.bindingByPub(pub);
410
+ const hosted = await hostedKeyOf(d, pub);
411
+ // v0.9.14: a hosted agent has no heartbeat; each connector call is its "being seen", so the idle-binding
412
+ // rule (v0.9.12) can cover it the same way it covers a local agent.
413
+ if (hosted) await d.put(`seen:${pub}`, new Date().toISOString());
414
+
415
+ if (WRITE_TOOLS.includes(name) && !hosted) {
416
+ return "This agent's signing key lives on its owner's machine, so the relay cannot act for it. "
417
+ + "Use the local can2cup client (or the can2cup CLI) for anything that has to be signed.";
418
+ }
419
+ const me = hosted ? { pub, priv: hosted.priv, name: binding?.name ?? "" } : undefined;
420
+
421
+ switch (name) {
422
+ case "can2cup_whoami": {
423
+ const away = await d.awayAt(pub);
424
+ return JSON.stringify({
425
+ pubkey: pub,
426
+ name: binding?.name ?? "",
427
+ keyCustody: hosted ? "hosted — this relay holds the signing key" : "local — the key never leaves its owner's machine",
428
+ canCommit: hosted ? "yes, within the mandate below" : "only from the local client",
429
+ principalLinkedOnLine: !!binding,
430
+ pendingPrincipalInstructions: await d.pendingInbox(pub),
431
+ localAgentAwake: hosted ? "n/a (hosted)" : !away,
432
+ }, null, 2);
433
+ }
434
+
435
+ case "can2cup_rooms": {
436
+ const rooms = await d.roomsFor(pub);
437
+ const list = Object.entries(rooms).map(([id, r]) => ({
438
+ room: id, name: r.name, state: r.state, lastSeq: r.lastSeq, participants: Object.values(r.participants),
439
+ }));
440
+ return list.length ? JSON.stringify(list, null, 2) : "This agent is not in any can2cup room.";
441
+ }
442
+
443
+ case "can2cup_join": {
444
+ // The secret rides in the URL fragment precisely so it never reaches a server log.
445
+ const m = /\/j\/([0-9a-f]{12})#(.+)$/.exec(String(args.invite ?? "").trim());
446
+ if (!m) return "That does not look like a can2cup invite link (expected https://<relay>/j/<id>#<secret>).";
447
+ const [, room, frag] = m;
448
+ // #<secret>.<key> marks an E2E room. A hosted agent joining one would hand this relay
449
+ // the room key, which silently defeats the encryption — refuse rather than pretend.
450
+ if (frag.includes(".")) {
451
+ return "This room is END-TO-END ENCRYPTED. A hosted agent's key (and then the room key) would "
452
+ + "live on the relay, which is exactly what E2E exists to prevent — join it from a local "
453
+ + "can2cup client instead (npm i -g <relay>/dl/can2cup.tgz).";
454
+ }
455
+ const secret = frag;
456
+ const path = `/rooms/${room}/join`;
457
+ const body = JSON.stringify({ pubkey: pub, name: me!.name });
458
+ const res = await d.roomCall(room, "/join", {
459
+ method: "POST",
460
+ headers: {
461
+ "content-type": "application/json",
462
+ authorization: `Bearer ${secret}`,
463
+ ...signRequestHeaders("POST", path, body, me!),
464
+ },
465
+ body,
466
+ });
467
+ const out = await j(res);
468
+ if (!res.ok) return `Could not join ${room}: ${String(out.error ?? res.status)}`;
469
+ const caps = await capsOf(d, pub);
470
+ caps[room] = { cap: String(out.cap ?? ""), name: String(out.name ?? "") };
471
+ await d.put(`hcap:${pub}`, caps);
472
+ return `Joined room ${room}${out.name ? ` "${String(out.name)}"` : ""}. Participants: `
473
+ + Object.values((out.participants ?? {}) as Record<string, { name?: string }>).map((p) => p.name || "?").join(", ");
474
+ }
475
+
476
+ case "can2cup_create_room": {
477
+ const day = new Date().toISOString().slice(0, 10);
478
+ const qk = `q:rooms:${pub}:${day}`;
479
+ const used = (await d.get<number>(qk)) ?? 0;
480
+ const lim = d.limits().roomsPerDay;
481
+ if (used >= lim) return `Daily room quota reached for this identity (${lim}/day). NOT CREATED — try tomorrow or ask the operator.`;
482
+ const id = d.newRoomId();
483
+ const created = await j(await d.roomCall(id, "", {
484
+ method: "POST",
485
+ headers: { "content-type": "application/json" },
486
+ body: JSON.stringify({ name: String(args.name ?? ""), creator: { pubkey: pub, name: me!.name } }),
487
+ }));
488
+ if (typeof created.id !== "string") return `Could not open a room: ${String(created.error ?? "unknown error")}`;
489
+ const caps = await capsOf(d, pub);
490
+ caps[id] = { cap: String(created.cap ?? ""), name: String(args.name ?? "") };
491
+ await d.put(`hcap:${pub}`, caps);
492
+ await d.put(qk, used + 1);
493
+ return `Room ${id} is open.\n\nInvite link — give it to the other party whole and privately; `
494
+ + `everything after the # is the secret:\n${origin}/j/${created.id}#${String(created.secret)}`;
495
+ }
496
+
497
+ case "can2cup_invite": {
498
+ const room = String(args.room ?? "");
499
+ const caps = await capsOf(d, pub);
500
+ const cap = caps[room]?.cap;
501
+ if (!cap) return `This agent has no capability for room ${room}.`;
502
+ // Only a cap-authenticated caller gets the current invite secret back.
503
+ const info = await j(await d.roomCall(room, "/info", { headers: { authorization: `Bearer ${cap}` } }));
504
+ if (typeof info.secret !== "string") return `Could not read the invite secret for ${room}: ${String(info.error ?? "unknown error")}`;
505
+ return `${origin}/j/${room}#${info.secret}`;
506
+ }
507
+
508
+ case "can2cup_send": {
509
+ const room = String(args.room ?? "");
510
+ const type = (args.type ?? "text") as MsgType;
511
+ if (!SENDABLE.includes(type)) return `"${type}" is not a can2cup message type.`;
512
+ const caps = await capsOf(d, pub);
513
+ const cap = caps[room]?.cap;
514
+ if (!cap) return `This agent has no capability for room ${room}. Join it first with can2cup_join.`;
515
+
516
+ const body: Record<string, unknown> = { text: String(args.text ?? "") };
517
+ if (typeof args.amount === "number") body.amount = args.amount;
518
+ if (typeof args.scope === "string") body.scope = args.scope;
519
+ if (typeof args.ref === "number") body.ref = args.ref;
520
+ if (typeof args.url === "string") body.url = args.url;
521
+ if (typeof args.expiresHours === "number") {
522
+ body.expires = new Date(Date.now() + args.expiresHours * 3.6e6).toISOString();
523
+ }
524
+ const blocked = await mandateBlock(d, pub, type, body);
525
+ if (blocked) return blocked;
526
+
527
+ // prev must be the hash of the latest entry we have seen, so read before signing.
528
+ const poll = await j(await d.roomCall(room, "/messages?since=999999999", { headers: { authorization: `Bearer ${cap}` } }));
529
+ if (typeof poll.lastHash !== "string") return `Could not read room ${room}: ${String(poll.error ?? "unknown error")}`;
530
+ const unsigned = { v: PROTOCOL_VERSION, room, from: pub, ts: new Date().toISOString(), type, body, prev: poll.lastHash };
531
+ const sent = await j(await d.roomCall(room, "/messages", {
532
+ method: "POST",
533
+ headers: { "content-type": "application/json", authorization: `Bearer ${cap}` },
534
+ body: JSON.stringify({ ...unsigned, sig: signHex(signingBytes(unsigned), me!.priv) }),
535
+ }));
536
+ if (typeof sent.seq !== "number") return `Not sent: ${String(sent.error ?? "unknown error")}`;
537
+ return `Sent as ${type} at seq ${sent.seq} in room ${room}.`;
538
+ }
539
+
540
+ case "can2cup_history": {
541
+ const room = String(args.room ?? "");
542
+ const since = typeof args.since === "number" ? args.since : 0;
543
+ const caps = await capsOf(d, pub);
544
+ const cap = caps[room]?.cap;
545
+ if (!cap) return `This agent has no capability for room ${room}.`;
546
+ const poll = await j(await d.roomCall(room, `/messages?since=${since}`, { headers: { authorization: `Bearer ${cap}` } }));
547
+ const msgs = (poll.messages ?? []) as Array<{ seq: number; from: string; type: string; body: { text?: string }; ts: string }>;
548
+ if (!Array.isArray(msgs) || msgs.length === 0) return `Nothing in room ${room} after seq ${since}.`;
549
+ return msgs.map((e) => `#${e.seq} ${e.from === "relay" ? "relay" : e.from.slice(0, 8)} [${e.type}] ${e.body?.text ?? JSON.stringify(e.body)}`).join("\n");
550
+ }
551
+
552
+ default:
553
+ return `unknown tool: ${name}`;
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Streamable HTTP transport. One endpoint: POST carries JSON-RPC, a notification
559
+ * (no id) gets 202 with no body, everything else answers inline. We do not open
560
+ * an SSE stream — nothing here pushes, so advertising one would be a lie.
561
+ */
562
+ export async function handleMcp(d: McpDeps, req: Request, origin: string): Promise<Response> {
563
+ const unauthorized = () => new Response(JSON.stringify({ error: "unauthorized" }), {
564
+ status: 401,
565
+ headers: {
566
+ "content-type": "application/json",
567
+ // Sends a connector to the metadata document instead of leaving it guessing.
568
+ "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
569
+ },
570
+ });
571
+
572
+ if (req.method === "GET") return new Response("this MCP endpoint does not open a server stream", { status: 405 });
573
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
574
+
575
+ const auth = req.headers.get("authorization") ?? "";
576
+ if (!auth.startsWith("Bearer ")) return unauthorized();
577
+ const tok = await d.get<StoredToken>(`oauth:tok:${auth.slice(7)}`);
578
+ if (!tok) return unauthorized();
579
+
580
+ let msg: { jsonrpc?: string; id?: unknown; method?: unknown; params?: unknown };
581
+ try { msg = (await req.json()) as typeof msg; }
582
+ catch { return rpcErr(null, -32700, "invalid JSON"); }
583
+
584
+ const id = msg.id;
585
+ const method = typeof msg.method === "string" ? msg.method : "";
586
+ if (msg.jsonrpc !== "2.0" || !method) return rpcErr(id ?? null, -32600, "invalid JSON-RPC request");
587
+ if (await d.get(`ban:pub:${tok.pub}`)) return rpcErr(id ?? null, -32000, "this identity has been banned by the relay operator");
588
+ // A notification has no id and expects no body.
589
+ if (id === undefined) return new Response(null, { status: 202 });
590
+
591
+ switch (method) {
592
+ case "initialize": {
593
+ const asked = (msg.params as { protocolVersion?: string } | undefined)?.protocolVersion;
594
+ return Response.json({
595
+ jsonrpc: "2.0", id,
596
+ result: {
597
+ protocolVersion: asked && KNOWN_PROTOCOLS.includes(asked) ? asked : LATEST_PROTOCOL,
598
+ capabilities: { tools: { listChanged: false } },
599
+ serverInfo: { name: "can2cup", version: MCP_SERVER_VERSION },
600
+ instructions:
601
+ "can2cup rooms let agents that answer to different humans talk to each other. Call can2cup_whoami "
602
+ + "first: it tells you whether this agent's key is hosted here or lives on its owner's machine. "
603
+ + "A hosted agent can join rooms and talk, but cannot send accept / grant / revoke / close — those "
604
+ + "create or withdraw authority and must be signed on the principal's own machine. An agent with a "
605
+ + "local key is read-only through this surface; use its local client to send.",
606
+ },
607
+ }, { headers: { "mcp-session-id": randomHex(16) } });
608
+ }
609
+ case "ping":
610
+ return Response.json({ jsonrpc: "2.0", id, result: {} });
611
+ case "tools/list":
612
+ return Response.json({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
613
+ case "tools/call": {
614
+ const p = msg.params as { name?: string; arguments?: Record<string, unknown> } | undefined;
615
+ const name = p?.name ?? "";
616
+ if (!TOOLS.some((t) => t.name === name)) return rpcErr(id, -32602, `unknown tool: ${name}`);
617
+ try { return text(id, await callTool(d, tok.pub, name, p?.arguments ?? {}, origin)); }
618
+ catch (e) { return text(id, `tool failed: ${(e as Error).message}`); }
619
+ }
620
+ case "resources/list":
621
+ return Response.json({ jsonrpc: "2.0", id, result: { resources: [] } });
622
+ case "prompts/list":
623
+ return Response.json({ jsonrpc: "2.0", id, result: { prompts: [] } });
624
+ default:
625
+ return rpcErr(id, -32601, `method not found: ${method}`);
626
+ }
627
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "types": ["@cloudflare/workers-types"]
10
+ },
11
+ "include": ["src/protocol", "src/relay"]
12
+ }