@ractive-ch/tab-mcp 0.0.0-stage → 0.1.0-alpha.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.
- package/AI_NOTICE +9 -0
- package/LICENSE +21 -0
- package/address.d.ts +14 -0
- package/config.d.ts +122 -0
- package/exchange.d.ts +69 -0
- package/index.d.ts +12 -0
- package/index.js +3558 -0
- package/index.js.map +25 -0
- package/ip.d.ts +22 -0
- package/log.d.ts +33 -0
- package/main.js +3627 -0
- package/main.js.map +26 -0
- package/oauth/cimd.d.ts +77 -0
- package/oauth/html.d.ts +13 -0
- package/oauth/pairing.d.ts +72 -0
- package/oauth/server.d.ts +68 -0
- package/package.json +41 -5
- package/protocol.d.ts +185 -0
- package/ratelimit.d.ts +21 -0
- package/relay.d.ts +42 -0
- package/session.d.ts +74 -0
- package/store/conformance.d.ts +11 -0
- package/store/memory.d.ts +16 -0
- package/store/sqlite.d.ts +17 -0
- package/store/types.d.ts +89 -0
- package/testing.d.ts +5 -0
- package/testing.js +204 -0
- package/testing.js.map +10 -0
- package/tokens.d.ts +45 -0
- package/README.md +0 -3
package/index.js
ADDED
|
@@ -0,0 +1,3558 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/address.ts
|
|
3
|
+
var BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
4
|
+
function base32(bytes) {
|
|
5
|
+
let out = "";
|
|
6
|
+
let buffer = 0;
|
|
7
|
+
let bits = 0;
|
|
8
|
+
for (const byte of bytes) {
|
|
9
|
+
buffer = buffer << 8 | byte;
|
|
10
|
+
bits += 8;
|
|
11
|
+
while (bits >= 5) {
|
|
12
|
+
bits -= 5;
|
|
13
|
+
out += BASE32_ALPHABET[buffer >>> bits & 31];
|
|
14
|
+
}
|
|
15
|
+
buffer &= (1 << bits) - 1;
|
|
16
|
+
}
|
|
17
|
+
if (bits > 0)
|
|
18
|
+
out += BASE32_ALPHABET[buffer << 5 - bits & 31];
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
var SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
22
|
+
var ADDRESS_ID_PATTERN = /^[a-z2-7]{52}$/;
|
|
23
|
+
function isWellFormedSecret(secret) {
|
|
24
|
+
return SECRET_PATTERN.test(secret);
|
|
25
|
+
}
|
|
26
|
+
function isAddressId(id) {
|
|
27
|
+
return ADDRESS_ID_PATTERN.test(id);
|
|
28
|
+
}
|
|
29
|
+
function addressIdFromSecret(secret) {
|
|
30
|
+
const digest = new Bun.CryptoHasher("sha256").update(secret).digest();
|
|
31
|
+
return base32(new Uint8Array(digest.buffer, digest.byteOffset, digest.length));
|
|
32
|
+
}
|
|
33
|
+
function newSecret() {
|
|
34
|
+
return crypto.getRandomValues(new Uint8Array(32)).toBase64({ alphabet: "base64url", omitPadding: true });
|
|
35
|
+
}
|
|
36
|
+
function shortAddress(id) {
|
|
37
|
+
return id.slice(0, 8);
|
|
38
|
+
}
|
|
39
|
+
// src/config.ts
|
|
40
|
+
var MIN_MESSAGE_BYTES = 16384;
|
|
41
|
+
var MAX_APP_NAME = 64;
|
|
42
|
+
var DEFAULT_CONFIG = {
|
|
43
|
+
port: 8080,
|
|
44
|
+
host: "0.0.0.0",
|
|
45
|
+
allowedOrigins: [],
|
|
46
|
+
sites: [],
|
|
47
|
+
clientIdHosts: [],
|
|
48
|
+
trustProxy: "none",
|
|
49
|
+
cors: true,
|
|
50
|
+
store: "sqlite",
|
|
51
|
+
sqlitePath: "tab-mcp.db",
|
|
52
|
+
maxSessions: 500,
|
|
53
|
+
maxSessionsPerIp: 5,
|
|
54
|
+
maxConcurrentRequests: 8,
|
|
55
|
+
requestTimeoutMs: 300000,
|
|
56
|
+
keepAliveMs: 20000,
|
|
57
|
+
idleTimeoutMs: 3600000,
|
|
58
|
+
sessionLifetimeMs: 43200000,
|
|
59
|
+
heartbeatMs: 25000,
|
|
60
|
+
helloTimeoutMs: 1e4,
|
|
61
|
+
bodyTimeoutMs: 1e4,
|
|
62
|
+
maxMessageBytes: 1048576,
|
|
63
|
+
accessTokenTtlMs: 3600000,
|
|
64
|
+
refreshTokenTtlMs: 2592000000,
|
|
65
|
+
grantLifetimeMs: 7776000000,
|
|
66
|
+
sweepIntervalMs: 600000,
|
|
67
|
+
backpressureLimitBytes: 4 * 1048576,
|
|
68
|
+
httpIdleTimeoutS: 30,
|
|
69
|
+
logLevel: "info",
|
|
70
|
+
development: false
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
class ConfigError extends Error {
|
|
74
|
+
name = "ConfigError";
|
|
75
|
+
}
|
|
76
|
+
function normalizeOrigin(value) {
|
|
77
|
+
let url;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(value);
|
|
80
|
+
} catch {
|
|
81
|
+
throw new ConfigError(`not an origin: ${value}`);
|
|
82
|
+
}
|
|
83
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
84
|
+
throw new ConfigError(`origin must be http or https: ${value}`);
|
|
85
|
+
}
|
|
86
|
+
if (url.pathname !== "/" && url.pathname !== "" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") {
|
|
87
|
+
throw new ConfigError(`an origin has no path, query or credentials (browsers send only the origin): ${value}`);
|
|
88
|
+
}
|
|
89
|
+
return url.origin;
|
|
90
|
+
}
|
|
91
|
+
function resolveConfig(input = {}) {
|
|
92
|
+
const config = { ...DEFAULT_CONFIG, ...input };
|
|
93
|
+
if (config.publicUrl !== undefined) {
|
|
94
|
+
const origin = normalizeOrigin(config.publicUrl);
|
|
95
|
+
if (!origin.startsWith("https:") && !config.development) {
|
|
96
|
+
throw new ConfigError("TAB_MCP_PUBLIC_URL must be https outside development");
|
|
97
|
+
}
|
|
98
|
+
config.publicUrl = origin;
|
|
99
|
+
} else if (!config.development) {
|
|
100
|
+
throw new ConfigError("TAB_MCP_PUBLIC_URL is required");
|
|
101
|
+
}
|
|
102
|
+
if (config.allowedOrigins !== "*") {
|
|
103
|
+
config.allowedOrigins = config.allowedOrigins.map(normalizeOrigin);
|
|
104
|
+
}
|
|
105
|
+
config.sites = config.sites.map((site) => validateSite(site));
|
|
106
|
+
const seen = new Set(config.allowedOrigins === "*" ? [] : config.allowedOrigins);
|
|
107
|
+
for (const site of config.sites) {
|
|
108
|
+
if (seen.has(site.origin)) {
|
|
109
|
+
throw new ConfigError(`origin configured twice: ${site.origin}`);
|
|
110
|
+
}
|
|
111
|
+
seen.add(site.origin);
|
|
112
|
+
}
|
|
113
|
+
config.clientIdHosts = config.clientIdHosts.map((h) => {
|
|
114
|
+
const host = h.trim().toLowerCase();
|
|
115
|
+
if (!/^[a-z0-9.-]+(:\d+)?$/.test(host)) {
|
|
116
|
+
throw new ConfigError(`not a host name: ${h}`);
|
|
117
|
+
}
|
|
118
|
+
return host;
|
|
119
|
+
});
|
|
120
|
+
const positive = [
|
|
121
|
+
"maxSessions",
|
|
122
|
+
"maxSessionsPerIp",
|
|
123
|
+
"maxConcurrentRequests",
|
|
124
|
+
"requestTimeoutMs",
|
|
125
|
+
"keepAliveMs",
|
|
126
|
+
"idleTimeoutMs",
|
|
127
|
+
"sessionLifetimeMs",
|
|
128
|
+
"heartbeatMs",
|
|
129
|
+
"helloTimeoutMs",
|
|
130
|
+
"bodyTimeoutMs",
|
|
131
|
+
"maxMessageBytes",
|
|
132
|
+
"accessTokenTtlMs",
|
|
133
|
+
"refreshTokenTtlMs",
|
|
134
|
+
"grantLifetimeMs",
|
|
135
|
+
"sweepIntervalMs",
|
|
136
|
+
"backpressureLimitBytes",
|
|
137
|
+
"httpIdleTimeoutS"
|
|
138
|
+
];
|
|
139
|
+
for (const key of positive) {
|
|
140
|
+
const v = config[key];
|
|
141
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) {
|
|
142
|
+
throw new ConfigError(`${key} must be a positive number`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (config.maxMessageBytes < MIN_MESSAGE_BYTES) {
|
|
146
|
+
throw new ConfigError(`maxMessageBytes must be at least ${MIN_MESSAGE_BYTES} (16 KiB)`);
|
|
147
|
+
}
|
|
148
|
+
if (config.httpIdleTimeoutS > 255) {
|
|
149
|
+
throw new ConfigError("httpIdleTimeoutS must be at most 255");
|
|
150
|
+
}
|
|
151
|
+
if (!Number.isInteger(config.port) || config.port < 0 || config.port > 65535) {
|
|
152
|
+
throw new ConfigError("port must be 0-65535");
|
|
153
|
+
}
|
|
154
|
+
return config;
|
|
155
|
+
}
|
|
156
|
+
var SITE_KEYS = new Set(["origin", "name", "pairing", "limits"]);
|
|
157
|
+
var LIMIT_KEYS = [
|
|
158
|
+
"maxMessageBytes",
|
|
159
|
+
"maxConcurrentRequests",
|
|
160
|
+
"requestTimeoutMs",
|
|
161
|
+
"keepAliveMs",
|
|
162
|
+
"idleTimeoutMs",
|
|
163
|
+
"sessionLifetimeMs",
|
|
164
|
+
"heartbeatMs"
|
|
165
|
+
];
|
|
166
|
+
function isAppName(value) {
|
|
167
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= MAX_APP_NAME && ![...value].some((ch) => {
|
|
168
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
169
|
+
return c < 32 || c >= 127 && c <= 159;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function validateSite(input) {
|
|
173
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
174
|
+
throw new ConfigError("a site must be an object with an origin");
|
|
175
|
+
}
|
|
176
|
+
const site = input;
|
|
177
|
+
for (const key of Object.keys(site)) {
|
|
178
|
+
if (!SITE_KEYS.has(key))
|
|
179
|
+
throw new ConfigError(`unknown site key: ${key}`);
|
|
180
|
+
}
|
|
181
|
+
if (typeof site["origin"] !== "string") {
|
|
182
|
+
throw new ConfigError("a site needs an origin");
|
|
183
|
+
}
|
|
184
|
+
const out = { origin: normalizeOrigin(site["origin"]) };
|
|
185
|
+
if (site["name"] !== undefined) {
|
|
186
|
+
if (!isAppName(site["name"])) {
|
|
187
|
+
throw new ConfigError(`site name must be 1-${MAX_APP_NAME} characters: ${out.origin}`);
|
|
188
|
+
}
|
|
189
|
+
out.name = site["name"];
|
|
190
|
+
}
|
|
191
|
+
const pairing = site["pairing"];
|
|
192
|
+
if (pairing !== undefined && pairing !== "code") {
|
|
193
|
+
if (pairing === "oidc") {
|
|
194
|
+
throw new ConfigError(`pairing "oidc" (identity binding) is planned but not available yet: ${out.origin}`);
|
|
195
|
+
}
|
|
196
|
+
throw new ConfigError(`pairing must be "code": ${out.origin}`);
|
|
197
|
+
}
|
|
198
|
+
out.pairing = "code";
|
|
199
|
+
const limits = site["limits"];
|
|
200
|
+
if (limits !== undefined) {
|
|
201
|
+
if (typeof limits !== "object" || limits === null) {
|
|
202
|
+
throw new ConfigError(`site limits must be an object: ${out.origin}`);
|
|
203
|
+
}
|
|
204
|
+
const given = limits;
|
|
205
|
+
const parsed = {};
|
|
206
|
+
for (const [key, value] of Object.entries(given)) {
|
|
207
|
+
if (!LIMIT_KEYS.includes(key)) {
|
|
208
|
+
throw new ConfigError(`unknown site limit: ${key}`);
|
|
209
|
+
}
|
|
210
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
211
|
+
throw new ConfigError(`site limit ${key} must be a positive number`);
|
|
212
|
+
}
|
|
213
|
+
parsed[key] = value;
|
|
214
|
+
}
|
|
215
|
+
if (parsed.maxMessageBytes !== undefined && parsed.maxMessageBytes < MIN_MESSAGE_BYTES) {
|
|
216
|
+
throw new ConfigError(`site limit maxMessageBytes must be at least ${MIN_MESSAGE_BYTES}`);
|
|
217
|
+
}
|
|
218
|
+
out.limits = parsed;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
function defaultLimits(config) {
|
|
223
|
+
return {
|
|
224
|
+
maxMessageBytes: config.maxMessageBytes,
|
|
225
|
+
maxConcurrentRequests: config.maxConcurrentRequests,
|
|
226
|
+
requestTimeoutMs: config.requestTimeoutMs,
|
|
227
|
+
keepAliveMs: config.keepAliveMs,
|
|
228
|
+
idleTimeoutMs: config.idleTimeoutMs,
|
|
229
|
+
sessionLifetimeMs: config.sessionLifetimeMs,
|
|
230
|
+
heartbeatMs: config.heartbeatMs
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function siteFor(config, origin) {
|
|
234
|
+
const defaults = defaultLimits(config);
|
|
235
|
+
const configured = origin === null ? undefined : config.sites.find((s) => s.origin === origin);
|
|
236
|
+
if (configured !== undefined) {
|
|
237
|
+
const site = {
|
|
238
|
+
origin: configured.origin,
|
|
239
|
+
pairing: configured.pairing ?? "code",
|
|
240
|
+
limits: { ...defaults, ...configured.limits }
|
|
241
|
+
};
|
|
242
|
+
if (configured.name !== undefined)
|
|
243
|
+
site.name = configured.name;
|
|
244
|
+
return site;
|
|
245
|
+
}
|
|
246
|
+
if (config.allowedOrigins === "*" || origin !== null && config.allowedOrigins.includes(origin)) {
|
|
247
|
+
return {
|
|
248
|
+
origin: config.allowedOrigins === "*" ? "*" : origin ?? "",
|
|
249
|
+
pairing: "code",
|
|
250
|
+
limits: defaults
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
function largestMessageBytes(config) {
|
|
256
|
+
return Math.max(config.maxMessageBytes, ...config.sites.map((s) => s.limits?.maxMessageBytes ?? 0));
|
|
257
|
+
}
|
|
258
|
+
function seconds(env, name) {
|
|
259
|
+
const raw = env[name];
|
|
260
|
+
if (raw === undefined || raw === "")
|
|
261
|
+
return;
|
|
262
|
+
const n = Number(raw);
|
|
263
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
264
|
+
throw new ConfigError(`${name} must be a positive number of seconds`);
|
|
265
|
+
}
|
|
266
|
+
return Math.round(n * 1000);
|
|
267
|
+
}
|
|
268
|
+
function integer(env, name) {
|
|
269
|
+
const raw = env[name];
|
|
270
|
+
if (raw === undefined || raw === "")
|
|
271
|
+
return;
|
|
272
|
+
const n = Number(raw);
|
|
273
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
274
|
+
throw new ConfigError(`${name} must be a whole number`);
|
|
275
|
+
}
|
|
276
|
+
return n;
|
|
277
|
+
}
|
|
278
|
+
function oneOf(env, name, values) {
|
|
279
|
+
const raw = env[name];
|
|
280
|
+
if (raw === undefined || raw === "")
|
|
281
|
+
return;
|
|
282
|
+
if (!values.includes(raw)) {
|
|
283
|
+
throw new ConfigError(`${name} must be one of ${values.join(", ")}`);
|
|
284
|
+
}
|
|
285
|
+
return raw;
|
|
286
|
+
}
|
|
287
|
+
function configFromEnv(env) {
|
|
288
|
+
const out = {};
|
|
289
|
+
const set = (key, value) => {
|
|
290
|
+
if (value !== undefined)
|
|
291
|
+
out[key] = value;
|
|
292
|
+
};
|
|
293
|
+
set("development", env["NODE_ENV"] === "development" ? true : undefined);
|
|
294
|
+
const publicUrl = env["TAB_MCP_PUBLIC_URL"];
|
|
295
|
+
if (publicUrl !== undefined && publicUrl !== "")
|
|
296
|
+
out.publicUrl = publicUrl;
|
|
297
|
+
set("port", integer(env, "TAB_MCP_PORT"));
|
|
298
|
+
const host = env["TAB_MCP_HOST"];
|
|
299
|
+
if (host !== undefined && host !== "")
|
|
300
|
+
out.host = host;
|
|
301
|
+
const origins = env["TAB_MCP_ALLOWED_ORIGINS"];
|
|
302
|
+
if (origins !== undefined) {
|
|
303
|
+
const trimmed = origins.trim();
|
|
304
|
+
out.allowedOrigins = trimmed === "*" ? "*" : trimmed.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
305
|
+
}
|
|
306
|
+
const sites = env["TAB_MCP_SITES"];
|
|
307
|
+
if (sites !== undefined && sites.trim() !== "") {
|
|
308
|
+
let parsed;
|
|
309
|
+
try {
|
|
310
|
+
parsed = JSON.parse(sites);
|
|
311
|
+
} catch {
|
|
312
|
+
throw new ConfigError("TAB_MCP_SITES must be a JSON array of sites");
|
|
313
|
+
}
|
|
314
|
+
if (!Array.isArray(parsed)) {
|
|
315
|
+
throw new ConfigError("TAB_MCP_SITES must be a JSON array of sites");
|
|
316
|
+
}
|
|
317
|
+
out.sites = parsed;
|
|
318
|
+
}
|
|
319
|
+
const hosts = env["TAB_MCP_CLIENT_ID_HOSTS"];
|
|
320
|
+
if (hosts !== undefined && hosts.trim() !== "") {
|
|
321
|
+
out.clientIdHosts = hosts.split(",").map((h) => h.trim()).filter((h) => h !== "");
|
|
322
|
+
}
|
|
323
|
+
set("trustProxy", oneOf(env, "TAB_MCP_TRUST_PROXY", [
|
|
324
|
+
"none",
|
|
325
|
+
"x-real-ip",
|
|
326
|
+
"x-forwarded-for"
|
|
327
|
+
]));
|
|
328
|
+
const cors = oneOf(env, "TAB_MCP_CORS", ["on", "off"]);
|
|
329
|
+
if (cors !== undefined)
|
|
330
|
+
out.cors = cors === "on";
|
|
331
|
+
set("store", oneOf(env, "TAB_MCP_STORE", ["sqlite", "memory"]));
|
|
332
|
+
const sqlitePath = env["TAB_MCP_SQLITE_PATH"];
|
|
333
|
+
if (sqlitePath !== undefined && sqlitePath !== "")
|
|
334
|
+
out.sqlitePath = sqlitePath;
|
|
335
|
+
set("maxSessions", integer(env, "TAB_MCP_MAX_SESSIONS"));
|
|
336
|
+
set("maxSessionsPerIp", integer(env, "TAB_MCP_MAX_SESSIONS_PER_IP"));
|
|
337
|
+
set("maxConcurrentRequests", integer(env, "TAB_MCP_MAX_CONCURRENT_REQUESTS"));
|
|
338
|
+
set("requestTimeoutMs", seconds(env, "TAB_MCP_REQUEST_TIMEOUT_S"));
|
|
339
|
+
set("keepAliveMs", seconds(env, "TAB_MCP_KEEPALIVE_S"));
|
|
340
|
+
set("idleTimeoutMs", seconds(env, "TAB_MCP_IDLE_TIMEOUT_S"));
|
|
341
|
+
set("sessionLifetimeMs", seconds(env, "TAB_MCP_SESSION_LIFETIME_S"));
|
|
342
|
+
set("heartbeatMs", seconds(env, "TAB_MCP_HEARTBEAT_S"));
|
|
343
|
+
set("bodyTimeoutMs", seconds(env, "TAB_MCP_BODY_TIMEOUT_S"));
|
|
344
|
+
set("maxMessageBytes", integer(env, "TAB_MCP_MAX_MESSAGE_BYTES"));
|
|
345
|
+
set("accessTokenTtlMs", seconds(env, "TAB_MCP_ACCESS_TOKEN_TTL_S"));
|
|
346
|
+
set("refreshTokenTtlMs", seconds(env, "TAB_MCP_REFRESH_TOKEN_TTL_S"));
|
|
347
|
+
set("grantLifetimeMs", seconds(env, "TAB_MCP_GRANT_LIFETIME_S"));
|
|
348
|
+
set("logLevel", oneOf(env, "TAB_MCP_LOG_LEVEL", [
|
|
349
|
+
"error",
|
|
350
|
+
"warn",
|
|
351
|
+
"info",
|
|
352
|
+
"debug"
|
|
353
|
+
]));
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
// src/ip.ts
|
|
357
|
+
function parseIpv4(ip) {
|
|
358
|
+
const parts = ip.split(".");
|
|
359
|
+
if (parts.length !== 4)
|
|
360
|
+
return;
|
|
361
|
+
const octets = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : -1);
|
|
362
|
+
return octets.every((o) => o >= 0 && o <= 255) ? octets : undefined;
|
|
363
|
+
}
|
|
364
|
+
function parseIpv6(ip) {
|
|
365
|
+
const address = ip.split("%")[0] ?? "";
|
|
366
|
+
const halves = address.split("::");
|
|
367
|
+
if (halves.length > 2)
|
|
368
|
+
return;
|
|
369
|
+
const parseSide = (side) => {
|
|
370
|
+
if (side === "")
|
|
371
|
+
return [];
|
|
372
|
+
const groups = [];
|
|
373
|
+
const items = side.split(":");
|
|
374
|
+
for (const [i, item] of items.entries()) {
|
|
375
|
+
if (i === items.length - 1 && item.includes(".")) {
|
|
376
|
+
const v4 = parseIpv4(item);
|
|
377
|
+
if (v4 === undefined)
|
|
378
|
+
return;
|
|
379
|
+
groups.push((v4[0] ?? 0) << 8 | (v4[1] ?? 0), (v4[2] ?? 0) << 8 | (v4[3] ?? 0));
|
|
380
|
+
} else if (/^[0-9a-fA-F]{1,4}$/.test(item)) {
|
|
381
|
+
groups.push(Number.parseInt(item, 16));
|
|
382
|
+
} else {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return groups;
|
|
387
|
+
};
|
|
388
|
+
const head = parseSide(halves[0] ?? "");
|
|
389
|
+
const tail = halves.length === 2 ? parseSide(halves[1] ?? "") : [];
|
|
390
|
+
if (head === undefined || tail === undefined)
|
|
391
|
+
return;
|
|
392
|
+
if (halves.length === 1)
|
|
393
|
+
return head.length === 8 ? head : undefined;
|
|
394
|
+
const missing = 8 - head.length - tail.length;
|
|
395
|
+
if (missing < 1)
|
|
396
|
+
return;
|
|
397
|
+
return [...head, ...new Array(missing).fill(0), ...tail];
|
|
398
|
+
}
|
|
399
|
+
var PRIVATE_V4 = [
|
|
400
|
+
[0, 0, 0, 0, 8],
|
|
401
|
+
[10, 0, 0, 0, 8],
|
|
402
|
+
[100, 64, 0, 0, 10],
|
|
403
|
+
[127, 0, 0, 0, 8],
|
|
404
|
+
[169, 254, 0, 0, 16],
|
|
405
|
+
[172, 16, 0, 0, 12],
|
|
406
|
+
[192, 0, 0, 0, 24],
|
|
407
|
+
[192, 0, 2, 0, 24],
|
|
408
|
+
[192, 88, 99, 0, 24],
|
|
409
|
+
[192, 168, 0, 0, 16],
|
|
410
|
+
[198, 18, 0, 0, 15],
|
|
411
|
+
[198, 51, 100, 0, 24],
|
|
412
|
+
[203, 0, 113, 0, 24],
|
|
413
|
+
[224, 0, 0, 0, 4],
|
|
414
|
+
[240, 0, 0, 0, 4]
|
|
415
|
+
];
|
|
416
|
+
function v4Number(o) {
|
|
417
|
+
return ((o[0] ?? 0) << 24 >>> 0) + ((o[1] ?? 0) << 16) + ((o[2] ?? 0) << 8) + (o[3] ?? 0);
|
|
418
|
+
}
|
|
419
|
+
function isPublicV4(octets) {
|
|
420
|
+
const n = v4Number(octets);
|
|
421
|
+
return PRIVATE_V4.every(([a, b, c, d, bits]) => {
|
|
422
|
+
const mask = bits === 0 ? 0 : 4294967295 << 32 - bits >>> 0;
|
|
423
|
+
return (n & mask) >>> 0 !== (v4Number([a, b, c, d]) & mask) >>> 0;
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
function isPublicV6(g) {
|
|
427
|
+
const [g0 = 0, g1 = 0] = g;
|
|
428
|
+
const embedded = [
|
|
429
|
+
(g[6] ?? 0) >> 8,
|
|
430
|
+
(g[6] ?? 0) & 255,
|
|
431
|
+
(g[7] ?? 0) >> 8,
|
|
432
|
+
(g[7] ?? 0) & 255
|
|
433
|
+
];
|
|
434
|
+
if (g.slice(0, 5).every((x) => x === 0) && (g[5] === 65535 || g[5] === 0)) {
|
|
435
|
+
return g[5] === 65535 && isPublicV4(embedded);
|
|
436
|
+
}
|
|
437
|
+
if (g0 === 100 && g1 === 65435 && g.slice(2, 6).every((x) => x === 0)) {
|
|
438
|
+
return isPublicV4(embedded);
|
|
439
|
+
}
|
|
440
|
+
if ((g0 & 57344) !== 8192)
|
|
441
|
+
return false;
|
|
442
|
+
if (g0 === 8193 && g1 < 512)
|
|
443
|
+
return false;
|
|
444
|
+
if (g0 === 8193 && g1 === 3512)
|
|
445
|
+
return false;
|
|
446
|
+
if (g0 === 8194)
|
|
447
|
+
return false;
|
|
448
|
+
if (g0 === 16383 && g1 < 4096)
|
|
449
|
+
return false;
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
function isPublicIp(ip) {
|
|
453
|
+
const v4 = parseIpv4(ip);
|
|
454
|
+
if (v4 !== undefined)
|
|
455
|
+
return isPublicV4(v4);
|
|
456
|
+
const v6 = parseIpv6(ip.replace(/^\[|\]$/g, ""));
|
|
457
|
+
if (v6 !== undefined)
|
|
458
|
+
return isPublicV6(v6);
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
function isLoopbackHost(hostname) {
|
|
462
|
+
const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
463
|
+
if (h === "localhost" || h.endsWith(".localhost"))
|
|
464
|
+
return true;
|
|
465
|
+
const v4 = parseIpv4(h);
|
|
466
|
+
if (v4 !== undefined)
|
|
467
|
+
return v4[0] === 127;
|
|
468
|
+
const v6 = parseIpv6(h);
|
|
469
|
+
return v6?.slice(0, 7).every((x) => x === 0) === true && v6[7] === 1;
|
|
470
|
+
}
|
|
471
|
+
function ipPrefixKey(ip) {
|
|
472
|
+
const v4 = parseIpv4(ip);
|
|
473
|
+
if (v4 !== undefined)
|
|
474
|
+
return v4.join(".");
|
|
475
|
+
const v6 = parseIpv6(ip.replace(/^\[|\]$/g, ""));
|
|
476
|
+
if (v6 === undefined)
|
|
477
|
+
return ip;
|
|
478
|
+
if (v6.slice(0, 5).every((g) => g === 0) && v6[5] === 65535) {
|
|
479
|
+
const hi = v6[6] ?? 0;
|
|
480
|
+
const lo = v6[7] ?? 0;
|
|
481
|
+
return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
482
|
+
}
|
|
483
|
+
return `${v6.slice(0, 4).map((g) => g.toString(16)).join(":")}::/64`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// src/log.ts
|
|
487
|
+
var ORDER = {
|
|
488
|
+
error: 0,
|
|
489
|
+
warn: 1,
|
|
490
|
+
info: 2,
|
|
491
|
+
debug: 3
|
|
492
|
+
};
|
|
493
|
+
function createLogger(level, sink = (line) => {
|
|
494
|
+
process.stdout.write(`${line}
|
|
495
|
+
`);
|
|
496
|
+
}) {
|
|
497
|
+
const max = ORDER[level];
|
|
498
|
+
const write = (lvl, event, fields) => {
|
|
499
|
+
if (ORDER[lvl] > max)
|
|
500
|
+
return;
|
|
501
|
+
sink(JSON.stringify({
|
|
502
|
+
time: new Date().toISOString(),
|
|
503
|
+
level: lvl,
|
|
504
|
+
event,
|
|
505
|
+
...fields
|
|
506
|
+
}));
|
|
507
|
+
};
|
|
508
|
+
return {
|
|
509
|
+
error: (e, f) => write("error", e, f),
|
|
510
|
+
warn: (e, f) => write("warn", e, f),
|
|
511
|
+
info: (e, f) => write("info", e, f),
|
|
512
|
+
debug: (e, f) => write("debug", e, f)
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function truncateIp(ip) {
|
|
516
|
+
const v4 = parseIpv4(ip);
|
|
517
|
+
if (v4 !== undefined)
|
|
518
|
+
return `${v4.slice(0, 3).join(".")}.0/24`;
|
|
519
|
+
const v6 = parseIpv6(ip);
|
|
520
|
+
if (v6 === undefined)
|
|
521
|
+
return "?";
|
|
522
|
+
const mapped = v6.slice(0, 5).every((g) => g === 0) && v6[5] === 65535;
|
|
523
|
+
if (mapped) {
|
|
524
|
+
const hi = v6[6] ?? 0;
|
|
525
|
+
return `${hi >> 8}.${hi & 255}.${(v6[7] ?? 0) >> 8}.0/24`;
|
|
526
|
+
}
|
|
527
|
+
return `${v6.slice(0, 3).map((g) => g.toString(16)).join(":")}::/48`;
|
|
528
|
+
}
|
|
529
|
+
function installCrashLogging(logger, exit = (code) => process.exit(code)) {
|
|
530
|
+
const name = (error) => error instanceof Error ? error.name : typeof error;
|
|
531
|
+
const onRejection = (error) => {
|
|
532
|
+
logger.error("process.unhandled_rejection", { error: name(error) });
|
|
533
|
+
};
|
|
534
|
+
const onException = (error) => {
|
|
535
|
+
logger.error("process.uncaught_exception", { error: name(error) });
|
|
536
|
+
exit(1);
|
|
537
|
+
};
|
|
538
|
+
process.on("unhandledRejection", onRejection);
|
|
539
|
+
process.on("uncaughtException", onException);
|
|
540
|
+
return () => {
|
|
541
|
+
process.off("unhandledRejection", onRejection);
|
|
542
|
+
process.off("uncaughtException", onException);
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
// src/protocol.ts
|
|
546
|
+
var SUBPROTOCOL = "tab-mcp.v1";
|
|
547
|
+
var MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
548
|
+
var PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion";
|
|
549
|
+
var CloseCode = {
|
|
550
|
+
normal: 1000,
|
|
551
|
+
policyViolation: 1008,
|
|
552
|
+
tooLarge: 1009,
|
|
553
|
+
sessionExpired: 4001,
|
|
554
|
+
idleTimeout: 4002,
|
|
555
|
+
heartbeatLost: 4003,
|
|
556
|
+
shuttingDown: 4004,
|
|
557
|
+
replaced: 4005,
|
|
558
|
+
rateLimited: 4008
|
|
559
|
+
};
|
|
560
|
+
var MAX_TITLE = 300;
|
|
561
|
+
var MAX_NAME = 200;
|
|
562
|
+
var MAX_ID = 128;
|
|
563
|
+
var MAX_HEADER_COUNT = 32;
|
|
564
|
+
var MAX_HEADER_VALUE = 8192;
|
|
565
|
+
var FIELD_VALUE = /^[\t\x20-\x7E\x80-\xFF]*$/;
|
|
566
|
+
function isObject(v) {
|
|
567
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
568
|
+
}
|
|
569
|
+
function isString(v, max) {
|
|
570
|
+
return typeof v === "string" && v.length <= max;
|
|
571
|
+
}
|
|
572
|
+
function isId(v) {
|
|
573
|
+
return typeof v === "string" && v.length > 0 && v.length <= MAX_ID;
|
|
574
|
+
}
|
|
575
|
+
function isStatus(v) {
|
|
576
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 200 && v <= 599;
|
|
577
|
+
}
|
|
578
|
+
function isHeaders(v) {
|
|
579
|
+
if (!isObject(v))
|
|
580
|
+
return false;
|
|
581
|
+
const entries = Object.entries(v);
|
|
582
|
+
if (entries.length > MAX_HEADER_COUNT)
|
|
583
|
+
return false;
|
|
584
|
+
return entries.every(([k, val]) => /^[a-z0-9-]{1,64}$/.test(k) && isString(val, MAX_HEADER_VALUE) && FIELD_VALUE.test(val));
|
|
585
|
+
}
|
|
586
|
+
function idOf(v) {
|
|
587
|
+
return isId(v["id"]) ? v["id"] : undefined;
|
|
588
|
+
}
|
|
589
|
+
function parseTabMessage(text) {
|
|
590
|
+
let v;
|
|
591
|
+
try {
|
|
592
|
+
v = JSON.parse(text);
|
|
593
|
+
} catch {
|
|
594
|
+
return { ok: false, error: "not JSON" };
|
|
595
|
+
}
|
|
596
|
+
if (!isObject(v))
|
|
597
|
+
return { ok: false, error: "not a JSON object" };
|
|
598
|
+
const type = v["type"];
|
|
599
|
+
if (typeof type !== "string" || type.length === 0 || type.length > 64) {
|
|
600
|
+
return { ok: false, error: "missing type" };
|
|
601
|
+
}
|
|
602
|
+
const id = idOf(v);
|
|
603
|
+
const bad = (error) => id === undefined ? { ok: false, error } : { ok: false, error, id };
|
|
604
|
+
switch (type) {
|
|
605
|
+
case "hello": {
|
|
606
|
+
const library = v["library"];
|
|
607
|
+
const page = v["page"];
|
|
608
|
+
if (!isObject(library) || !isString(library["name"], MAX_NAME) || !isString(library["version"], MAX_NAME)) {
|
|
609
|
+
return bad("hello.library must be {name, version}");
|
|
610
|
+
}
|
|
611
|
+
if (!isObject(page) || !isString(page["title"], MAX_TITLE)) {
|
|
612
|
+
return bad("hello.page must be {title}");
|
|
613
|
+
}
|
|
614
|
+
if (typeof v["secret"] !== "string")
|
|
615
|
+
return bad("hello.secret missing");
|
|
616
|
+
const app = v["app"];
|
|
617
|
+
if (app !== undefined && (!isObject(app) || !isAppName(app["name"]))) {
|
|
618
|
+
return bad(`hello.app.name must be 1-${MAX_APP_NAME} characters`);
|
|
619
|
+
}
|
|
620
|
+
const hello = {
|
|
621
|
+
type,
|
|
622
|
+
library: { name: library["name"], version: library["version"] },
|
|
623
|
+
page: { title: page["title"] },
|
|
624
|
+
secret: v["secret"]
|
|
625
|
+
};
|
|
626
|
+
if (isObject(app) && isAppName(app["name"])) {
|
|
627
|
+
hello.app = { name: app["name"] };
|
|
628
|
+
}
|
|
629
|
+
return { ok: true, message: hello };
|
|
630
|
+
}
|
|
631
|
+
case "pong": {
|
|
632
|
+
const t = v["t"];
|
|
633
|
+
if (typeof t !== "number")
|
|
634
|
+
return bad("pong.t must be a number");
|
|
635
|
+
return { ok: true, message: { type, t } };
|
|
636
|
+
}
|
|
637
|
+
case "bye": {
|
|
638
|
+
const reason = v["reason"];
|
|
639
|
+
return {
|
|
640
|
+
ok: true,
|
|
641
|
+
message: {
|
|
642
|
+
type,
|
|
643
|
+
reason: isString(reason, MAX_NAME) ? reason : ""
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
case "http.response": {
|
|
648
|
+
if (id === undefined)
|
|
649
|
+
return bad("http.response.id missing");
|
|
650
|
+
const { status, headers, body } = v;
|
|
651
|
+
if (!isStatus(status))
|
|
652
|
+
return bad("http.response.status invalid");
|
|
653
|
+
if (!isHeaders(headers))
|
|
654
|
+
return bad("http.response.headers invalid");
|
|
655
|
+
if (typeof body !== "string")
|
|
656
|
+
return bad("http.response.body invalid");
|
|
657
|
+
return { ok: true, message: { type, id, status, headers, body } };
|
|
658
|
+
}
|
|
659
|
+
case "http.response.start": {
|
|
660
|
+
if (id === undefined)
|
|
661
|
+
return bad("http.response.start.id missing");
|
|
662
|
+
const { status, headers } = v;
|
|
663
|
+
if (!isStatus(status))
|
|
664
|
+
return bad("http.response.start.status invalid");
|
|
665
|
+
if (!isHeaders(headers))
|
|
666
|
+
return bad("http.response.start.headers invalid");
|
|
667
|
+
return { ok: true, message: { type, id, status, headers } };
|
|
668
|
+
}
|
|
669
|
+
case "http.response.chunk": {
|
|
670
|
+
if (id === undefined)
|
|
671
|
+
return bad("http.response.chunk.id missing");
|
|
672
|
+
const data = v["data"];
|
|
673
|
+
if (typeof data !== "string")
|
|
674
|
+
return bad("http.response.chunk.data invalid");
|
|
675
|
+
return { ok: true, message: { type, id, data } };
|
|
676
|
+
}
|
|
677
|
+
case "http.response.end": {
|
|
678
|
+
if (id === undefined)
|
|
679
|
+
return bad("http.response.end.id missing");
|
|
680
|
+
return { ok: true, message: { type, id } };
|
|
681
|
+
}
|
|
682
|
+
case "pairing.start":
|
|
683
|
+
case "pairing.stop":
|
|
684
|
+
case "grants.revokeAll":
|
|
685
|
+
return { ok: true, message: { type } };
|
|
686
|
+
case "grant.revoke": {
|
|
687
|
+
const grantId = v["grantId"];
|
|
688
|
+
if (!isId(grantId))
|
|
689
|
+
return bad("grant.revoke.grantId missing");
|
|
690
|
+
return { ok: true, message: { type, grantId } };
|
|
691
|
+
}
|
|
692
|
+
case "error": {
|
|
693
|
+
const code = v["code"];
|
|
694
|
+
const message = v["message"];
|
|
695
|
+
if (!isString(code, MAX_NAME))
|
|
696
|
+
return bad("error.code invalid");
|
|
697
|
+
const m = {
|
|
698
|
+
type,
|
|
699
|
+
code,
|
|
700
|
+
message: isString(message, 1000) ? message : ""
|
|
701
|
+
};
|
|
702
|
+
if (id !== undefined)
|
|
703
|
+
m.id = id;
|
|
704
|
+
return { ok: true, message: m };
|
|
705
|
+
}
|
|
706
|
+
default:
|
|
707
|
+
return id === undefined ? { ok: true, unknown: true, type } : { ok: true, unknown: true, type, id };
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// package.json
|
|
711
|
+
var version = "0.1.0-alpha.1";
|
|
712
|
+
|
|
713
|
+
// src/exchange.ts
|
|
714
|
+
var RelayErrorCode = {
|
|
715
|
+
pageNotOpen: -31001,
|
|
716
|
+
timeout: -31002,
|
|
717
|
+
tabGone: -31003,
|
|
718
|
+
overloaded: -31004,
|
|
719
|
+
sendFailed: -31005,
|
|
720
|
+
tooLarge: -31006,
|
|
721
|
+
badResponse: -31007,
|
|
722
|
+
storeUnavailable: -31008
|
|
723
|
+
};
|
|
724
|
+
var encoder = new TextEncoder;
|
|
725
|
+
var NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
|
726
|
+
var PASSED_RESPONSE_HEADERS = new Set(["content-type", "cache-control"]);
|
|
727
|
+
function isEventStream(headers) {
|
|
728
|
+
return (headers["content-type"] ?? "").toLowerCase().startsWith("text/event-stream");
|
|
729
|
+
}
|
|
730
|
+
function endsAtBoundary(text) {
|
|
731
|
+
return text.endsWith(`
|
|
732
|
+
|
|
733
|
+
`) || text.endsWith(`\r
|
|
734
|
+
\r
|
|
735
|
+
`) || text.endsWith("\r\r");
|
|
736
|
+
}
|
|
737
|
+
function sseEvent(data) {
|
|
738
|
+
const lines = data.split(/\r\n|\r|\n/);
|
|
739
|
+
return `event: message
|
|
740
|
+
${lines.map((l) => `data: ${l}`).join(`
|
|
741
|
+
`)}
|
|
742
|
+
|
|
743
|
+
`;
|
|
744
|
+
}
|
|
745
|
+
function rpcErrorBody(rpcId, code, message, data) {
|
|
746
|
+
const error = { code, message };
|
|
747
|
+
if (data !== undefined)
|
|
748
|
+
error["data"] = data;
|
|
749
|
+
return JSON.stringify({ jsonrpc: "2.0", id: rpcId ?? null, error });
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
class Exchange {
|
|
753
|
+
id;
|
|
754
|
+
isListen;
|
|
755
|
+
response;
|
|
756
|
+
status = 0;
|
|
757
|
+
outcome = "pending";
|
|
758
|
+
badResponseReason;
|
|
759
|
+
#options;
|
|
760
|
+
#resolve;
|
|
761
|
+
#state = "waiting";
|
|
762
|
+
#relayStarted = false;
|
|
763
|
+
#tabStarted = false;
|
|
764
|
+
#wrap = false;
|
|
765
|
+
#wrapped = "";
|
|
766
|
+
#controller;
|
|
767
|
+
#atBoundary = true;
|
|
768
|
+
#bodyBytes = 0;
|
|
769
|
+
#startTimer;
|
|
770
|
+
#requestTimer;
|
|
771
|
+
#keepAliveTimer;
|
|
772
|
+
constructor(options) {
|
|
773
|
+
this.#options = options;
|
|
774
|
+
this.id = options.id;
|
|
775
|
+
this.isListen = options.isListen;
|
|
776
|
+
this.response = new Promise((resolve) => {
|
|
777
|
+
this.#resolve = resolve;
|
|
778
|
+
});
|
|
779
|
+
if (options.rpcId !== undefined) {
|
|
780
|
+
this.#startTimer = setTimeout(() => this.#startRelayStream(), options.keepAliveMs);
|
|
781
|
+
}
|
|
782
|
+
if (!options.isListen) {
|
|
783
|
+
this.#requestTimer = setTimeout(() => this.#timeout(), options.requestTimeoutMs);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
get done() {
|
|
787
|
+
return this.#state === "done";
|
|
788
|
+
}
|
|
789
|
+
onResponse(msg) {
|
|
790
|
+
if (this.#tabStarted) {
|
|
791
|
+
this.#badResponse("http.response after http.response.start");
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (this.#state === "waiting") {
|
|
795
|
+
if (!this.#account(msg.body))
|
|
796
|
+
return;
|
|
797
|
+
if (NULL_BODY_STATUSES.has(msg.status) && msg.body !== "") {
|
|
798
|
+
this.#badResponse(`status ${msg.status} must not have a body`);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
let response;
|
|
802
|
+
try {
|
|
803
|
+
response = new Response(msg.body === "" ? null : msg.body, {
|
|
804
|
+
status: msg.status,
|
|
805
|
+
headers: this.#headers(msg.headers)
|
|
806
|
+
});
|
|
807
|
+
} catch {
|
|
808
|
+
this.#badResponse("the response could not be built");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
this.#respond(response, msg.status);
|
|
812
|
+
this.#finish("ok");
|
|
813
|
+
} else if (this.#state === "streaming" && this.#relayStarted) {
|
|
814
|
+
if (!this.#account(msg.body))
|
|
815
|
+
return;
|
|
816
|
+
if (msg.body !== "")
|
|
817
|
+
this.#write(sseEvent(msg.body));
|
|
818
|
+
this.#finish("ok");
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
onStart(msg) {
|
|
822
|
+
if (this.#tabStarted) {
|
|
823
|
+
this.#badResponse("http.response.start sent twice");
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
this.#tabStarted = true;
|
|
827
|
+
if (this.#state === "waiting") {
|
|
828
|
+
if (NULL_BODY_STATUSES.has(msg.status)) {
|
|
829
|
+
this.#badResponse(`status ${msg.status} cannot be streamed`);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
let headers;
|
|
833
|
+
try {
|
|
834
|
+
headers = this.#headers(msg.headers);
|
|
835
|
+
} catch {
|
|
836
|
+
this.#badResponse("the response headers are invalid");
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
this.#openStream(msg.status, headers);
|
|
840
|
+
} else if (this.#state === "streaming" && this.#relayStarted) {
|
|
841
|
+
this.#wrap = !isEventStream(msg.headers);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
onChunk(data) {
|
|
845
|
+
if (this.#state !== "streaming")
|
|
846
|
+
return;
|
|
847
|
+
if (!this.#account(data))
|
|
848
|
+
return;
|
|
849
|
+
if (this.#wrap)
|
|
850
|
+
this.#wrapped += data;
|
|
851
|
+
else
|
|
852
|
+
this.#write(data);
|
|
853
|
+
}
|
|
854
|
+
onEnd() {
|
|
855
|
+
if (this.#state !== "streaming")
|
|
856
|
+
return;
|
|
857
|
+
if (this.#wrap && this.#wrapped !== "")
|
|
858
|
+
this.#write(sseEvent(this.#wrapped));
|
|
859
|
+
this.#finish("ok");
|
|
860
|
+
}
|
|
861
|
+
clientClosed() {
|
|
862
|
+
if (this.#state === "done")
|
|
863
|
+
return;
|
|
864
|
+
if (this.#state === "waiting")
|
|
865
|
+
this.#respond(new Response(null, { status: 503 }), 0);
|
|
866
|
+
this.#options.cancel("client_closed");
|
|
867
|
+
this.#finish("client_closed", false);
|
|
868
|
+
}
|
|
869
|
+
fail(status, code, message, cancelTab) {
|
|
870
|
+
if (this.#state === "done")
|
|
871
|
+
return;
|
|
872
|
+
if (this.#state === "waiting") {
|
|
873
|
+
this.#respond(this.#errorResponse(status, code, message), status);
|
|
874
|
+
} else if (this.#atBoundary && this.#options.rpcId !== undefined) {
|
|
875
|
+
this.#write(sseEvent(rpcErrorBody(this.#options.rpcId, code, message)));
|
|
876
|
+
}
|
|
877
|
+
if (cancelTab)
|
|
878
|
+
this.#options.cancel("error");
|
|
879
|
+
this.#finish(`error_${code}`);
|
|
880
|
+
}
|
|
881
|
+
#badResponse(reason) {
|
|
882
|
+
this.badResponseReason = reason;
|
|
883
|
+
this.fail(502, RelayErrorCode.badResponse, "The page sent an invalid response.", true);
|
|
884
|
+
}
|
|
885
|
+
#account(data) {
|
|
886
|
+
this.#bodyBytes += Buffer.byteLength(data);
|
|
887
|
+
if (this.isListen || this.#bodyBytes <= this.#options.maxBodyBytes) {
|
|
888
|
+
return true;
|
|
889
|
+
}
|
|
890
|
+
this.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
#timeout() {
|
|
894
|
+
if (this.#state === "done")
|
|
895
|
+
return;
|
|
896
|
+
this.fail(504, RelayErrorCode.timeout, "The page did not answer in time.", false);
|
|
897
|
+
this.#options.cancel("timeout");
|
|
898
|
+
}
|
|
899
|
+
#startRelayStream() {
|
|
900
|
+
if (this.#state !== "waiting")
|
|
901
|
+
return;
|
|
902
|
+
this.#relayStarted = true;
|
|
903
|
+
this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }));
|
|
904
|
+
}
|
|
905
|
+
#openStream(status, headers) {
|
|
906
|
+
const stream = new ReadableStream({
|
|
907
|
+
start: (controller) => {
|
|
908
|
+
this.#controller = controller;
|
|
909
|
+
},
|
|
910
|
+
cancel: () => this.clientClosed()
|
|
911
|
+
}, new ByteLengthQueuingStrategy({
|
|
912
|
+
highWaterMark: this.#options.maxBodyBytes
|
|
913
|
+
}));
|
|
914
|
+
if (isEventStream(Object.fromEntries(headers))) {
|
|
915
|
+
headers.set("x-accel-buffering", "no");
|
|
916
|
+
if (!headers.has("cache-control"))
|
|
917
|
+
headers.set("cache-control", "no-cache");
|
|
918
|
+
}
|
|
919
|
+
let response;
|
|
920
|
+
try {
|
|
921
|
+
response = new Response(stream, { status, headers });
|
|
922
|
+
} catch {
|
|
923
|
+
this.#badResponse("the response could not be built");
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
this.#state = "streaming";
|
|
927
|
+
this.#respond(response, status);
|
|
928
|
+
this.#scheduleKeepAlive();
|
|
929
|
+
}
|
|
930
|
+
#write(text) {
|
|
931
|
+
if (this.#state !== "streaming" || this.#controller === undefined || text === "")
|
|
932
|
+
return;
|
|
933
|
+
try {
|
|
934
|
+
this.#controller.enqueue(encoder.encode(text));
|
|
935
|
+
} catch {
|
|
936
|
+
this.clientClosed();
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
this.#atBoundary = endsAtBoundary(text);
|
|
940
|
+
const room = this.#controller.desiredSize;
|
|
941
|
+
if (room !== null && room < 0) {
|
|
942
|
+
this.#options.cancel("error");
|
|
943
|
+
this.#finish("backpressure");
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
this.#scheduleKeepAlive();
|
|
947
|
+
}
|
|
948
|
+
#scheduleKeepAlive() {
|
|
949
|
+
clearTimeout(this.#keepAliveTimer);
|
|
950
|
+
if (this.#state !== "streaming" || this.#wrap) {
|
|
951
|
+
if (this.#state === "streaming") {
|
|
952
|
+
this.#keepAliveTimer = setTimeout(() => {
|
|
953
|
+
this.#write(`: keep-alive
|
|
954
|
+
|
|
955
|
+
`);
|
|
956
|
+
}, this.#options.keepAliveMs);
|
|
957
|
+
}
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
this.#keepAliveTimer = setTimeout(() => {
|
|
961
|
+
if (this.#atBoundary)
|
|
962
|
+
this.#write(`: keep-alive
|
|
963
|
+
|
|
964
|
+
`);
|
|
965
|
+
else
|
|
966
|
+
this.#scheduleKeepAlive();
|
|
967
|
+
}, this.#options.keepAliveMs);
|
|
968
|
+
}
|
|
969
|
+
#headers(fromTab) {
|
|
970
|
+
const headers = new Headers(this.#options.extraHeaders);
|
|
971
|
+
for (const [k, v] of Object.entries(fromTab)) {
|
|
972
|
+
if (PASSED_RESPONSE_HEADERS.has(k))
|
|
973
|
+
headers.set(k, v);
|
|
974
|
+
}
|
|
975
|
+
return headers;
|
|
976
|
+
}
|
|
977
|
+
#errorResponse(status, code, message) {
|
|
978
|
+
const headers = this.#headers({ "content-type": "application/json" });
|
|
979
|
+
return new Response(rpcErrorBody(this.#options.rpcId, code, message), {
|
|
980
|
+
status,
|
|
981
|
+
headers
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
#respond(response, status) {
|
|
985
|
+
clearTimeout(this.#startTimer);
|
|
986
|
+
this.status = status;
|
|
987
|
+
this.#resolve(response);
|
|
988
|
+
}
|
|
989
|
+
#finish(outcome, closeStream = true) {
|
|
990
|
+
if (this.#state === "done")
|
|
991
|
+
return;
|
|
992
|
+
const wasStreaming = this.#state === "streaming";
|
|
993
|
+
this.#state = "done";
|
|
994
|
+
this.outcome = outcome;
|
|
995
|
+
clearTimeout(this.#startTimer);
|
|
996
|
+
clearTimeout(this.#requestTimer);
|
|
997
|
+
clearTimeout(this.#keepAliveTimer);
|
|
998
|
+
if (wasStreaming && closeStream) {
|
|
999
|
+
try {
|
|
1000
|
+
this.#controller?.close();
|
|
1001
|
+
} catch {}
|
|
1002
|
+
}
|
|
1003
|
+
this.#options.onDone(this);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/oauth/server.ts
|
|
1008
|
+
import { timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1009
|
+
|
|
1010
|
+
// src/ratelimit.ts
|
|
1011
|
+
class RateLimiter {
|
|
1012
|
+
#buckets = new Map;
|
|
1013
|
+
#capacity;
|
|
1014
|
+
#refill;
|
|
1015
|
+
#maxKeys;
|
|
1016
|
+
constructor(options) {
|
|
1017
|
+
this.#capacity = options.capacity;
|
|
1018
|
+
this.#refill = options.refillPerSecond;
|
|
1019
|
+
this.#maxKeys = options.maxKeys ?? 1e4;
|
|
1020
|
+
}
|
|
1021
|
+
take(key, now = Date.now()) {
|
|
1022
|
+
let bucket = this.#buckets.get(key);
|
|
1023
|
+
if (bucket === undefined) {
|
|
1024
|
+
if (this.#buckets.size >= this.#maxKeys) {
|
|
1025
|
+
const oldest = this.#buckets.keys().next().value;
|
|
1026
|
+
if (oldest !== undefined)
|
|
1027
|
+
this.#buckets.delete(oldest);
|
|
1028
|
+
}
|
|
1029
|
+
bucket = { tokens: this.#capacity, updatedAt: now };
|
|
1030
|
+
this.#buckets.set(key, bucket);
|
|
1031
|
+
} else {
|
|
1032
|
+
const elapsed = Math.max(0, now - bucket.updatedAt) / 1000;
|
|
1033
|
+
bucket.tokens = Math.min(this.#capacity, bucket.tokens + elapsed * this.#refill);
|
|
1034
|
+
bucket.updatedAt = now;
|
|
1035
|
+
}
|
|
1036
|
+
if (bucket.tokens < 1)
|
|
1037
|
+
return false;
|
|
1038
|
+
bucket.tokens -= 1;
|
|
1039
|
+
return true;
|
|
1040
|
+
}
|
|
1041
|
+
sweep(now = Date.now()) {
|
|
1042
|
+
for (const [key, b] of this.#buckets) {
|
|
1043
|
+
const elapsed = Math.max(0, now - b.updatedAt) / 1000;
|
|
1044
|
+
if (b.tokens + elapsed * this.#refill >= this.#capacity) {
|
|
1045
|
+
this.#buckets.delete(key);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// src/tokens.ts
|
|
1052
|
+
var PREFIX = {
|
|
1053
|
+
access: "tmcp_at_",
|
|
1054
|
+
refresh: "tmcp_rt_"
|
|
1055
|
+
};
|
|
1056
|
+
function random(bytes) {
|
|
1057
|
+
return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
|
|
1058
|
+
}
|
|
1059
|
+
function newToken(kind) {
|
|
1060
|
+
return PREFIX[kind] + random(32);
|
|
1061
|
+
}
|
|
1062
|
+
function newId(prefix) {
|
|
1063
|
+
return `${prefix}_${random(12)}`;
|
|
1064
|
+
}
|
|
1065
|
+
function hashToken(token) {
|
|
1066
|
+
return new Bun.CryptoHasher("sha256").update(token).digest().toBase64({ alphabet: "base64url", omitPadding: true });
|
|
1067
|
+
}
|
|
1068
|
+
async function issueTokenPair(store, grant, options) {
|
|
1069
|
+
const now = options.now ?? Date.now();
|
|
1070
|
+
const accessToken = newToken("access");
|
|
1071
|
+
const refreshToken = newToken("refresh");
|
|
1072
|
+
const base = {
|
|
1073
|
+
grantId: grant.grantId,
|
|
1074
|
+
addressId: grant.addressId,
|
|
1075
|
+
clientId: grant.clientId,
|
|
1076
|
+
clientName: grant.clientName,
|
|
1077
|
+
scopes: grant.scopes,
|
|
1078
|
+
grantCreatedAt: grant.grantCreatedAt,
|
|
1079
|
+
issuedAt: now
|
|
1080
|
+
};
|
|
1081
|
+
const accessExpires = Math.min(now + options.accessTtlMs, options.grantEndsAt);
|
|
1082
|
+
const refreshStored = await store.put({
|
|
1083
|
+
...base,
|
|
1084
|
+
hash: hashToken(refreshToken),
|
|
1085
|
+
kind: "refresh",
|
|
1086
|
+
expiresAt: Math.min(now + options.refreshTtlMs, options.grantEndsAt)
|
|
1087
|
+
});
|
|
1088
|
+
if (!refreshStored)
|
|
1089
|
+
return;
|
|
1090
|
+
const accessStored = await store.put({
|
|
1091
|
+
...base,
|
|
1092
|
+
hash: hashToken(accessToken),
|
|
1093
|
+
kind: "access",
|
|
1094
|
+
expiresAt: accessExpires
|
|
1095
|
+
});
|
|
1096
|
+
if (!accessStored)
|
|
1097
|
+
return;
|
|
1098
|
+
return {
|
|
1099
|
+
accessToken,
|
|
1100
|
+
refreshToken,
|
|
1101
|
+
expiresIn: Math.max(0, Math.floor((accessExpires - now) / 1000))
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// src/oauth/cimd.ts
|
|
1106
|
+
class ClientMetadataError extends Error {
|
|
1107
|
+
name = "ClientMetadataError";
|
|
1108
|
+
reason;
|
|
1109
|
+
constructor(reason, message) {
|
|
1110
|
+
super(message);
|
|
1111
|
+
this.reason = reason;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
var MAX_CLIENT_ID_LENGTH = 2048;
|
|
1115
|
+
var MAX_NAME_LENGTH = 64;
|
|
1116
|
+
var MAX_REDIRECT_URIS = 20;
|
|
1117
|
+
var MIN_CACHE_MS = 5 * 60000;
|
|
1118
|
+
var DEFAULT_CACHE_MS = 60 * 60000;
|
|
1119
|
+
var MAX_CACHE_MS = 24 * 60 * 60000;
|
|
1120
|
+
var MAX_CACHE_ENTRIES = 1000;
|
|
1121
|
+
async function defaultLookup(hostname) {
|
|
1122
|
+
const records = await Bun.dns.lookup(hostname, {});
|
|
1123
|
+
return records.map((r) => r.address);
|
|
1124
|
+
}
|
|
1125
|
+
var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]/gu;
|
|
1126
|
+
function displayName(raw) {
|
|
1127
|
+
let cleaned = raw.normalize("NFC").replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ").replace(/\p{Cf}/gu, "").replace(INVISIBLE, "");
|
|
1128
|
+
cleaned = cleaned.replace(/\s+/g, " ").trim();
|
|
1129
|
+
const chars = [...cleaned];
|
|
1130
|
+
return chars.length > MAX_NAME_LENGTH ? `${chars.slice(0, MAX_NAME_LENGTH - 1).join("")}\u2026` : cleaned;
|
|
1131
|
+
}
|
|
1132
|
+
function cacheMs(header) {
|
|
1133
|
+
if (header === null)
|
|
1134
|
+
return DEFAULT_CACHE_MS;
|
|
1135
|
+
const directives = header.toLowerCase().split(",").map((d) => d.trim());
|
|
1136
|
+
if (directives.includes("no-store") || directives.includes("no-cache")) {
|
|
1137
|
+
return MIN_CACHE_MS;
|
|
1138
|
+
}
|
|
1139
|
+
const maxAge = directives.map((d) => /^max-age=(\d+)$/.exec(d)?.[1]).find((v) => v !== undefined);
|
|
1140
|
+
if (maxAge === undefined)
|
|
1141
|
+
return DEFAULT_CACHE_MS;
|
|
1142
|
+
return Math.min(MAX_CACHE_MS, Math.max(MIN_CACHE_MS, Number(maxAge) * 1000));
|
|
1143
|
+
}
|
|
1144
|
+
function isIpLiteral(hostname) {
|
|
1145
|
+
const h = hostname.replace(/^\[|\]$/g, "");
|
|
1146
|
+
return parseIpv4(h) !== undefined || parseIpv6(h) !== undefined;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
class ClientMetadataFetcher {
|
|
1150
|
+
#cache = new Map;
|
|
1151
|
+
#perHost = new RateLimiter({ capacity: 5, refillPerSecond: 0.2 });
|
|
1152
|
+
#global = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
|
|
1153
|
+
#allowedHosts;
|
|
1154
|
+
#logger;
|
|
1155
|
+
#timeoutMs;
|
|
1156
|
+
#maxBytes;
|
|
1157
|
+
#lookup;
|
|
1158
|
+
#fetch;
|
|
1159
|
+
#testMode;
|
|
1160
|
+
#now;
|
|
1161
|
+
constructor(options = {}) {
|
|
1162
|
+
this.#allowedHosts = options.allowedHosts ?? [];
|
|
1163
|
+
this.#logger = options.logger;
|
|
1164
|
+
this.#timeoutMs = options.timeoutMs ?? 5000;
|
|
1165
|
+
this.#maxBytes = options.maxBytes ?? 65536;
|
|
1166
|
+
this.#lookup = options.lookup ?? defaultLookup;
|
|
1167
|
+
this.#fetch = options.fetch ?? fetch;
|
|
1168
|
+
this.#testMode = options.testMode;
|
|
1169
|
+
this.#now = options.now ?? Date.now;
|
|
1170
|
+
}
|
|
1171
|
+
async get(clientId) {
|
|
1172
|
+
const url = this.#checkUrl(clientId);
|
|
1173
|
+
const now = this.#now();
|
|
1174
|
+
const cached = this.#cache.get(clientId);
|
|
1175
|
+
if (cached !== undefined && cached.until > now)
|
|
1176
|
+
return cached.meta;
|
|
1177
|
+
if (!this.#perHost.take(url.host, now)) {
|
|
1178
|
+
throw new ClientMetadataError("rate_limited", "Too many requests for this AI app's details. Try again in a minute.");
|
|
1179
|
+
}
|
|
1180
|
+
if (!this.#global.take("all", now)) {
|
|
1181
|
+
throw new ClientMetadataError("rate_limited", "The relay is busy. Try again in a minute.");
|
|
1182
|
+
}
|
|
1183
|
+
const address = await this.#vettedAddress(url);
|
|
1184
|
+
const { meta, ttlMs } = await this.#fetchDocument(url, clientId, address);
|
|
1185
|
+
if (this.#cache.size >= MAX_CACHE_ENTRIES) {
|
|
1186
|
+
const oldest = this.#cache.keys().next().value;
|
|
1187
|
+
if (oldest !== undefined)
|
|
1188
|
+
this.#cache.delete(oldest);
|
|
1189
|
+
}
|
|
1190
|
+
this.#cache.delete(clientId);
|
|
1191
|
+
this.#cache.set(clientId, { meta, until: now + ttlMs });
|
|
1192
|
+
return meta;
|
|
1193
|
+
}
|
|
1194
|
+
#checkUrl(clientId) {
|
|
1195
|
+
const refuse = (message) => new ClientMetadataError("invalid_client_id", message);
|
|
1196
|
+
if (clientId.length === 0 || clientId.length > MAX_CLIENT_ID_LENGTH) {
|
|
1197
|
+
throw refuse("The AI app's client_id is missing or too long.");
|
|
1198
|
+
}
|
|
1199
|
+
let url;
|
|
1200
|
+
try {
|
|
1201
|
+
url = new URL(clientId);
|
|
1202
|
+
} catch {
|
|
1203
|
+
throw refuse("The AI app's client_id is not a URL.");
|
|
1204
|
+
}
|
|
1205
|
+
const test = this.#testMode !== undefined;
|
|
1206
|
+
const scheme = url.protocol === "https:" || test && url.protocol === "http:";
|
|
1207
|
+
if (!scheme) {
|
|
1208
|
+
throw refuse("The AI app's client_id must be an https:// URL.");
|
|
1209
|
+
}
|
|
1210
|
+
if (url.href !== clientId) {
|
|
1211
|
+
throw refuse("The AI app's client_id is not a canonical URL.");
|
|
1212
|
+
}
|
|
1213
|
+
if (url.username !== "" || url.password !== "" || url.hash !== "") {
|
|
1214
|
+
throw refuse("The AI app's client_id must not contain credentials or a fragment.");
|
|
1215
|
+
}
|
|
1216
|
+
if (url.pathname === "/" || url.pathname === "") {
|
|
1217
|
+
throw refuse("The AI app's client_id must have a path.");
|
|
1218
|
+
}
|
|
1219
|
+
if (url.port !== "" && !test) {
|
|
1220
|
+
throw refuse("The AI app's client_id must use the default port.");
|
|
1221
|
+
}
|
|
1222
|
+
if (this.#allowedHosts.length > 0 && !this.#allowedHosts.includes(url.host)) {
|
|
1223
|
+
throw new ClientMetadataError("host_not_allowed", `This relay does not accept AI apps from ${url.host}.`);
|
|
1224
|
+
}
|
|
1225
|
+
if (isIpLiteral(url.hostname) && !this.#addressAllowed(url.hostname)) {
|
|
1226
|
+
throw this.#notLoaded("private_address", url);
|
|
1227
|
+
}
|
|
1228
|
+
return url;
|
|
1229
|
+
}
|
|
1230
|
+
#addressAllowed(address) {
|
|
1231
|
+
if (isPublicIp(address))
|
|
1232
|
+
return true;
|
|
1233
|
+
return this.#testMode !== undefined && isLoopbackHost(address);
|
|
1234
|
+
}
|
|
1235
|
+
#notLoaded(reason, url, detail = reason) {
|
|
1236
|
+
this.#logger?.info("oauth.cimd_failed", { host: url.host, reason: detail });
|
|
1237
|
+
return new ClientMetadataError(reason, `The AI app's details could not be loaded from ${url.host}.`);
|
|
1238
|
+
}
|
|
1239
|
+
async#vettedAddress(url) {
|
|
1240
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
1241
|
+
if (isIpLiteral(hostname))
|
|
1242
|
+
return hostname;
|
|
1243
|
+
let addresses;
|
|
1244
|
+
try {
|
|
1245
|
+
addresses = await this.#lookup(hostname);
|
|
1246
|
+
} catch {
|
|
1247
|
+
throw this.#notLoaded("unresolvable", url);
|
|
1248
|
+
}
|
|
1249
|
+
if (addresses.length === 0 || !addresses.every((a) => this.#addressAllowed(a))) {
|
|
1250
|
+
throw this.#notLoaded("private_address", url);
|
|
1251
|
+
}
|
|
1252
|
+
return addresses.find((a) => parseIpv4(a) !== undefined) ?? addresses[0] ?? "";
|
|
1253
|
+
}
|
|
1254
|
+
async#fetchDocument(url, clientId, address) {
|
|
1255
|
+
const fail = (detail) => this.#notLoaded("fetch_failed", url, detail);
|
|
1256
|
+
const pinned = new URL(clientId);
|
|
1257
|
+
pinned.hostname = address.includes(":") ? `[${address}]` : address;
|
|
1258
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
1259
|
+
const tls = url.protocol === "https:" ? {
|
|
1260
|
+
tls: {
|
|
1261
|
+
...isIpLiteral(hostname) ? {} : { serverName: hostname },
|
|
1262
|
+
...this.#testMode?.ca === undefined ? {} : { ca: this.#testMode.ca }
|
|
1263
|
+
}
|
|
1264
|
+
} : {};
|
|
1265
|
+
let response;
|
|
1266
|
+
try {
|
|
1267
|
+
response = await this.#fetch(pinned.href, {
|
|
1268
|
+
method: "GET",
|
|
1269
|
+
redirect: "manual",
|
|
1270
|
+
headers: { accept: "application/json", host: url.host },
|
|
1271
|
+
signal: AbortSignal.timeout(this.#timeoutMs),
|
|
1272
|
+
...tls
|
|
1273
|
+
});
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
throw fail(`fetch_failed:${error instanceof Error ? error.name : "unknown"}`);
|
|
1276
|
+
}
|
|
1277
|
+
try {
|
|
1278
|
+
if (response.status !== 200)
|
|
1279
|
+
throw fail(`status_${response.status}`);
|
|
1280
|
+
const type = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
|
|
1281
|
+
if (type !== "application/json")
|
|
1282
|
+
throw fail("not_json");
|
|
1283
|
+
const text = await this.#readLimited(response, url);
|
|
1284
|
+
let doc;
|
|
1285
|
+
try {
|
|
1286
|
+
doc = JSON.parse(text);
|
|
1287
|
+
} catch {
|
|
1288
|
+
throw fail("bad_json");
|
|
1289
|
+
}
|
|
1290
|
+
const meta = validateDocument(doc, clientId, url);
|
|
1291
|
+
return { meta, ttlMs: cacheMs(response.headers.get("cache-control")) };
|
|
1292
|
+
} finally {
|
|
1293
|
+
if (!response.bodyUsed)
|
|
1294
|
+
response.body?.cancel().catch(() => {});
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
async#readLimited(response, url) {
|
|
1298
|
+
const tooLarge = () => this.#notLoaded("too_large", url);
|
|
1299
|
+
const length = Number(response.headers.get("content-length") ?? "0");
|
|
1300
|
+
if (length > this.#maxBytes)
|
|
1301
|
+
throw tooLarge();
|
|
1302
|
+
const reader = response.body?.getReader();
|
|
1303
|
+
if (reader === undefined)
|
|
1304
|
+
return "";
|
|
1305
|
+
const chunks = [];
|
|
1306
|
+
let total = 0;
|
|
1307
|
+
for (;; ) {
|
|
1308
|
+
const { done, value } = await reader.read();
|
|
1309
|
+
if (done)
|
|
1310
|
+
break;
|
|
1311
|
+
total += value.byteLength;
|
|
1312
|
+
if (total > this.#maxBytes) {
|
|
1313
|
+
await reader.cancel().catch(() => {});
|
|
1314
|
+
throw tooLarge();
|
|
1315
|
+
}
|
|
1316
|
+
chunks.push(value);
|
|
1317
|
+
}
|
|
1318
|
+
try {
|
|
1319
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
|
1320
|
+
} catch {
|
|
1321
|
+
throw this.#notLoaded("fetch_failed", url, "bad_utf8");
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
function validateDocument(doc, clientId, url) {
|
|
1326
|
+
const invalid = (message) => new ClientMetadataError("invalid_metadata", message);
|
|
1327
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
1328
|
+
throw invalid("The AI app's details are not a JSON object.");
|
|
1329
|
+
}
|
|
1330
|
+
const d = doc;
|
|
1331
|
+
if (d["client_id"] !== clientId) {
|
|
1332
|
+
throw invalid("The AI app's details name a different client_id.");
|
|
1333
|
+
}
|
|
1334
|
+
if (d["client_secret"] !== undefined || d["client_secret_expires_at"] !== undefined) {
|
|
1335
|
+
throw invalid("The AI app's details must not contain a client secret.");
|
|
1336
|
+
}
|
|
1337
|
+
const name = d["client_name"];
|
|
1338
|
+
if (typeof name !== "string" || displayName(name) === "") {
|
|
1339
|
+
throw invalid("The AI app's details have no client_name.");
|
|
1340
|
+
}
|
|
1341
|
+
if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(displayName(name))) {
|
|
1342
|
+
throw invalid("The AI app's name contains a web address in parentheses, which this relay does not show.");
|
|
1343
|
+
}
|
|
1344
|
+
const uris = d["redirect_uris"];
|
|
1345
|
+
if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS || !uris.every((u) => typeof u === "string" && isRedirectUri(u))) {
|
|
1346
|
+
throw invalid("The AI app's details have no valid redirect_uris.");
|
|
1347
|
+
}
|
|
1348
|
+
const method = d["token_endpoint_auth_method"];
|
|
1349
|
+
if (method !== undefined && method !== "none") {
|
|
1350
|
+
throw invalid('The AI app must use token_endpoint_auth_method "none" with this relay.');
|
|
1351
|
+
}
|
|
1352
|
+
const grants = d["grant_types"];
|
|
1353
|
+
if (grants !== undefined && (!Array.isArray(grants) || !grants.includes("authorization_code"))) {
|
|
1354
|
+
throw invalid("The AI app's details do not allow the authorization code grant.");
|
|
1355
|
+
}
|
|
1356
|
+
const responses = d["response_types"];
|
|
1357
|
+
if (responses !== undefined && (!Array.isArray(responses) || !responses.includes("code"))) {
|
|
1358
|
+
throw invalid(`The AI app's details do not allow response type "code".`);
|
|
1359
|
+
}
|
|
1360
|
+
return {
|
|
1361
|
+
clientId,
|
|
1362
|
+
name: displayName(name),
|
|
1363
|
+
host: url.host,
|
|
1364
|
+
redirectUris: uris
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
var PRIVATE_USE_SCHEME = /^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+:$/;
|
|
1368
|
+
function isRedirectUri(value) {
|
|
1369
|
+
if (value.length > 2048 || value.includes("#"))
|
|
1370
|
+
return false;
|
|
1371
|
+
let u;
|
|
1372
|
+
try {
|
|
1373
|
+
u = new URL(value);
|
|
1374
|
+
} catch {
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
if (u.username !== "" || u.password !== "")
|
|
1378
|
+
return false;
|
|
1379
|
+
if (u.protocol === "https:")
|
|
1380
|
+
return u.hostname !== "";
|
|
1381
|
+
if (u.protocol === "http:")
|
|
1382
|
+
return LOOPBACK_REDIRECT_HOSTS.has(u.hostname);
|
|
1383
|
+
return PRIVATE_USE_SCHEME.test(u.protocol);
|
|
1384
|
+
}
|
|
1385
|
+
var LOOPBACK_REDIRECT_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
1386
|
+
function redirectUriMatches(requested, registered) {
|
|
1387
|
+
if (requested === registered)
|
|
1388
|
+
return true;
|
|
1389
|
+
let a;
|
|
1390
|
+
let b;
|
|
1391
|
+
try {
|
|
1392
|
+
a = new URL(requested);
|
|
1393
|
+
b = new URL(registered);
|
|
1394
|
+
} catch {
|
|
1395
|
+
return false;
|
|
1396
|
+
}
|
|
1397
|
+
if (a.protocol !== "http:" || b.protocol !== "http:")
|
|
1398
|
+
return false;
|
|
1399
|
+
if (!LOOPBACK_REDIRECT_HOSTS.has(b.hostname) || a.hostname !== b.hostname) {
|
|
1400
|
+
return false;
|
|
1401
|
+
}
|
|
1402
|
+
if (a.href !== requested || a.username !== "" || a.password !== "" || a.hash !== "") {
|
|
1403
|
+
return false;
|
|
1404
|
+
}
|
|
1405
|
+
a.port = "";
|
|
1406
|
+
b.port = "";
|
|
1407
|
+
return a.href === b.href;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// src/oauth/html.ts
|
|
1411
|
+
var ESCAPES = {
|
|
1412
|
+
"&": "&",
|
|
1413
|
+
"<": "<",
|
|
1414
|
+
">": ">",
|
|
1415
|
+
'"': """,
|
|
1416
|
+
"'": "'"
|
|
1417
|
+
};
|
|
1418
|
+
function escapeHtml(text) {
|
|
1419
|
+
return text.replace(/[&<>"']/g, (c) => ESCAPES[c] ?? c);
|
|
1420
|
+
}
|
|
1421
|
+
var AUTHORIZE_CSS = `:root { color-scheme: light dark; --accent: #1d4ed8; --error: #b91c1c; }
|
|
1422
|
+
@media (prefers-color-scheme: dark) { :root { --accent: #93b4ff; --error: #fca5a5; } }
|
|
1423
|
+
* { box-sizing: border-box; }
|
|
1424
|
+
body { margin: 0; font: 1rem/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; background: Canvas; color: CanvasText; }
|
|
1425
|
+
main { max-width: 34rem; margin: 3rem auto; padding: 0 1rem; }
|
|
1426
|
+
h1 { font-size: 1.5rem; line-height: 1.25; margin: 0 0 1rem; }
|
|
1427
|
+
p { margin: 0 0 1rem; }
|
|
1428
|
+
code, .origin { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
|
|
1429
|
+
.help { color: GrayText; font-size: 0.9rem; }
|
|
1430
|
+
.error { color: var(--error); font-weight: 600; }
|
|
1431
|
+
label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
|
|
1432
|
+
input[type="text"] { font: 1.5rem/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.2em; width: 10ch; padding: 0.4rem 0.5rem; border: 2px solid GrayText; border-radius: 0.4rem; background: Field; color: FieldText; }
|
|
1433
|
+
input[type="text"]:focus-visible, button:focus-visible, a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
|
1434
|
+
.actions { display: flex; gap: 0.75rem; flex-wrap: wrap; margin: 1.5rem 0 1.25rem; }
|
|
1435
|
+
button, .button { font: inherit; padding: 0.5rem 1.25rem; border-radius: 0.4rem; border: 2px solid var(--accent); cursor: pointer; text-decoration: none; display: inline-block; }
|
|
1436
|
+
button.primary, .button.primary { background: var(--accent); color: Canvas; }
|
|
1437
|
+
button.secondary, .button:not(.primary) { background: transparent; color: var(--accent); }
|
|
1438
|
+
footer { margin-top: 2.5rem; font-size: 0.85rem; color: GrayText; }
|
|
1439
|
+
`;
|
|
1440
|
+
function pageHeaders(formTargets = []) {
|
|
1441
|
+
const formAction = ["'self'", ...formTargets].join(" ");
|
|
1442
|
+
return new Headers({
|
|
1443
|
+
"content-type": "text/html; charset=utf-8",
|
|
1444
|
+
"content-security-policy": `default-src 'none'; style-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action ${formAction}`,
|
|
1445
|
+
"x-frame-options": "DENY",
|
|
1446
|
+
"x-content-type-options": "nosniff",
|
|
1447
|
+
"referrer-policy": "same-origin",
|
|
1448
|
+
"cache-control": "no-store"
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
function renderPage(title, body) {
|
|
1452
|
+
return `<!doctype html>
|
|
1453
|
+
<html lang="en">
|
|
1454
|
+
<head>
|
|
1455
|
+
<meta charset="utf-8">
|
|
1456
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1457
|
+
<meta name="robots" content="noindex">
|
|
1458
|
+
<title>${escapeHtml(title)}</title>
|
|
1459
|
+
<link rel="stylesheet" href="/authorize.css">
|
|
1460
|
+
</head>
|
|
1461
|
+
<body>
|
|
1462
|
+
<main>
|
|
1463
|
+
${body}
|
|
1464
|
+
<footer>tab-mcp relay. It passes requests between AI tools and web pages and does not store their contents.</footer>
|
|
1465
|
+
</main>
|
|
1466
|
+
</body>
|
|
1467
|
+
</html>`;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// src/oauth/pairing.ts
|
|
1471
|
+
import { timingSafeEqual } from "crypto";
|
|
1472
|
+
var PAIRING_CODE_TTL_MS = 120000;
|
|
1473
|
+
var PAIRING_MAX_ATTEMPTS = 5;
|
|
1474
|
+
function newPairingCode() {
|
|
1475
|
+
const buffer = new Uint32Array(1);
|
|
1476
|
+
for (;; ) {
|
|
1477
|
+
crypto.getRandomValues(buffer);
|
|
1478
|
+
const n = buffer[0] ?? 0;
|
|
1479
|
+
if (n < 4294000000)
|
|
1480
|
+
return String(n % 1e6).padStart(6, "0");
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
function sameCode(a, b) {
|
|
1484
|
+
const x = Buffer.from(a);
|
|
1485
|
+
const y = Buffer.from(b);
|
|
1486
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
class PairingCodes {
|
|
1490
|
+
#send;
|
|
1491
|
+
#ttlMs;
|
|
1492
|
+
#maxAttempts;
|
|
1493
|
+
#current;
|
|
1494
|
+
#previous;
|
|
1495
|
+
#timer;
|
|
1496
|
+
constructor(send, options = {}) {
|
|
1497
|
+
this.#send = send;
|
|
1498
|
+
this.#ttlMs = options.ttlMs ?? PAIRING_CODE_TTL_MS;
|
|
1499
|
+
this.#maxAttempts = options.maxAttempts ?? PAIRING_MAX_ATTEMPTS;
|
|
1500
|
+
}
|
|
1501
|
+
get active() {
|
|
1502
|
+
return this.#current !== undefined;
|
|
1503
|
+
}
|
|
1504
|
+
get attemptsLeft() {
|
|
1505
|
+
return this.#current === undefined ? 0 : this.#maxAttempts - this.#current.attempts;
|
|
1506
|
+
}
|
|
1507
|
+
start() {
|
|
1508
|
+
if (this.#current !== undefined) {
|
|
1509
|
+
this.#send(this.#current.code, this.#current.expiresAt);
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
this.#issue();
|
|
1513
|
+
}
|
|
1514
|
+
stop() {
|
|
1515
|
+
clearTimeout(this.#timer);
|
|
1516
|
+
this.#timer = undefined;
|
|
1517
|
+
this.#current = undefined;
|
|
1518
|
+
this.#previous = undefined;
|
|
1519
|
+
}
|
|
1520
|
+
check(input, now = Date.now(), admit = () => true) {
|
|
1521
|
+
const current = this.#current;
|
|
1522
|
+
if (current === undefined)
|
|
1523
|
+
return "inactive";
|
|
1524
|
+
const typed = input.replace(/[\s-]/g, "");
|
|
1525
|
+
if (!/^\d{6}$/.test(typed))
|
|
1526
|
+
return "malformed";
|
|
1527
|
+
if (!admit())
|
|
1528
|
+
return "limited";
|
|
1529
|
+
if (now >= current.expiresAt) {
|
|
1530
|
+
this.#previous = current.code;
|
|
1531
|
+
this.#issue();
|
|
1532
|
+
return "expired";
|
|
1533
|
+
}
|
|
1534
|
+
if (sameCode(typed, current.code)) {
|
|
1535
|
+
this.#previous = undefined;
|
|
1536
|
+
this.#issue();
|
|
1537
|
+
return "ok";
|
|
1538
|
+
}
|
|
1539
|
+
current.attempts++;
|
|
1540
|
+
const expired = this.#previous !== undefined && sameCode(typed, this.#previous);
|
|
1541
|
+
if (current.attempts >= this.#maxAttempts) {
|
|
1542
|
+
this.#previous = undefined;
|
|
1543
|
+
this.#issue();
|
|
1544
|
+
return "locked";
|
|
1545
|
+
}
|
|
1546
|
+
return expired ? "expired" : "wrong";
|
|
1547
|
+
}
|
|
1548
|
+
#issue() {
|
|
1549
|
+
clearTimeout(this.#timer);
|
|
1550
|
+
const expiresAt = Date.now() + this.#ttlMs;
|
|
1551
|
+
this.#current = { code: newPairingCode(), expiresAt, attempts: 0 };
|
|
1552
|
+
this.#timer = setTimeout(() => {
|
|
1553
|
+
if (this.#current === undefined)
|
|
1554
|
+
return;
|
|
1555
|
+
this.#previous = this.#current.code;
|
|
1556
|
+
this.#issue();
|
|
1557
|
+
}, this.#ttlMs);
|
|
1558
|
+
this.#send(this.#current.code, expiresAt);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
var MESSAGES = {
|
|
1562
|
+
wrong: "That code is not right. Check the code shown in the page and try again.",
|
|
1563
|
+
expired: "That code has expired. The page now shows a new code: enter that one.",
|
|
1564
|
+
locked: "Too many wrong codes. The page now shows a new code: enter that one.",
|
|
1565
|
+
inactive: "The page is not showing a code right now. In the page, open \u201CConnect your AI tool\u201D to see one, then enter it here.",
|
|
1566
|
+
malformed: "Enter the 6 digits shown in the page.",
|
|
1567
|
+
limited: "Too many attempts for this page. Wait a few minutes and try again. If it keeps happening, someone else may be guessing: use \u201CNew address\u201D in the page."
|
|
1568
|
+
};
|
|
1569
|
+
var codePairing = {
|
|
1570
|
+
kind: "code",
|
|
1571
|
+
render() {
|
|
1572
|
+
return `<p><label for="code">Code shown in the page</label>
|
|
1573
|
+
<input id="code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9 ]{6,7}" maxlength="7" required autofocus aria-describedby="code-help"></p>
|
|
1574
|
+
<p id="code-help" class="help">In the page, \u201CConnect your AI tool\u201D shows a 6-digit code. It changes every 2 minutes.</p>`;
|
|
1575
|
+
},
|
|
1576
|
+
check(target, form, admit) {
|
|
1577
|
+
const result = target.codes.check(form.get("code") ?? "", Date.now(), admit);
|
|
1578
|
+
if (result === "ok")
|
|
1579
|
+
return { ok: true };
|
|
1580
|
+
let message = MESSAGES[result];
|
|
1581
|
+
if (result === "wrong") {
|
|
1582
|
+
const left = target.codes.attemptsLeft;
|
|
1583
|
+
message += ` ${left === 1 ? "1 try is" : `${left} tries are`} left for this code.`;
|
|
1584
|
+
}
|
|
1585
|
+
return {
|
|
1586
|
+
ok: false,
|
|
1587
|
+
message,
|
|
1588
|
+
reason: result === "limited" ? "rate_limited" : result
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
var PAIRING_METHODS = {
|
|
1593
|
+
code: codePairing
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
// src/oauth/server.ts
|
|
1597
|
+
var SCOPE = "tools";
|
|
1598
|
+
var PENDING_TTL_MS = 10 * 60000;
|
|
1599
|
+
var AUTH_CODE_TTL_MS = 60000;
|
|
1600
|
+
var MAX_PENDING = 1e4;
|
|
1601
|
+
var MAX_PENDING_PER_IP = 10;
|
|
1602
|
+
var MAX_PENDING_PER_ADDRESS = 20;
|
|
1603
|
+
var MAX_CODES = 1e4;
|
|
1604
|
+
var MAX_FORM_BYTES = 8192;
|
|
1605
|
+
var MAX_STATE_LENGTH = 1024;
|
|
1606
|
+
|
|
1607
|
+
class OAuthError extends Error {
|
|
1608
|
+
status;
|
|
1609
|
+
code;
|
|
1610
|
+
constructor(status, code, description) {
|
|
1611
|
+
super(description);
|
|
1612
|
+
this.status = status;
|
|
1613
|
+
this.code = code;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
function constantTimeEqual(a, b) {
|
|
1617
|
+
const x = Buffer.from(a);
|
|
1618
|
+
const y = Buffer.from(b);
|
|
1619
|
+
return x.length === y.length && timingSafeEqual2(x, y);
|
|
1620
|
+
}
|
|
1621
|
+
function s256(verifier) {
|
|
1622
|
+
return new Bun.CryptoHasher("sha256").update(verifier).digest().toBase64({ alphabet: "base64url", omitPadding: true });
|
|
1623
|
+
}
|
|
1624
|
+
var VERIFIER = /^[A-Za-z0-9\-._~]{43,128}$/;
|
|
1625
|
+
function single(params, names) {
|
|
1626
|
+
const out = {};
|
|
1627
|
+
for (const name of names) {
|
|
1628
|
+
const values = params.getAll(name);
|
|
1629
|
+
if (values.length > 1)
|
|
1630
|
+
return { duplicate: name };
|
|
1631
|
+
const value = values[0];
|
|
1632
|
+
out[name] = value === undefined || value === "" ? undefined : value;
|
|
1633
|
+
}
|
|
1634
|
+
return out;
|
|
1635
|
+
}
|
|
1636
|
+
function random2(bytes) {
|
|
1637
|
+
return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
|
|
1638
|
+
}
|
|
1639
|
+
function formTarget(redirectUri) {
|
|
1640
|
+
try {
|
|
1641
|
+
const u = new URL(redirectUri);
|
|
1642
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.origin : u.protocol;
|
|
1643
|
+
} catch {
|
|
1644
|
+
return "'none'";
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
function hostOf(url) {
|
|
1648
|
+
try {
|
|
1649
|
+
const u = new URL(url);
|
|
1650
|
+
return u.host || u.protocol;
|
|
1651
|
+
} catch {
|
|
1652
|
+
return url;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
class AuthorizationServer {
|
|
1657
|
+
#host;
|
|
1658
|
+
#cimd;
|
|
1659
|
+
#pending = new Map;
|
|
1660
|
+
#pendingPerIp = new Map;
|
|
1661
|
+
#pendingPerAddress = new Map;
|
|
1662
|
+
#codes = new Map;
|
|
1663
|
+
#authCodeTtlMs;
|
|
1664
|
+
#methods;
|
|
1665
|
+
#csrfSecret = random2(32);
|
|
1666
|
+
#viewsPerIp = new RateLimiter({
|
|
1667
|
+
capacity: 30,
|
|
1668
|
+
refillPerSecond: 0.5
|
|
1669
|
+
});
|
|
1670
|
+
#attemptsPerIp = new RateLimiter({
|
|
1671
|
+
capacity: 20,
|
|
1672
|
+
refillPerSecond: 0.1
|
|
1673
|
+
});
|
|
1674
|
+
#guessesPerAddressIp = new RateLimiter({
|
|
1675
|
+
capacity: 10,
|
|
1676
|
+
refillPerSecond: 1 / 60
|
|
1677
|
+
});
|
|
1678
|
+
#guessesPerAddress = new RateLimiter({
|
|
1679
|
+
capacity: 30,
|
|
1680
|
+
refillPerSecond: 1 / 30
|
|
1681
|
+
});
|
|
1682
|
+
#tokenPerIp = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
|
|
1683
|
+
constructor(host, testMode) {
|
|
1684
|
+
this.#host = host;
|
|
1685
|
+
this.#authCodeTtlMs = testMode?.authCodeTtlMs ?? AUTH_CODE_TTL_MS;
|
|
1686
|
+
this.#methods = testMode?.pairingMethods ?? PAIRING_METHODS;
|
|
1687
|
+
this.#cimd = new ClientMetadataFetcher({
|
|
1688
|
+
allowedHosts: host.config.clientIdHosts,
|
|
1689
|
+
logger: host.logger,
|
|
1690
|
+
...testMode?.cimd === undefined ? {} : { testMode: testMode.cimd }
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
get #issuer() {
|
|
1694
|
+
return this.#host.publicUrl();
|
|
1695
|
+
}
|
|
1696
|
+
#cors() {
|
|
1697
|
+
return this.#host.config.cors ? { "access-control-allow-origin": "*" } : {};
|
|
1698
|
+
}
|
|
1699
|
+
#json(status, value, headers = {}) {
|
|
1700
|
+
return new Response(JSON.stringify(value), {
|
|
1701
|
+
status,
|
|
1702
|
+
headers: {
|
|
1703
|
+
"content-type": "application/json",
|
|
1704
|
+
...this.#cors(),
|
|
1705
|
+
...headers
|
|
1706
|
+
}
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
preflight(methods) {
|
|
1710
|
+
if (!this.#host.config.cors) {
|
|
1711
|
+
return new Response(null, { status: 405, headers: { allow: methods } });
|
|
1712
|
+
}
|
|
1713
|
+
return new Response(null, {
|
|
1714
|
+
status: 204,
|
|
1715
|
+
headers: {
|
|
1716
|
+
...this.#cors(),
|
|
1717
|
+
"access-control-allow-methods": `${methods}, OPTIONS`,
|
|
1718
|
+
"access-control-allow-headers": "authorization, content-type, mcp-protocol-version",
|
|
1719
|
+
"access-control-max-age": "600"
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
protectedResource(addressId) {
|
|
1724
|
+
if (!isAddressId(addressId))
|
|
1725
|
+
return new Response("Not found", { status: 404 });
|
|
1726
|
+
return this.#json(200, {
|
|
1727
|
+
resource: `${this.#issuer}/a/${addressId}/mcp`,
|
|
1728
|
+
authorization_servers: [this.#issuer],
|
|
1729
|
+
scopes_supported: [SCOPE],
|
|
1730
|
+
bearer_methods_supported: ["header"],
|
|
1731
|
+
resource_name: "Tools of a web page (tab-mcp)"
|
|
1732
|
+
}, { "cache-control": "public, max-age=3600" });
|
|
1733
|
+
}
|
|
1734
|
+
metadata() {
|
|
1735
|
+
const issuer = this.#issuer;
|
|
1736
|
+
return this.#json(200, {
|
|
1737
|
+
issuer,
|
|
1738
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
1739
|
+
token_endpoint: `${issuer}/token`,
|
|
1740
|
+
revocation_endpoint: `${issuer}/revoke`,
|
|
1741
|
+
response_types_supported: ["code"],
|
|
1742
|
+
response_modes_supported: ["query"],
|
|
1743
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
1744
|
+
code_challenge_methods_supported: ["S256"],
|
|
1745
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
1746
|
+
revocation_endpoint_auth_methods_supported: ["none"],
|
|
1747
|
+
scopes_supported: [SCOPE],
|
|
1748
|
+
client_id_metadata_document_supported: true,
|
|
1749
|
+
authorization_response_iss_parameter_supported: true,
|
|
1750
|
+
service_documentation: "https://github.com/ractive/tab-mcp"
|
|
1751
|
+
}, { "cache-control": "public, max-age=3600" });
|
|
1752
|
+
}
|
|
1753
|
+
stylesheet() {
|
|
1754
|
+
return new Response(AUTHORIZE_CSS, {
|
|
1755
|
+
headers: {
|
|
1756
|
+
"content-type": "text/css; charset=utf-8",
|
|
1757
|
+
"cache-control": "public, max-age=3600",
|
|
1758
|
+
"x-content-type-options": "nosniff"
|
|
1759
|
+
}
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
#page(status, title, body, formTargets = [], extraHeaders = {}) {
|
|
1763
|
+
const headers = pageHeaders(formTargets);
|
|
1764
|
+
for (const [k, v] of Object.entries(extraHeaders))
|
|
1765
|
+
headers.append(k, v);
|
|
1766
|
+
return new Response(renderPage(title, body), { status, headers });
|
|
1767
|
+
}
|
|
1768
|
+
#errorPage(status, message) {
|
|
1769
|
+
return this.#page(status, "This connection cannot be made", `<h1>This connection cannot be made</h1>
|
|
1770
|
+
<p role="alert">${escapeHtml(message)}</p>
|
|
1771
|
+
<p>Go back to your AI tool and try again. If it keeps happening, the AI tool may not work with this relay.</p>`);
|
|
1772
|
+
}
|
|
1773
|
+
#errorUrl(redirectUri, state, error, description) {
|
|
1774
|
+
const url = new URL(redirectUri);
|
|
1775
|
+
const params = {
|
|
1776
|
+
error,
|
|
1777
|
+
error_description: description,
|
|
1778
|
+
...state === undefined ? {} : { state },
|
|
1779
|
+
iss: this.#issuer
|
|
1780
|
+
};
|
|
1781
|
+
for (const [k, v] of Object.entries(params))
|
|
1782
|
+
url.searchParams.append(k, v);
|
|
1783
|
+
return url.href;
|
|
1784
|
+
}
|
|
1785
|
+
#invalidRequestPage(redirectUri, state, error, description) {
|
|
1786
|
+
const url = this.#errorUrl(redirectUri, state, error, description);
|
|
1787
|
+
const host = hostOf(redirectUri);
|
|
1788
|
+
return this.#page(400, "This connection cannot be made", `<h1>This connection cannot be made</h1>
|
|
1789
|
+
<p role="alert">The AI tool sent a request this relay cannot accept: ${escapeHtml(description)} (${escapeHtml(error)}).</p>
|
|
1790
|
+
<p>You can go back to the AI tool at ${escapeHtml(host)} to tell it, or close this page.</p>
|
|
1791
|
+
<div class="actions"><a class="button" href="${escapeHtml(url)}">Go back to ${escapeHtml(host)}</a></div>`);
|
|
1792
|
+
}
|
|
1793
|
+
#redirectError(redirectUri, state, error, description) {
|
|
1794
|
+
return new Response(null, {
|
|
1795
|
+
status: 303,
|
|
1796
|
+
headers: {
|
|
1797
|
+
location: this.#errorUrl(redirectUri, state, error, description),
|
|
1798
|
+
"cache-control": "no-store",
|
|
1799
|
+
"referrer-policy": "no-referrer"
|
|
1800
|
+
}
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
#redirect(redirectUri, params) {
|
|
1804
|
+
const url = new URL(redirectUri);
|
|
1805
|
+
for (const [k, v] of Object.entries(params))
|
|
1806
|
+
url.searchParams.append(k, v);
|
|
1807
|
+
return new Response(null, {
|
|
1808
|
+
status: 303,
|
|
1809
|
+
headers: {
|
|
1810
|
+
location: url.href,
|
|
1811
|
+
"cache-control": "no-store",
|
|
1812
|
+
"referrer-policy": "no-referrer"
|
|
1813
|
+
}
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
async authorize(req, ip) {
|
|
1817
|
+
if (!this.#viewsPerIp.take(ip)) {
|
|
1818
|
+
return this.#errorPage(429, "Too many requests from your network. Wait a minute and try again.");
|
|
1819
|
+
}
|
|
1820
|
+
const query = new URL(req.url).searchParams;
|
|
1821
|
+
const p = single(query, [
|
|
1822
|
+
"response_type",
|
|
1823
|
+
"client_id",
|
|
1824
|
+
"redirect_uri",
|
|
1825
|
+
"code_challenge",
|
|
1826
|
+
"code_challenge_method",
|
|
1827
|
+
"state",
|
|
1828
|
+
"resource",
|
|
1829
|
+
"scope"
|
|
1830
|
+
]);
|
|
1831
|
+
if ("duplicate" in p) {
|
|
1832
|
+
if (p.duplicate === "client_id" || p.duplicate === "redirect_uri") {
|
|
1833
|
+
return this.#errorPage(400, `The request names ${p.duplicate} twice.`);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
const clientId = "duplicate" in p ? query.get("client_id") : p["client_id"];
|
|
1837
|
+
const redirectUri = "duplicate" in p ? query.get("redirect_uri") : p["redirect_uri"];
|
|
1838
|
+
if (clientId === undefined || clientId === null) {
|
|
1839
|
+
return this.#errorPage(400, "The request has no client_id.");
|
|
1840
|
+
}
|
|
1841
|
+
let client;
|
|
1842
|
+
try {
|
|
1843
|
+
client = await this.#cimd.get(clientId);
|
|
1844
|
+
} catch (error) {
|
|
1845
|
+
if (error instanceof ClientMetadataError) {
|
|
1846
|
+
this.#host.logger.info("oauth.client_refused", {
|
|
1847
|
+
reason: error.reason,
|
|
1848
|
+
host: hostOf(clientId).slice(0, 100)
|
|
1849
|
+
});
|
|
1850
|
+
return this.#errorPage(400, error.message);
|
|
1851
|
+
}
|
|
1852
|
+
throw error;
|
|
1853
|
+
}
|
|
1854
|
+
if (redirectUri === undefined || redirectUri === null) {
|
|
1855
|
+
return this.#errorPage(400, "The request has no redirect_uri.");
|
|
1856
|
+
}
|
|
1857
|
+
if (!client.redirectUris.some((r) => redirectUriMatches(redirectUri, r))) {
|
|
1858
|
+
this.#host.logger.info("oauth.client_refused", {
|
|
1859
|
+
reason: "redirect_uri",
|
|
1860
|
+
host: client.host
|
|
1861
|
+
});
|
|
1862
|
+
return this.#errorPage(400, `${client.name} asked to return to an address it has not registered (${hostOf(redirectUri)}).`);
|
|
1863
|
+
}
|
|
1864
|
+
const state = "duplicate" in p ? undefined : p["state"];
|
|
1865
|
+
const fail = (error, description) => this.#invalidRequestPage(redirectUri, state, error, description);
|
|
1866
|
+
if ("duplicate" in p) {
|
|
1867
|
+
return fail("invalid_request", `${p.duplicate} was sent more than once`);
|
|
1868
|
+
}
|
|
1869
|
+
if (state !== undefined && state.length > MAX_STATE_LENGTH) {
|
|
1870
|
+
return fail("invalid_request", "state is too long");
|
|
1871
|
+
}
|
|
1872
|
+
if (p["response_type"] !== "code") {
|
|
1873
|
+
return fail("unsupported_response_type", "only response_type=code is supported");
|
|
1874
|
+
}
|
|
1875
|
+
const challenge = p["code_challenge"];
|
|
1876
|
+
if (challenge === undefined) {
|
|
1877
|
+
return fail("invalid_request", "code_challenge is required (PKCE)");
|
|
1878
|
+
}
|
|
1879
|
+
if (p["code_challenge_method"] !== "S256") {
|
|
1880
|
+
return fail("invalid_request", "code_challenge_method must be S256");
|
|
1881
|
+
}
|
|
1882
|
+
if (!/^[A-Za-z0-9\-._~]{43,128}$/.test(challenge)) {
|
|
1883
|
+
return fail("invalid_request", "code_challenge is malformed");
|
|
1884
|
+
}
|
|
1885
|
+
const resource = p["resource"];
|
|
1886
|
+
const addressId = this.#addressOf(resource);
|
|
1887
|
+
if (resource === undefined || addressId === undefined) {
|
|
1888
|
+
return fail("invalid_target", "resource must be the MCP address of a page on this relay");
|
|
1889
|
+
}
|
|
1890
|
+
const scopes = this.#grantedScopes(p["scope"]);
|
|
1891
|
+
if (scopes === undefined) {
|
|
1892
|
+
return fail("invalid_scope", `the only scope is "${SCOPE}"`);
|
|
1893
|
+
}
|
|
1894
|
+
if (this.#host.session(addressId) === undefined) {
|
|
1895
|
+
return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"));
|
|
1896
|
+
}
|
|
1897
|
+
this.#sweepPending();
|
|
1898
|
+
if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP || (this.#pendingPerAddress.get(addressId) ?? 0) >= MAX_PENDING_PER_ADDRESS) {
|
|
1899
|
+
return this.#errorPage(429, "Too many connection requests are open right now. Wait a few minutes and try again.");
|
|
1900
|
+
}
|
|
1901
|
+
const pending = {
|
|
1902
|
+
id: random2(24),
|
|
1903
|
+
expiresAt: Date.now() + PENDING_TTL_MS,
|
|
1904
|
+
ip,
|
|
1905
|
+
client,
|
|
1906
|
+
redirectUri,
|
|
1907
|
+
state,
|
|
1908
|
+
codeChallenge: challenge,
|
|
1909
|
+
addressId,
|
|
1910
|
+
resource,
|
|
1911
|
+
scopes
|
|
1912
|
+
};
|
|
1913
|
+
this.#addPending(pending);
|
|
1914
|
+
const browser = this.#browserId(req);
|
|
1915
|
+
return this.#pairingPage(pending, browser.id, undefined, 200, browser.cookie);
|
|
1916
|
+
}
|
|
1917
|
+
#addPending(p) {
|
|
1918
|
+
this.#pending.set(p.id, p);
|
|
1919
|
+
this.#pendingPerIp.set(p.ip, (this.#pendingPerIp.get(p.ip) ?? 0) + 1);
|
|
1920
|
+
this.#pendingPerAddress.set(p.addressId, (this.#pendingPerAddress.get(p.addressId) ?? 0) + 1);
|
|
1921
|
+
}
|
|
1922
|
+
#dropPending(p) {
|
|
1923
|
+
if (!this.#pending.delete(p.id))
|
|
1924
|
+
return;
|
|
1925
|
+
const dec = (map, key) => {
|
|
1926
|
+
const n = (map.get(key) ?? 1) - 1;
|
|
1927
|
+
if (n <= 0)
|
|
1928
|
+
map.delete(key);
|
|
1929
|
+
else
|
|
1930
|
+
map.set(key, n);
|
|
1931
|
+
};
|
|
1932
|
+
dec(this.#pendingPerIp, p.ip);
|
|
1933
|
+
dec(this.#pendingPerAddress, p.addressId);
|
|
1934
|
+
}
|
|
1935
|
+
#offlinePage(client, retryUrl, cancelUrl, error) {
|
|
1936
|
+
const errorHtml = error === undefined ? "" : `<p class="error" role="alert">${escapeHtml(error)}</p>`;
|
|
1937
|
+
const body = `<h1>Open the page first</h1>
|
|
1938
|
+
${errorHtml}
|
|
1939
|
+
<p><strong>${escapeHtml(client.name)}</strong> (${escapeHtml(client.host)}) wants to use the tools of a web page, but that page is not open right now.</p>
|
|
1940
|
+
<p>Open the page, click \u201CConnect your AI tool\u201D, then try again here.</p>
|
|
1941
|
+
<div class="actions">
|
|
1942
|
+
<a class="button primary" href="${escapeHtml(retryUrl)}">Try again</a>
|
|
1943
|
+
<a class="button" href="${escapeHtml(cancelUrl)}">Cancel</a>
|
|
1944
|
+
</div>`;
|
|
1945
|
+
return this.#page(200, "Open the page first", body);
|
|
1946
|
+
}
|
|
1947
|
+
async authorizeSubmit(req, ip) {
|
|
1948
|
+
const origin = req.headers.get("origin");
|
|
1949
|
+
const site = req.headers.get("sec-fetch-site");
|
|
1950
|
+
if (origin !== null && origin !== this.#issuer || site !== null && site !== "same-origin") {
|
|
1951
|
+
return this.#errorPage(403, "This form can only be sent from this page.");
|
|
1952
|
+
}
|
|
1953
|
+
if (!this.#attemptsPerIp.take(ip)) {
|
|
1954
|
+
return this.#errorPage(429, "Too many attempts from your network. Wait a few minutes, then start again from your AI tool.");
|
|
1955
|
+
}
|
|
1956
|
+
const type = (req.headers.get("content-type") ?? "").split(";")[0]?.trim();
|
|
1957
|
+
if (type !== "application/x-www-form-urlencoded") {
|
|
1958
|
+
return this.#errorPage(400, "The form was not sent correctly.");
|
|
1959
|
+
}
|
|
1960
|
+
const text = await this.#readForm(req);
|
|
1961
|
+
if (text === undefined) {
|
|
1962
|
+
return this.#errorPage(413, "The form was too large.");
|
|
1963
|
+
}
|
|
1964
|
+
const form = new URLSearchParams(text);
|
|
1965
|
+
const pending = this.#pending.get(form.get("request_id") ?? "");
|
|
1966
|
+
if (pending === undefined || pending.expiresAt <= Date.now()) {
|
|
1967
|
+
return this.#errorPage(400, "This connection request has expired or was already used.");
|
|
1968
|
+
}
|
|
1969
|
+
const browser = this.#browserId(req);
|
|
1970
|
+
const csrf = form.get("csrf") ?? "";
|
|
1971
|
+
if (browser.cookie !== undefined || !Bun.CSRF.verify(csrf, {
|
|
1972
|
+
secret: this.#csrfSecret,
|
|
1973
|
+
sessionId: `${pending.id}.${browser.id}`,
|
|
1974
|
+
maxAge: PENDING_TTL_MS
|
|
1975
|
+
})) {
|
|
1976
|
+
return this.#errorPage(403, "The form could not be checked. Your browser may block cookies for this site.");
|
|
1977
|
+
}
|
|
1978
|
+
const action = form.get("action");
|
|
1979
|
+
if (action === "cancel") {
|
|
1980
|
+
this.#dropPending(pending);
|
|
1981
|
+
this.#host.logger.info("oauth.cancelled", {
|
|
1982
|
+
address: shortAddress(pending.addressId),
|
|
1983
|
+
client: pending.client.host
|
|
1984
|
+
});
|
|
1985
|
+
return this.#redirectError(pending.redirectUri, pending.state, "access_denied", "the user cancelled the connection");
|
|
1986
|
+
}
|
|
1987
|
+
if (action !== "connect") {
|
|
1988
|
+
return this.#pairingPage(pending, browser.id);
|
|
1989
|
+
}
|
|
1990
|
+
const session = this.#host.session(pending.addressId);
|
|
1991
|
+
if (session === undefined) {
|
|
1992
|
+
return this.#pairingPage(pending, browser.id, "The page is still not open.");
|
|
1993
|
+
}
|
|
1994
|
+
const method = this.#methods[session.site.pairing];
|
|
1995
|
+
if (method === undefined) {
|
|
1996
|
+
return this.#errorPage(500, "This page's pairing method is not available.");
|
|
1997
|
+
}
|
|
1998
|
+
const admit = () => this.#guessesPerAddressIp.take(`${pending.addressId}|${ip}`) && this.#guessesPerAddress.take(pending.addressId);
|
|
1999
|
+
const result = await method.check(session, form, admit);
|
|
2000
|
+
if (!result.ok) {
|
|
2001
|
+
this.#host.logger.info("oauth.pairing_failed", {
|
|
2002
|
+
address: shortAddress(pending.addressId),
|
|
2003
|
+
client: pending.client.host,
|
|
2004
|
+
reason: result.reason
|
|
2005
|
+
});
|
|
2006
|
+
return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" ? 429 : 400);
|
|
2007
|
+
}
|
|
2008
|
+
this.#dropPending(pending);
|
|
2009
|
+
const code = random2(32);
|
|
2010
|
+
if (this.#codes.size >= MAX_CODES)
|
|
2011
|
+
this.#sweepCodes(true);
|
|
2012
|
+
const { id: _id, expiresAt: _expires, ip: _ip, ...request } = pending;
|
|
2013
|
+
this.#codes.set(hashToken(code), {
|
|
2014
|
+
...request,
|
|
2015
|
+
expiresAt: Date.now() + this.#authCodeTtlMs,
|
|
2016
|
+
used: false
|
|
2017
|
+
});
|
|
2018
|
+
this.#host.logger.info("oauth.paired", {
|
|
2019
|
+
address: shortAddress(pending.addressId),
|
|
2020
|
+
client: pending.client.host,
|
|
2021
|
+
method: method.kind
|
|
2022
|
+
});
|
|
2023
|
+
return this.#redirect(pending.redirectUri, {
|
|
2024
|
+
code,
|
|
2025
|
+
...pending.state === undefined ? {} : { state: pending.state },
|
|
2026
|
+
iss: this.#issuer
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
#addressOf(resource) {
|
|
2030
|
+
if (resource === undefined)
|
|
2031
|
+
return;
|
|
2032
|
+
const prefix = `${this.#issuer}/a/`;
|
|
2033
|
+
if (!resource.startsWith(prefix) || !resource.endsWith("/mcp")) {
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
const id = resource.slice(prefix.length, -"/mcp".length);
|
|
2037
|
+
return isAddressId(id) ? id : undefined;
|
|
2038
|
+
}
|
|
2039
|
+
#grantedScopes(scope) {
|
|
2040
|
+
if (scope === undefined)
|
|
2041
|
+
return [SCOPE];
|
|
2042
|
+
const requested = scope.split(" ").filter((s) => s !== "");
|
|
2043
|
+
if (requested.length === 0)
|
|
2044
|
+
return [SCOPE];
|
|
2045
|
+
return requested.includes(SCOPE) ? [SCOPE] : undefined;
|
|
2046
|
+
}
|
|
2047
|
+
#browserId(req) {
|
|
2048
|
+
const name = this.#cookieName();
|
|
2049
|
+
const header = req.headers.get("cookie") ?? "";
|
|
2050
|
+
for (const part of header.split(";")) {
|
|
2051
|
+
const [k, v] = part.trim().split("=");
|
|
2052
|
+
if (k === name && v !== undefined && /^[A-Za-z0-9_-]{22}$/.test(v)) {
|
|
2053
|
+
return { id: v };
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
const id = random2(16);
|
|
2057
|
+
const secure = this.#issuer.startsWith("https:") ? "; Secure" : "";
|
|
2058
|
+
return {
|
|
2059
|
+
id,
|
|
2060
|
+
cookie: `${name}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${PENDING_TTL_MS / 1000}${secure}`
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
#cookieName() {
|
|
2064
|
+
return this.#issuer.startsWith("https:") ? "__Host-tab-mcp-authorize" : "tab-mcp-authorize";
|
|
2065
|
+
}
|
|
2066
|
+
async#readForm(req) {
|
|
2067
|
+
const length = Number(req.headers.get("content-length") ?? "0");
|
|
2068
|
+
if (length > MAX_FORM_BYTES)
|
|
2069
|
+
return;
|
|
2070
|
+
const bytes = await req.arrayBuffer().catch(() => new ArrayBuffer(0));
|
|
2071
|
+
if (bytes.byteLength > MAX_FORM_BYTES)
|
|
2072
|
+
return;
|
|
2073
|
+
return new TextDecoder().decode(bytes);
|
|
2074
|
+
}
|
|
2075
|
+
#pairingPage(pending, browserId, error, status = 200, cookie) {
|
|
2076
|
+
const csrf = Bun.CSRF.generate(this.#csrfSecret, {
|
|
2077
|
+
sessionId: `${pending.id}.${browserId}`,
|
|
2078
|
+
expiresIn: PENDING_TTL_MS
|
|
2079
|
+
});
|
|
2080
|
+
const client = pending.client;
|
|
2081
|
+
const clientLabel = `<strong>${escapeHtml(client.name)}</strong> (${escapeHtml(client.host)})`;
|
|
2082
|
+
const hidden = `<input type="hidden" name="request_id" value="${escapeHtml(pending.id)}">
|
|
2083
|
+
<input type="hidden" name="csrf" value="${escapeHtml(csrf)}">`;
|
|
2084
|
+
const errorHtml = error === undefined ? "" : `<p class="error" role="alert" id="error">${escapeHtml(error)}</p>`;
|
|
2085
|
+
const extra = cookie === undefined ? {} : { "set-cookie": cookie };
|
|
2086
|
+
const targets = [formTarget(pending.redirectUri)];
|
|
2087
|
+
const cancel = `<button type="submit" class="secondary" name="action" value="cancel" formnovalidate>Cancel</button>`;
|
|
2088
|
+
const session = this.#host.session(pending.addressId);
|
|
2089
|
+
if (session === undefined) {
|
|
2090
|
+
const body = `<h1>Open the page first</h1>
|
|
2091
|
+
${errorHtml}
|
|
2092
|
+
<p>${clientLabel} wants to use the tools of a web page, but that page is not open right now.</p>
|
|
2093
|
+
<p>Open the page, click \u201CConnect your AI tool\u201D, then try again here.</p>
|
|
2094
|
+
<form method="post" action="/authorize">
|
|
2095
|
+
${hidden}
|
|
2096
|
+
<div class="actions">
|
|
2097
|
+
<button type="submit" class="primary" name="action" value="retry">Try again</button>
|
|
2098
|
+
${cancel}
|
|
2099
|
+
</div>
|
|
2100
|
+
</form>`;
|
|
2101
|
+
return this.#page(status, "Open the page first", body, targets, extra);
|
|
2102
|
+
}
|
|
2103
|
+
const method = this.#methods[session.site.pairing];
|
|
2104
|
+
if (method === undefined) {
|
|
2105
|
+
return this.#errorPage(500, "This page's pairing method is not available.");
|
|
2106
|
+
}
|
|
2107
|
+
const page = session.origin === "" ? "a page that did not say where it runs" : `<span class="origin">${escapeHtml(session.origin)}</span>`;
|
|
2108
|
+
const target = session.appName === undefined ? `tools from ${page}` : `<strong>${escapeHtml(session.appName)}</strong> on ${page}`;
|
|
2109
|
+
const heading = `Connect ${client.name} from ${client.host}`;
|
|
2110
|
+
const body = `<h1>${escapeHtml(heading)}</h1>
|
|
2111
|
+
<p>You're connecting ${clientLabel} to ${target}.</p>
|
|
2112
|
+
<p>${escapeHtml(client.name)} can then use this page's tools while the page is open. You can disconnect it in the page at any time.</p>
|
|
2113
|
+
${errorHtml}
|
|
2114
|
+
<form method="post" action="/authorize">
|
|
2115
|
+
${hidden}
|
|
2116
|
+
${method.render(session)}
|
|
2117
|
+
<div class="actions">
|
|
2118
|
+
<button type="submit" class="primary" name="action" value="connect">Connect</button>
|
|
2119
|
+
${cancel}
|
|
2120
|
+
</div>
|
|
2121
|
+
</form>
|
|
2122
|
+
<p class="help">After you connect, your browser goes back to ${escapeHtml(hostOf(pending.redirectUri))}. Only connect AI tools you started yourself.</p>`;
|
|
2123
|
+
return this.#page(status, heading, body, targets, extra);
|
|
2124
|
+
}
|
|
2125
|
+
async token(req, ip) {
|
|
2126
|
+
const noStore = { "cache-control": "no-store", pragma: "no-cache" };
|
|
2127
|
+
try {
|
|
2128
|
+
if (!this.#tokenPerIp.take(ip)) {
|
|
2129
|
+
throw new OAuthError(429, "temporarily_unavailable", "too many requests; try again shortly");
|
|
2130
|
+
}
|
|
2131
|
+
const params = await this.#formParams(req);
|
|
2132
|
+
const p = single(params, [
|
|
2133
|
+
"grant_type",
|
|
2134
|
+
"client_id",
|
|
2135
|
+
"code",
|
|
2136
|
+
"redirect_uri",
|
|
2137
|
+
"code_verifier",
|
|
2138
|
+
"refresh_token",
|
|
2139
|
+
"resource",
|
|
2140
|
+
"scope"
|
|
2141
|
+
]);
|
|
2142
|
+
if ("duplicate" in p) {
|
|
2143
|
+
throw new OAuthError(400, "invalid_request", `${p.duplicate} was sent more than once`);
|
|
2144
|
+
}
|
|
2145
|
+
const clientId = p["client_id"];
|
|
2146
|
+
if (clientId === undefined) {
|
|
2147
|
+
throw new OAuthError(400, "invalid_request", "client_id is required");
|
|
2148
|
+
}
|
|
2149
|
+
let body;
|
|
2150
|
+
if (p["grant_type"] === "authorization_code") {
|
|
2151
|
+
body = await this.#codeGrant(p, clientId);
|
|
2152
|
+
} else if (p["grant_type"] === "refresh_token") {
|
|
2153
|
+
body = await this.#refreshGrant(p, clientId);
|
|
2154
|
+
} else if (p["grant_type"] === undefined) {
|
|
2155
|
+
throw new OAuthError(400, "invalid_request", "grant_type is required");
|
|
2156
|
+
} else {
|
|
2157
|
+
throw new OAuthError(400, "unsupported_grant_type", "only authorization_code and refresh_token are supported");
|
|
2158
|
+
}
|
|
2159
|
+
return this.#json(200, body, noStore);
|
|
2160
|
+
} catch (error) {
|
|
2161
|
+
return this.#oauthErrorResponse(error, noStore);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
#oauthErrorResponse(error, headers) {
|
|
2165
|
+
if (error instanceof OAuthError) {
|
|
2166
|
+
const extra = { ...headers };
|
|
2167
|
+
if (error.status === 401)
|
|
2168
|
+
extra["www-authenticate"] = 'Basic realm="tab-mcp"';
|
|
2169
|
+
if (error.status === 429 || error.status === 503)
|
|
2170
|
+
extra["retry-after"] = "10";
|
|
2171
|
+
return this.#json(error.status, { error: error.code, error_description: error.message }, extra);
|
|
2172
|
+
}
|
|
2173
|
+
this.#host.logger.error("oauth.error", {
|
|
2174
|
+
message: error instanceof Error ? error.name : "unknown"
|
|
2175
|
+
});
|
|
2176
|
+
return this.#json(503, {
|
|
2177
|
+
error: "temporarily_unavailable",
|
|
2178
|
+
error_description: "the relay cannot issue tokens right now"
|
|
2179
|
+
}, { ...headers, "retry-after": "10" });
|
|
2180
|
+
}
|
|
2181
|
+
async#formParams(req) {
|
|
2182
|
+
const type = (req.headers.get("content-type") ?? "").split(";")[0]?.trim();
|
|
2183
|
+
if (type !== "application/x-www-form-urlencoded") {
|
|
2184
|
+
throw new OAuthError(400, "invalid_request", "the body must be application/x-www-form-urlencoded");
|
|
2185
|
+
}
|
|
2186
|
+
if (req.headers.get("authorization") !== null) {
|
|
2187
|
+
throw new OAuthError(401, "invalid_client", "client authentication is not supported (token_endpoint_auth_method none)");
|
|
2188
|
+
}
|
|
2189
|
+
const text = await this.#readForm(req);
|
|
2190
|
+
if (text === undefined) {
|
|
2191
|
+
throw new OAuthError(413, "invalid_request", "the request is too large");
|
|
2192
|
+
}
|
|
2193
|
+
const params = new URLSearchParams(text);
|
|
2194
|
+
if (params.has("client_secret") || params.has("client_assertion")) {
|
|
2195
|
+
throw new OAuthError(400, "invalid_client", "client authentication is not supported (token_endpoint_auth_method none)");
|
|
2196
|
+
}
|
|
2197
|
+
return params;
|
|
2198
|
+
}
|
|
2199
|
+
async#codeGrant(p, clientId) {
|
|
2200
|
+
const code = p["code"];
|
|
2201
|
+
const redirectUri = p["redirect_uri"];
|
|
2202
|
+
const verifier = p["code_verifier"];
|
|
2203
|
+
if (code === undefined || redirectUri === undefined || verifier === undefined) {
|
|
2204
|
+
throw new OAuthError(400, "invalid_request", "code, redirect_uri and code_verifier are required");
|
|
2205
|
+
}
|
|
2206
|
+
const key = hashToken(code);
|
|
2207
|
+
const issued = this.#codes.get(key);
|
|
2208
|
+
const invalid = (why) => new OAuthError(400, "invalid_grant", why);
|
|
2209
|
+
if (issued === undefined)
|
|
2210
|
+
throw invalid("the code is not valid");
|
|
2211
|
+
if (issued.used) {
|
|
2212
|
+
if (issued.grantId !== undefined) {
|
|
2213
|
+
await this.#revokeGrant(issued.addressId, issued.grantId, "reuse_detected");
|
|
2214
|
+
}
|
|
2215
|
+
throw invalid("the code was already used");
|
|
2216
|
+
}
|
|
2217
|
+
issued.used = true;
|
|
2218
|
+
if (issued.expiresAt <= Date.now())
|
|
2219
|
+
throw invalid("the code has expired");
|
|
2220
|
+
if (!constantTimeEqual(clientId, issued.client.clientId)) {
|
|
2221
|
+
throw invalid("the code was issued to another client");
|
|
2222
|
+
}
|
|
2223
|
+
if (redirectUri !== issued.redirectUri) {
|
|
2224
|
+
throw invalid("redirect_uri does not match the authorization request");
|
|
2225
|
+
}
|
|
2226
|
+
if (p["resource"] !== undefined && p["resource"] !== issued.resource) {
|
|
2227
|
+
throw new OAuthError(400, "invalid_target", "resource does not match the authorization request");
|
|
2228
|
+
}
|
|
2229
|
+
if (!VERIFIER.test(verifier) || !constantTimeEqual(s256(verifier), issued.codeChallenge)) {
|
|
2230
|
+
throw invalid("code_verifier does not match the code_challenge");
|
|
2231
|
+
}
|
|
2232
|
+
const now = Date.now();
|
|
2233
|
+
const grantId = newId("g");
|
|
2234
|
+
issued.grantId = grantId;
|
|
2235
|
+
const config = this.#host.config;
|
|
2236
|
+
const tokens = await issueTokenPair(this.#host.store, {
|
|
2237
|
+
grantId,
|
|
2238
|
+
addressId: issued.addressId,
|
|
2239
|
+
clientId: issued.client.clientId,
|
|
2240
|
+
clientName: issued.client.name,
|
|
2241
|
+
scopes: issued.scopes,
|
|
2242
|
+
grantCreatedAt: now
|
|
2243
|
+
}, {
|
|
2244
|
+
accessTtlMs: config.accessTokenTtlMs,
|
|
2245
|
+
refreshTtlMs: config.refreshTokenTtlMs,
|
|
2246
|
+
grantEndsAt: now + config.grantLifetimeMs,
|
|
2247
|
+
now
|
|
2248
|
+
});
|
|
2249
|
+
if (tokens === undefined) {
|
|
2250
|
+
throw invalid("the connection was revoked");
|
|
2251
|
+
}
|
|
2252
|
+
this.#host.logger.info("oauth.grant_created", {
|
|
2253
|
+
address: shortAddress(issued.addressId),
|
|
2254
|
+
client: issued.client.host
|
|
2255
|
+
});
|
|
2256
|
+
this.#host.grantAdded(issued.addressId, {
|
|
2257
|
+
grantId,
|
|
2258
|
+
client: {
|
|
2259
|
+
id: issued.client.clientId,
|
|
2260
|
+
name: issued.client.name,
|
|
2261
|
+
host: issued.client.host
|
|
2262
|
+
},
|
|
2263
|
+
createdAt: new Date(now).toISOString()
|
|
2264
|
+
});
|
|
2265
|
+
return this.#tokenBody(tokens, issued.scopes);
|
|
2266
|
+
}
|
|
2267
|
+
#tokenBody(tokens, scopes) {
|
|
2268
|
+
return {
|
|
2269
|
+
access_token: tokens.accessToken,
|
|
2270
|
+
token_type: "Bearer",
|
|
2271
|
+
expires_in: tokens.expiresIn,
|
|
2272
|
+
refresh_token: tokens.refreshToken,
|
|
2273
|
+
scope: scopes.join(" ")
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
async#refreshGrant(p, clientId) {
|
|
2277
|
+
const token = p["refresh_token"];
|
|
2278
|
+
if (token === undefined) {
|
|
2279
|
+
throw new OAuthError(400, "invalid_request", "refresh_token is required");
|
|
2280
|
+
}
|
|
2281
|
+
const invalid = (why) => new OAuthError(400, "invalid_grant", why);
|
|
2282
|
+
const hash = hashToken(token);
|
|
2283
|
+
const record = await this.#host.store.get(hash);
|
|
2284
|
+
if (record === undefined || record.kind !== "refresh") {
|
|
2285
|
+
throw invalid("the refresh token is not valid");
|
|
2286
|
+
}
|
|
2287
|
+
if (!constantTimeEqual(clientId, record.clientId)) {
|
|
2288
|
+
throw invalid("the refresh token was issued to another client");
|
|
2289
|
+
}
|
|
2290
|
+
const now = Date.now();
|
|
2291
|
+
if (record.expiresAt <= now)
|
|
2292
|
+
throw invalid("the refresh token has expired");
|
|
2293
|
+
if (record.usedAt !== undefined) {
|
|
2294
|
+
await this.#reuseDetected(record);
|
|
2295
|
+
throw invalid("the refresh token was already used; the connection was revoked");
|
|
2296
|
+
}
|
|
2297
|
+
if (p["resource"] !== undefined && p["resource"] !== `${this.#issuer}/a/${record.addressId}/mcp`) {
|
|
2298
|
+
throw new OAuthError(400, "invalid_target", "resource is not the address this token belongs to");
|
|
2299
|
+
}
|
|
2300
|
+
const scope = p["scope"];
|
|
2301
|
+
if (scope !== undefined) {
|
|
2302
|
+
const wanted = scope.split(" ").filter((s) => s !== "");
|
|
2303
|
+
if (!wanted.every((s) => record.scopes.includes(s))) {
|
|
2304
|
+
throw new OAuthError(400, "invalid_scope", "scope exceeds the grant");
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
if (!await this.#host.store.markUsed(hash, now)) {
|
|
2308
|
+
await this.#reuseDetected(record);
|
|
2309
|
+
throw invalid("the refresh token was already used; the connection was revoked");
|
|
2310
|
+
}
|
|
2311
|
+
const tokens = await issueTokenPair(this.#host.store, {
|
|
2312
|
+
grantId: record.grantId,
|
|
2313
|
+
addressId: record.addressId,
|
|
2314
|
+
clientId: record.clientId,
|
|
2315
|
+
clientName: record.clientName,
|
|
2316
|
+
scopes: record.scopes,
|
|
2317
|
+
grantCreatedAt: record.grantCreatedAt
|
|
2318
|
+
}, {
|
|
2319
|
+
accessTtlMs: this.#host.config.accessTokenTtlMs,
|
|
2320
|
+
refreshTtlMs: this.#host.config.refreshTokenTtlMs,
|
|
2321
|
+
grantEndsAt: record.grantCreatedAt + this.#host.config.grantLifetimeMs,
|
|
2322
|
+
now
|
|
2323
|
+
});
|
|
2324
|
+
if (tokens === undefined) {
|
|
2325
|
+
throw invalid("the connection was revoked");
|
|
2326
|
+
}
|
|
2327
|
+
this.#host.logger.debug("oauth.refreshed", {
|
|
2328
|
+
address: shortAddress(record.addressId),
|
|
2329
|
+
client: hostOf(record.clientId)
|
|
2330
|
+
});
|
|
2331
|
+
return this.#tokenBody(tokens, record.scopes);
|
|
2332
|
+
}
|
|
2333
|
+
async#reuseDetected(record) {
|
|
2334
|
+
this.#host.logger.warn("oauth.refresh_reuse", {
|
|
2335
|
+
address: shortAddress(record.addressId),
|
|
2336
|
+
client: hostOf(record.clientId)
|
|
2337
|
+
});
|
|
2338
|
+
await this.#revokeGrant(record.addressId, record.grantId, "reuse_detected");
|
|
2339
|
+
}
|
|
2340
|
+
async#revokeGrant(addressId, grantId, reason) {
|
|
2341
|
+
const now = Date.now();
|
|
2342
|
+
const n = await this.#host.store.revoke({ grantId }, { now, keepUntil: now + this.#host.config.grantLifetimeMs });
|
|
2343
|
+
if (n > 0)
|
|
2344
|
+
this.#host.grantRemoved(addressId, grantId, reason);
|
|
2345
|
+
}
|
|
2346
|
+
async revoke(req, ip) {
|
|
2347
|
+
const noStore = { "cache-control": "no-store" };
|
|
2348
|
+
try {
|
|
2349
|
+
if (!this.#tokenPerIp.take(ip)) {
|
|
2350
|
+
throw new OAuthError(429, "temporarily_unavailable", "too many requests; try again shortly");
|
|
2351
|
+
}
|
|
2352
|
+
const params = await this.#formParams(req);
|
|
2353
|
+
const p = single(params, ["token", "client_id", "token_type_hint"]);
|
|
2354
|
+
if ("duplicate" in p) {
|
|
2355
|
+
throw new OAuthError(400, "invalid_request", `${p.duplicate} was sent more than once`);
|
|
2356
|
+
}
|
|
2357
|
+
const token = p["token"];
|
|
2358
|
+
const clientId = p["client_id"];
|
|
2359
|
+
if (token === undefined || clientId === undefined) {
|
|
2360
|
+
throw new OAuthError(400, "invalid_request", "token and client_id are required");
|
|
2361
|
+
}
|
|
2362
|
+
const record = await this.#host.store.get(hashToken(token));
|
|
2363
|
+
if (record === undefined)
|
|
2364
|
+
return new Response(null, {
|
|
2365
|
+
status: 200,
|
|
2366
|
+
headers: { ...this.#cors(), ...noStore }
|
|
2367
|
+
});
|
|
2368
|
+
if (!constantTimeEqual(clientId, record.clientId)) {
|
|
2369
|
+
throw new OAuthError(400, "invalid_client", "the token was issued to another client");
|
|
2370
|
+
}
|
|
2371
|
+
await this.#revokeGrant(record.addressId, record.grantId, "revoked");
|
|
2372
|
+
this.#host.logger.info("oauth.revoked", {
|
|
2373
|
+
address: shortAddress(record.addressId),
|
|
2374
|
+
client: hostOf(record.clientId),
|
|
2375
|
+
by: "client"
|
|
2376
|
+
});
|
|
2377
|
+
return new Response(null, {
|
|
2378
|
+
status: 200,
|
|
2379
|
+
headers: { ...this.#cors(), ...noStore }
|
|
2380
|
+
});
|
|
2381
|
+
} catch (error) {
|
|
2382
|
+
return this.#oauthErrorResponse(error, noStore);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
#sweepPending() {
|
|
2386
|
+
const now = Date.now();
|
|
2387
|
+
for (const p of this.#pending.values()) {
|
|
2388
|
+
if (p.expiresAt > now)
|
|
2389
|
+
break;
|
|
2390
|
+
this.#dropPending(p);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
#sweepCodes(force = false) {
|
|
2394
|
+
const now = Date.now();
|
|
2395
|
+
for (const [key, c] of this.#codes) {
|
|
2396
|
+
if (c.expiresAt <= now)
|
|
2397
|
+
this.#codes.delete(key);
|
|
2398
|
+
}
|
|
2399
|
+
if (force && this.#codes.size >= MAX_CODES) {
|
|
2400
|
+
const drop = this.#codes.size - MAX_CODES + 1;
|
|
2401
|
+
for (const key of [...this.#codes.keys()].slice(0, drop)) {
|
|
2402
|
+
this.#codes.delete(key);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
sweep() {
|
|
2407
|
+
this.#sweepPending();
|
|
2408
|
+
this.#sweepCodes();
|
|
2409
|
+
const now = Date.now();
|
|
2410
|
+
this.#viewsPerIp.sweep(now);
|
|
2411
|
+
this.#attemptsPerIp.sweep(now);
|
|
2412
|
+
this.#guessesPerAddressIp.sweep(now);
|
|
2413
|
+
this.#guessesPerAddress.sweep(now);
|
|
2414
|
+
this.#tokenPerIp.sweep(now);
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// src/session.ts
|
|
2419
|
+
function limitsOf(limits) {
|
|
2420
|
+
return {
|
|
2421
|
+
maxMessageBytes: limits.maxMessageBytes,
|
|
2422
|
+
maxBodyBytes: limits.maxMessageBytes,
|
|
2423
|
+
requestTimeoutMs: limits.requestTimeoutMs,
|
|
2424
|
+
idleTimeoutMs: limits.idleTimeoutMs,
|
|
2425
|
+
heartbeatMs: limits.heartbeatMs,
|
|
2426
|
+
keepAliveMs: limits.keepAliveMs,
|
|
2427
|
+
maxConcurrentRequests: limits.maxConcurrentRequests
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
function clientHost(clientId) {
|
|
2431
|
+
try {
|
|
2432
|
+
return new URL(clientId).host;
|
|
2433
|
+
} catch {
|
|
2434
|
+
return "";
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
function messageId(parsed) {
|
|
2438
|
+
if ("message" in parsed) {
|
|
2439
|
+
return "id" in parsed.message ? parsed.message.id : undefined;
|
|
2440
|
+
}
|
|
2441
|
+
return parsed.id;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
class TabSession {
|
|
2445
|
+
sessionId = newId("s");
|
|
2446
|
+
ip;
|
|
2447
|
+
origin;
|
|
2448
|
+
site;
|
|
2449
|
+
openedAt = Date.now();
|
|
2450
|
+
addressId;
|
|
2451
|
+
appName;
|
|
2452
|
+
codes;
|
|
2453
|
+
pending = new Map;
|
|
2454
|
+
#ws;
|
|
2455
|
+
#host;
|
|
2456
|
+
#state = "new";
|
|
2457
|
+
#lastRequestAt = Date.now();
|
|
2458
|
+
#missedPongs = 0;
|
|
2459
|
+
#nextRequest = 0;
|
|
2460
|
+
#helloTimer;
|
|
2461
|
+
#heartbeatTimer;
|
|
2462
|
+
#lifetimeTimer;
|
|
2463
|
+
constructor(ws, ip, origin, site, host) {
|
|
2464
|
+
this.#ws = ws;
|
|
2465
|
+
this.ip = ip;
|
|
2466
|
+
this.origin = origin;
|
|
2467
|
+
this.site = site;
|
|
2468
|
+
this.#host = host;
|
|
2469
|
+
this.codes = new PairingCodes((code, expiresAt) => {
|
|
2470
|
+
this.send({
|
|
2471
|
+
type: "pairing.code",
|
|
2472
|
+
code,
|
|
2473
|
+
expiresAt: new Date(expiresAt).toISOString()
|
|
2474
|
+
});
|
|
2475
|
+
}, host.pairingCodeTtlMs === undefined ? {} : { ttlMs: host.pairingCodeTtlMs });
|
|
2476
|
+
this.#helloTimer = setTimeout(() => {
|
|
2477
|
+
if (this.#state === "new") {
|
|
2478
|
+
this.#protocolError("bad_message", "no hello in time");
|
|
2479
|
+
}
|
|
2480
|
+
}, host.config.helloTimeoutMs);
|
|
2481
|
+
}
|
|
2482
|
+
get ready() {
|
|
2483
|
+
return this.#state === "ready";
|
|
2484
|
+
}
|
|
2485
|
+
get limits() {
|
|
2486
|
+
return this.site.limits;
|
|
2487
|
+
}
|
|
2488
|
+
send(message) {
|
|
2489
|
+
if (this.#state === "ended")
|
|
2490
|
+
return false;
|
|
2491
|
+
let sent;
|
|
2492
|
+
try {
|
|
2493
|
+
sent = this.#ws.send(JSON.stringify(message));
|
|
2494
|
+
} catch {
|
|
2495
|
+
return false;
|
|
2496
|
+
}
|
|
2497
|
+
if (sent === 0) {
|
|
2498
|
+
this.#host.logger.warn("tab.send_dropped", {
|
|
2499
|
+
session: this.sessionId,
|
|
2500
|
+
type: message.type
|
|
2501
|
+
});
|
|
2502
|
+
return false;
|
|
2503
|
+
}
|
|
2504
|
+
return true;
|
|
2505
|
+
}
|
|
2506
|
+
handleFrame(data) {
|
|
2507
|
+
if (this.#state === "ended")
|
|
2508
|
+
return;
|
|
2509
|
+
if (typeof data !== "string") {
|
|
2510
|
+
this.#protocolError("bad_message", "binary frames are not allowed");
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
const tooLarge = Buffer.byteLength(data) > this.limits.maxMessageBytes;
|
|
2514
|
+
const parsed = parseTabMessage(data);
|
|
2515
|
+
if (tooLarge) {
|
|
2516
|
+
const id = messageId(parsed);
|
|
2517
|
+
this.#sendError("too_large", "message exceeds maxMessageBytes", id);
|
|
2518
|
+
const exchange = id === undefined ? undefined : this.pending.get(id);
|
|
2519
|
+
exchange?.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
if (!parsed.ok) {
|
|
2523
|
+
if (this.#state === "new") {
|
|
2524
|
+
this.#protocolError("bad_message", parsed.error);
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
this.#sendError("bad_message", parsed.error, parsed.id);
|
|
2528
|
+
const exchange = parsed.id === undefined ? undefined : this.pending.get(parsed.id);
|
|
2529
|
+
exchange?.fail(502, RelayErrorCode.badResponse, "The page sent an invalid response.", true);
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
if ("unknown" in parsed) {
|
|
2533
|
+
if (this.#state === "new") {
|
|
2534
|
+
this.#protocolError("bad_message", "hello expected");
|
|
2535
|
+
} else if (parsed.id !== undefined) {
|
|
2536
|
+
this.#sendError("unknown_type", `unknown type ${parsed.type}`, parsed.id);
|
|
2537
|
+
}
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
this.#handle(parsed.message).catch((error) => {
|
|
2541
|
+
this.#host.logger.error("tab.handler_error", {
|
|
2542
|
+
session: this.sessionId,
|
|
2543
|
+
type: parsed.message.type,
|
|
2544
|
+
message: error instanceof Error ? error.name : "unknown"
|
|
2545
|
+
});
|
|
2546
|
+
this.#sendError("bad_message", "the relay could not handle this message");
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
async#handle(msg) {
|
|
2550
|
+
if (this.#state === "new") {
|
|
2551
|
+
if (msg.type !== "hello") {
|
|
2552
|
+
this.#protocolError("bad_message", "hello expected");
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
await this.#hello(msg.secret, msg.library, msg.app?.name);
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
if (this.#state === "hello")
|
|
2559
|
+
return;
|
|
2560
|
+
switch (msg.type) {
|
|
2561
|
+
case "hello":
|
|
2562
|
+
this.#sendError("bad_message", "hello was already sent");
|
|
2563
|
+
return;
|
|
2564
|
+
case "pong":
|
|
2565
|
+
this.#missedPongs = 0;
|
|
2566
|
+
return;
|
|
2567
|
+
case "bye":
|
|
2568
|
+
this.end(CloseCode.normal, "bye");
|
|
2569
|
+
return;
|
|
2570
|
+
case "pairing.start":
|
|
2571
|
+
if (this.site.pairing === "code")
|
|
2572
|
+
this.codes.start();
|
|
2573
|
+
return;
|
|
2574
|
+
case "pairing.stop":
|
|
2575
|
+
this.codes.stop();
|
|
2576
|
+
return;
|
|
2577
|
+
case "grant.revoke":
|
|
2578
|
+
if (!await this.#host.revokeGrant(this, msg.grantId)) {
|
|
2579
|
+
this.#sendError("unknown_id", "unknown grant");
|
|
2580
|
+
}
|
|
2581
|
+
return;
|
|
2582
|
+
case "grants.revokeAll":
|
|
2583
|
+
await this.#host.revokeAll(this);
|
|
2584
|
+
return;
|
|
2585
|
+
case "error":
|
|
2586
|
+
this.#host.logger.debug("tab.error", {
|
|
2587
|
+
session: this.sessionId,
|
|
2588
|
+
code: msg.code
|
|
2589
|
+
});
|
|
2590
|
+
return;
|
|
2591
|
+
case "http.response":
|
|
2592
|
+
case "http.response.start":
|
|
2593
|
+
case "http.response.chunk":
|
|
2594
|
+
case "http.response.end": {
|
|
2595
|
+
const exchange = this.pending.get(msg.id);
|
|
2596
|
+
if (exchange === undefined)
|
|
2597
|
+
return;
|
|
2598
|
+
if (msg.type === "http.response")
|
|
2599
|
+
exchange.onResponse(msg);
|
|
2600
|
+
else if (msg.type === "http.response.start")
|
|
2601
|
+
exchange.onStart(msg);
|
|
2602
|
+
else if (msg.type === "http.response.chunk")
|
|
2603
|
+
exchange.onChunk(msg.data);
|
|
2604
|
+
else
|
|
2605
|
+
exchange.onEnd();
|
|
2606
|
+
if (exchange.badResponseReason !== undefined) {
|
|
2607
|
+
this.#sendError("bad_message", exchange.badResponseReason, msg.id);
|
|
2608
|
+
}
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
async#hello(secret, library, appName) {
|
|
2614
|
+
if (!isWellFormedSecret(secret)) {
|
|
2615
|
+
this.#protocolError("bad_message", "secret must be 43 base64url characters");
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
clearTimeout(this.#helloTimer);
|
|
2619
|
+
this.#state = "hello";
|
|
2620
|
+
this.addressId = addressIdFromSecret(secret);
|
|
2621
|
+
this.appName = this.site.name ?? appName;
|
|
2622
|
+
const replaced = this.#host.claimAddress(this);
|
|
2623
|
+
if (replaced !== undefined) {
|
|
2624
|
+
replaced.end(CloseCode.replaced, "replaced by a newer session", "replaced");
|
|
2625
|
+
}
|
|
2626
|
+
let grants = [];
|
|
2627
|
+
try {
|
|
2628
|
+
const list = await this.#host.store.listGrants(this.addressId, Date.now());
|
|
2629
|
+
grants = list.map((g) => ({
|
|
2630
|
+
grantId: g.grantId,
|
|
2631
|
+
client: {
|
|
2632
|
+
id: g.clientId,
|
|
2633
|
+
name: g.clientName,
|
|
2634
|
+
host: clientHost(g.clientId)
|
|
2635
|
+
},
|
|
2636
|
+
createdAt: new Date(g.createdAt).toISOString()
|
|
2637
|
+
}));
|
|
2638
|
+
} catch (error) {
|
|
2639
|
+
this.#host.logger.error("store.error", {
|
|
2640
|
+
op: "listGrants",
|
|
2641
|
+
message: error instanceof Error ? error.message : "unknown"
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
if (this.#state !== "hello")
|
|
2645
|
+
return;
|
|
2646
|
+
const limits = this.limits;
|
|
2647
|
+
const expiresAt = this.openedAt + limits.sessionLifetimeMs;
|
|
2648
|
+
this.#state = "ready";
|
|
2649
|
+
const sent = this.send({
|
|
2650
|
+
type: "session",
|
|
2651
|
+
sessionId: this.sessionId,
|
|
2652
|
+
address: `${this.#host.publicUrl()}/a/${this.addressId}/mcp`,
|
|
2653
|
+
origin: this.origin,
|
|
2654
|
+
...this.appName === undefined ? {} : { appName: this.appName },
|
|
2655
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
2656
|
+
limits: limitsOf(limits),
|
|
2657
|
+
grants
|
|
2658
|
+
});
|
|
2659
|
+
if (!sent) {
|
|
2660
|
+
this.end(CloseCode.policyViolation, "send failed");
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
this.#heartbeatTimer = setInterval(() => this.#tick(), limits.heartbeatMs);
|
|
2664
|
+
this.#lifetimeTimer = setTimeout(() => this.end(CloseCode.sessionExpired, "session lifetime reached", "expired"), Math.max(0, expiresAt - Date.now()));
|
|
2665
|
+
this.#host.logger.info("tab.session", {
|
|
2666
|
+
session: this.sessionId,
|
|
2667
|
+
address: shortAddress(this.addressId),
|
|
2668
|
+
origin: this.origin,
|
|
2669
|
+
library: `${library.name}@${library.version}`,
|
|
2670
|
+
replaced: replaced !== undefined
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
#tick() {
|
|
2674
|
+
if (this.#state !== "ready")
|
|
2675
|
+
return;
|
|
2676
|
+
if (this.#missedPongs >= 2) {
|
|
2677
|
+
this.end(CloseCode.heartbeatLost, "heartbeat lost");
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
if (this.pending.size === 0 && Date.now() - this.#lastRequestAt >= this.limits.idleTimeoutMs) {
|
|
2681
|
+
this.end(CloseCode.idleTimeout, "idle", "idle");
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
this.#missedPongs++;
|
|
2685
|
+
this.send({ type: "ping", t: Date.now() });
|
|
2686
|
+
}
|
|
2687
|
+
forward(input) {
|
|
2688
|
+
this.#lastRequestAt = Date.now();
|
|
2689
|
+
const id = `r${++this.#nextRequest}`;
|
|
2690
|
+
const limits = this.limits;
|
|
2691
|
+
const exchange = new Exchange({
|
|
2692
|
+
id,
|
|
2693
|
+
rpcId: input.rpcId,
|
|
2694
|
+
isListen: input.isListen,
|
|
2695
|
+
requestTimeoutMs: limits.requestTimeoutMs,
|
|
2696
|
+
keepAliveMs: limits.keepAliveMs,
|
|
2697
|
+
maxBodyBytes: limitsOf(limits).maxBodyBytes,
|
|
2698
|
+
extraHeaders: input.extraHeaders,
|
|
2699
|
+
cancel: (reason) => {
|
|
2700
|
+
if (!this.send({ type: "http.cancel", id, reason })) {
|
|
2701
|
+
this.#host.logger.debug("tab.cancel_not_sent", {
|
|
2702
|
+
session: this.sessionId
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
},
|
|
2706
|
+
onDone: (e) => {
|
|
2707
|
+
this.pending.delete(id);
|
|
2708
|
+
this.#lastRequestAt = Date.now();
|
|
2709
|
+
input.onDone(e);
|
|
2710
|
+
}
|
|
2711
|
+
});
|
|
2712
|
+
this.pending.set(id, exchange);
|
|
2713
|
+
const sent = this.send({
|
|
2714
|
+
type: "http.request",
|
|
2715
|
+
id,
|
|
2716
|
+
method: input.method,
|
|
2717
|
+
path: "/mcp",
|
|
2718
|
+
headers: input.headers,
|
|
2719
|
+
body: input.body,
|
|
2720
|
+
grant: input.grant
|
|
2721
|
+
});
|
|
2722
|
+
if (!sent) {
|
|
2723
|
+
exchange.fail(503, RelayErrorCode.sendFailed, "The connection to the page is overloaded. Try again.", false);
|
|
2724
|
+
}
|
|
2725
|
+
return exchange;
|
|
2726
|
+
}
|
|
2727
|
+
end(code, reason, bye) {
|
|
2728
|
+
if (this.#state === "ended")
|
|
2729
|
+
return;
|
|
2730
|
+
if (bye !== undefined)
|
|
2731
|
+
this.send({ type: "bye", reason: bye });
|
|
2732
|
+
this.#cleanup(code, reason);
|
|
2733
|
+
try {
|
|
2734
|
+
this.#ws.close(code, reason);
|
|
2735
|
+
} catch {}
|
|
2736
|
+
}
|
|
2737
|
+
closed(code) {
|
|
2738
|
+
this.#cleanup(code, "closed");
|
|
2739
|
+
}
|
|
2740
|
+
#cleanup(code, reason) {
|
|
2741
|
+
if (this.#state === "ended")
|
|
2742
|
+
return;
|
|
2743
|
+
const wasReady = this.#state === "ready" || this.#state === "hello";
|
|
2744
|
+
this.#state = "ended";
|
|
2745
|
+
this.codes.stop();
|
|
2746
|
+
clearTimeout(this.#helloTimer);
|
|
2747
|
+
clearInterval(this.#heartbeatTimer);
|
|
2748
|
+
clearTimeout(this.#lifetimeTimer);
|
|
2749
|
+
for (const exchange of [...this.pending.values()]) {
|
|
2750
|
+
exchange.fail(503, RelayErrorCode.tabGone, "The page closed its connection to the relay.", false);
|
|
2751
|
+
}
|
|
2752
|
+
this.#host.released(this);
|
|
2753
|
+
if (wasReady) {
|
|
2754
|
+
this.#host.logger.info("tab.closed", {
|
|
2755
|
+
session: this.sessionId,
|
|
2756
|
+
code,
|
|
2757
|
+
reason,
|
|
2758
|
+
durationMs: Date.now() - this.openedAt
|
|
2759
|
+
});
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
#sendError(code, message, id) {
|
|
2763
|
+
this.send(id === undefined ? { type: "error", code, message } : { type: "error", code, message, id });
|
|
2764
|
+
}
|
|
2765
|
+
#protocolError(code, message) {
|
|
2766
|
+
this.#sendError(code, message);
|
|
2767
|
+
this.end(CloseCode.policyViolation, message);
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
// src/store/memory.ts
|
|
2772
|
+
function copy(record) {
|
|
2773
|
+
return { ...record, scopes: [...record.scopes] };
|
|
2774
|
+
}
|
|
2775
|
+
function matches(record, selector) {
|
|
2776
|
+
return "grantId" in selector ? record.grantId === selector.grantId : record.addressId === selector.addressId;
|
|
2777
|
+
}
|
|
2778
|
+
function isLive(record, now) {
|
|
2779
|
+
return record.expiresAt > now && (record.kind === "access" || record.usedAt === undefined);
|
|
2780
|
+
}
|
|
2781
|
+
function summarizeGrants(records, now) {
|
|
2782
|
+
const grants = new Map;
|
|
2783
|
+
for (const r of records) {
|
|
2784
|
+
if (!isLive(r, now) || grants.has(r.grantId))
|
|
2785
|
+
continue;
|
|
2786
|
+
grants.set(r.grantId, {
|
|
2787
|
+
grantId: r.grantId,
|
|
2788
|
+
clientId: r.clientId,
|
|
2789
|
+
clientName: r.clientName,
|
|
2790
|
+
scopes: [...r.scopes],
|
|
2791
|
+
createdAt: r.grantCreatedAt
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
2794
|
+
return [...grants.values()].sort((a, b) => a.createdAt - b.createdAt || a.grantId.localeCompare(b.grantId));
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
class MemoryTokenStore {
|
|
2798
|
+
#records = new Map;
|
|
2799
|
+
#revokedGrants = new Map;
|
|
2800
|
+
#revokedAddresses = new Map;
|
|
2801
|
+
async put(record) {
|
|
2802
|
+
if (this.#revokedGrants.has(record.grantId))
|
|
2803
|
+
return false;
|
|
2804
|
+
const address = this.#revokedAddresses.get(record.addressId);
|
|
2805
|
+
if (address !== undefined && record.grantCreatedAt <= address.at) {
|
|
2806
|
+
return false;
|
|
2807
|
+
}
|
|
2808
|
+
this.#records.set(record.hash, copy(record));
|
|
2809
|
+
return true;
|
|
2810
|
+
}
|
|
2811
|
+
async get(hash) {
|
|
2812
|
+
const r = this.#records.get(hash);
|
|
2813
|
+
return r === undefined ? undefined : copy(r);
|
|
2814
|
+
}
|
|
2815
|
+
async markUsed(hash, now) {
|
|
2816
|
+
const r = this.#records.get(hash);
|
|
2817
|
+
if (r === undefined || r.usedAt !== undefined)
|
|
2818
|
+
return false;
|
|
2819
|
+
r.usedAt = now;
|
|
2820
|
+
return true;
|
|
2821
|
+
}
|
|
2822
|
+
async revoke(selector, options) {
|
|
2823
|
+
if ("grantId" in selector) {
|
|
2824
|
+
this.#revokedGrants.set(selector.grantId, options.keepUntil);
|
|
2825
|
+
} else {
|
|
2826
|
+
const old = this.#revokedAddresses.get(selector.addressId);
|
|
2827
|
+
this.#revokedAddresses.set(selector.addressId, {
|
|
2828
|
+
at: Math.max(old?.at ?? 0, options.now),
|
|
2829
|
+
until: Math.max(old?.until ?? 0, options.keepUntil)
|
|
2830
|
+
});
|
|
2831
|
+
}
|
|
2832
|
+
let n = 0;
|
|
2833
|
+
for (const [hash, r] of this.#records) {
|
|
2834
|
+
if (matches(r, selector)) {
|
|
2835
|
+
this.#records.delete(hash);
|
|
2836
|
+
n++;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
return n;
|
|
2840
|
+
}
|
|
2841
|
+
async listGrants(addressId, now) {
|
|
2842
|
+
const own = [...this.#records.values()].filter((r) => r.addressId === addressId);
|
|
2843
|
+
return summarizeGrants(own, now);
|
|
2844
|
+
}
|
|
2845
|
+
async deleteExpired(now) {
|
|
2846
|
+
for (const [id, until] of this.#revokedGrants) {
|
|
2847
|
+
if (until <= now)
|
|
2848
|
+
this.#revokedGrants.delete(id);
|
|
2849
|
+
}
|
|
2850
|
+
for (const [id, t] of this.#revokedAddresses) {
|
|
2851
|
+
if (t.until <= now)
|
|
2852
|
+
this.#revokedAddresses.delete(id);
|
|
2853
|
+
}
|
|
2854
|
+
let n = 0;
|
|
2855
|
+
for (const [hash, r] of this.#records) {
|
|
2856
|
+
if (r.expiresAt <= now) {
|
|
2857
|
+
this.#records.delete(hash);
|
|
2858
|
+
n++;
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
return n;
|
|
2862
|
+
}
|
|
2863
|
+
async close() {
|
|
2864
|
+
this.#records.clear();
|
|
2865
|
+
this.#revokedGrants.clear();
|
|
2866
|
+
this.#revokedAddresses.clear();
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
// src/store/sqlite.ts
|
|
2871
|
+
import { Database } from "bun:sqlite";
|
|
2872
|
+
var SCHEMA_VERSION = 2;
|
|
2873
|
+
function toRecord(row) {
|
|
2874
|
+
const record = {
|
|
2875
|
+
hash: row.hash,
|
|
2876
|
+
kind: row.kind,
|
|
2877
|
+
grantId: row.grant_id,
|
|
2878
|
+
addressId: row.address_id,
|
|
2879
|
+
clientId: row.client_id,
|
|
2880
|
+
clientName: row.client_name,
|
|
2881
|
+
scopes: JSON.parse(row.scopes),
|
|
2882
|
+
grantCreatedAt: row.grant_created_at,
|
|
2883
|
+
issuedAt: row.issued_at,
|
|
2884
|
+
expiresAt: row.expires_at
|
|
2885
|
+
};
|
|
2886
|
+
if (row.used_at !== null)
|
|
2887
|
+
record.usedAt = row.used_at;
|
|
2888
|
+
return record;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
class SqliteTokenStore {
|
|
2892
|
+
#db;
|
|
2893
|
+
constructor(path) {
|
|
2894
|
+
this.#db = new Database(path, { create: true, strict: true });
|
|
2895
|
+
this.#db.run("PRAGMA busy_timeout = 5000");
|
|
2896
|
+
if (path !== ":memory:")
|
|
2897
|
+
this.#db.run("PRAGMA journal_mode = WAL");
|
|
2898
|
+
this.#migrate();
|
|
2899
|
+
}
|
|
2900
|
+
#migrate() {
|
|
2901
|
+
const row = this.#db.query("PRAGMA user_version").get();
|
|
2902
|
+
if (row.user_version >= SCHEMA_VERSION)
|
|
2903
|
+
return;
|
|
2904
|
+
this.#db.transaction(() => {
|
|
2905
|
+
this.#db.run(`CREATE TABLE IF NOT EXISTS revocations (
|
|
2906
|
+
kind TEXT NOT NULL CHECK (kind IN ('grant', 'address')),
|
|
2907
|
+
id TEXT NOT NULL,
|
|
2908
|
+
at INTEGER NOT NULL,
|
|
2909
|
+
keep_until INTEGER NOT NULL,
|
|
2910
|
+
PRIMARY KEY (kind, id)
|
|
2911
|
+
) STRICT`);
|
|
2912
|
+
this.#db.run(`CREATE TABLE IF NOT EXISTS tokens (
|
|
2913
|
+
hash TEXT PRIMARY KEY,
|
|
2914
|
+
kind TEXT NOT NULL CHECK (kind IN ('access', 'refresh')),
|
|
2915
|
+
grant_id TEXT NOT NULL,
|
|
2916
|
+
address_id TEXT NOT NULL,
|
|
2917
|
+
client_id TEXT NOT NULL,
|
|
2918
|
+
client_name TEXT NOT NULL,
|
|
2919
|
+
scopes TEXT NOT NULL,
|
|
2920
|
+
grant_created_at INTEGER NOT NULL,
|
|
2921
|
+
issued_at INTEGER NOT NULL,
|
|
2922
|
+
expires_at INTEGER NOT NULL,
|
|
2923
|
+
used_at INTEGER
|
|
2924
|
+
) STRICT`);
|
|
2925
|
+
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
|
|
2926
|
+
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
|
|
2927
|
+
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
|
|
2928
|
+
this.#db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
2929
|
+
})();
|
|
2930
|
+
}
|
|
2931
|
+
async put(r) {
|
|
2932
|
+
return this.#db.transaction(() => {
|
|
2933
|
+
const revoked = this.#db.query(`SELECT 1 FROM revocations WHERE (kind = 'grant' AND id = $grant_id)
|
|
2934
|
+
OR (kind = 'address' AND id = $address_id AND at >= $created)`).get({
|
|
2935
|
+
grant_id: r.grantId,
|
|
2936
|
+
address_id: r.addressId,
|
|
2937
|
+
created: r.grantCreatedAt
|
|
2938
|
+
});
|
|
2939
|
+
if (revoked !== null)
|
|
2940
|
+
return false;
|
|
2941
|
+
this.#insert(r);
|
|
2942
|
+
return true;
|
|
2943
|
+
})();
|
|
2944
|
+
}
|
|
2945
|
+
#insert(r) {
|
|
2946
|
+
this.#db.query(`INSERT OR REPLACE INTO tokens (hash, kind, grant_id, address_id, client_id,
|
|
2947
|
+
client_name, scopes, grant_created_at, issued_at, expires_at, used_at)
|
|
2948
|
+
VALUES ($hash, $kind, $grant_id, $address_id, $client_id, $client_name,
|
|
2949
|
+
$scopes, $grant_created_at, $issued_at, $expires_at, $used_at)`).run({
|
|
2950
|
+
hash: r.hash,
|
|
2951
|
+
kind: r.kind,
|
|
2952
|
+
grant_id: r.grantId,
|
|
2953
|
+
address_id: r.addressId,
|
|
2954
|
+
client_id: r.clientId,
|
|
2955
|
+
client_name: r.clientName,
|
|
2956
|
+
scopes: JSON.stringify(r.scopes),
|
|
2957
|
+
grant_created_at: r.grantCreatedAt,
|
|
2958
|
+
issued_at: r.issuedAt,
|
|
2959
|
+
expires_at: r.expiresAt,
|
|
2960
|
+
used_at: r.usedAt ?? null
|
|
2961
|
+
});
|
|
2962
|
+
}
|
|
2963
|
+
async get(hash) {
|
|
2964
|
+
const row = this.#db.query("SELECT * FROM tokens WHERE hash = $hash").get({ hash });
|
|
2965
|
+
return row === null ? undefined : toRecord(row);
|
|
2966
|
+
}
|
|
2967
|
+
async markUsed(hash, now) {
|
|
2968
|
+
const result = this.#db.query("UPDATE tokens SET used_at = $now WHERE hash = $hash AND used_at IS NULL").run({ hash, now });
|
|
2969
|
+
return result.changes === 1;
|
|
2970
|
+
}
|
|
2971
|
+
async revoke(selector, options) {
|
|
2972
|
+
return this.#db.transaction(() => {
|
|
2973
|
+
this.#db.query(`INSERT INTO revocations (kind, id, at, keep_until) VALUES ($kind, $id, $at, $until)
|
|
2974
|
+
ON CONFLICT (kind, id) DO UPDATE SET at = max(at, excluded.at),
|
|
2975
|
+
keep_until = max(keep_until, excluded.keep_until)`).run({
|
|
2976
|
+
kind: "grantId" in selector ? "grant" : "address",
|
|
2977
|
+
id: "grantId" in selector ? selector.grantId : selector.addressId,
|
|
2978
|
+
at: options.now,
|
|
2979
|
+
until: options.keepUntil
|
|
2980
|
+
});
|
|
2981
|
+
return this.#delete(selector);
|
|
2982
|
+
})();
|
|
2983
|
+
}
|
|
2984
|
+
#delete(selector) {
|
|
2985
|
+
const result = "grantId" in selector ? this.#db.query("DELETE FROM tokens WHERE grant_id = $id").run({ id: selector.grantId }) : this.#db.query("DELETE FROM tokens WHERE address_id = $id").run({ id: selector.addressId });
|
|
2986
|
+
return result.changes;
|
|
2987
|
+
}
|
|
2988
|
+
async listGrants(addressId, now) {
|
|
2989
|
+
const rows = this.#db.query(`SELECT * FROM tokens WHERE address_id = $addressId AND expires_at > $now
|
|
2990
|
+
AND (kind = 'access' OR used_at IS NULL)`).all({ addressId, now });
|
|
2991
|
+
return summarizeGrants(rows.map(toRecord), now);
|
|
2992
|
+
}
|
|
2993
|
+
async deleteExpired(now) {
|
|
2994
|
+
this.#db.query("DELETE FROM revocations WHERE keep_until <= $now").run({ now });
|
|
2995
|
+
return this.#db.query("DELETE FROM tokens WHERE expires_at <= $now").run({ now }).changes;
|
|
2996
|
+
}
|
|
2997
|
+
async close() {
|
|
2998
|
+
this.#db.close();
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
// src/relay.ts
|
|
3003
|
+
var VERSION = version;
|
|
3004
|
+
var CLAUDE_MISMATCHED_HEADER = "2025-11-25";
|
|
3005
|
+
var FORWARDED_HEADERS = new Set([
|
|
3006
|
+
"content-type",
|
|
3007
|
+
"accept",
|
|
3008
|
+
"mcp-method",
|
|
3009
|
+
"mcp-name",
|
|
3010
|
+
"traceparent",
|
|
3011
|
+
"tracestate"
|
|
3012
|
+
]);
|
|
3013
|
+
function json(status, value, headers) {
|
|
3014
|
+
const h = new Headers(headers);
|
|
3015
|
+
h.set("content-type", "application/json");
|
|
3016
|
+
return new Response(JSON.stringify(value), { status, headers: h });
|
|
3017
|
+
}
|
|
3018
|
+
function rpcError(status, rpcId, code, message, headers, data) {
|
|
3019
|
+
return new Response(rpcErrorBody(rpcId, code, message, data), {
|
|
3020
|
+
status,
|
|
3021
|
+
headers: { ...headers, "content-type": "application/json" }
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
3024
|
+
function rpcIdOf(body) {
|
|
3025
|
+
if (!("id" in body))
|
|
3026
|
+
return;
|
|
3027
|
+
const id = body["id"];
|
|
3028
|
+
return typeof id === "string" || typeof id === "number" ? id : null;
|
|
3029
|
+
}
|
|
3030
|
+
function methodForLog(body) {
|
|
3031
|
+
const m = body["method"];
|
|
3032
|
+
return typeof m === "string" && /^[A-Za-z0-9_/.-]{1,64}$/.test(m) ? m : "?";
|
|
3033
|
+
}
|
|
3034
|
+
function metaVersion(body) {
|
|
3035
|
+
const params = body["params"];
|
|
3036
|
+
if (typeof params !== "object" || params === null)
|
|
3037
|
+
return;
|
|
3038
|
+
const meta = params["_meta"];
|
|
3039
|
+
if (typeof meta !== "object" || meta === null)
|
|
3040
|
+
return;
|
|
3041
|
+
return meta[PROTOCOL_VERSION_META_KEY];
|
|
3042
|
+
}
|
|
3043
|
+
var INDEX_HTML = `<!doctype html>
|
|
3044
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3045
|
+
<title>tab-mcp relay</title></head>
|
|
3046
|
+
<body><h1>tab-mcp relay</h1>
|
|
3047
|
+
<p>This server relays AI apps' MCP requests to tools offered by web pages open in a browser tab.
|
|
3048
|
+
It does not log request contents. Source and documentation:
|
|
3049
|
+
<a href="https://github.com/ractive/tab-mcp">github.com/ractive/tab-mcp</a>.</p>
|
|
3050
|
+
</body></html>`;
|
|
3051
|
+
function createRelay(options = {}) {
|
|
3052
|
+
return startRelay(options, undefined);
|
|
3053
|
+
}
|
|
3054
|
+
function startRelay(options, hooks) {
|
|
3055
|
+
const { store: storeOption, logger: givenLogger, ...partial } = options;
|
|
3056
|
+
const tokenStore = typeof storeOption === "object" ? storeOption : undefined;
|
|
3057
|
+
const config = resolveConfig(typeof storeOption === "string" ? { ...partial, store: storeOption } : partial);
|
|
3058
|
+
const logger = givenLogger ?? createLogger(config.logLevel);
|
|
3059
|
+
const ownsStore = tokenStore === undefined;
|
|
3060
|
+
const store = tokenStore ?? (config.store === "sqlite" ? new SqliteTokenStore(config.sqlitePath) : new MemoryTokenStore);
|
|
3061
|
+
const sessions = new Map;
|
|
3062
|
+
const allSessions = new Set;
|
|
3063
|
+
const perIp = new Map;
|
|
3064
|
+
let claudeWorkarounds = 0;
|
|
3065
|
+
let publicUrl = config.publicUrl ?? "";
|
|
3066
|
+
const corsHeaders = config.cors ? {
|
|
3067
|
+
"access-control-allow-origin": "*",
|
|
3068
|
+
"access-control-expose-headers": "www-authenticate, mcp-protocol-version"
|
|
3069
|
+
} : {};
|
|
3070
|
+
const revokeOptions = () => {
|
|
3071
|
+
const now = Date.now();
|
|
3072
|
+
return { now, keepUntil: now + config.grantLifetimeMs };
|
|
3073
|
+
};
|
|
3074
|
+
function grantRemoved(addressId, grantId, reason) {
|
|
3075
|
+
const session = sessions.get(addressId);
|
|
3076
|
+
if (session?.ready)
|
|
3077
|
+
session.send({ type: "grant.removed", grantId, reason });
|
|
3078
|
+
}
|
|
3079
|
+
const host = {
|
|
3080
|
+
config,
|
|
3081
|
+
logger,
|
|
3082
|
+
store,
|
|
3083
|
+
...hooks?.pairingCodeTtlMs === undefined ? {} : { pairingCodeTtlMs: hooks.pairingCodeTtlMs },
|
|
3084
|
+
publicUrl: () => publicUrl,
|
|
3085
|
+
async revokeGrant(session, grantId) {
|
|
3086
|
+
const addressId = session.addressId;
|
|
3087
|
+
if (addressId === undefined)
|
|
3088
|
+
return false;
|
|
3089
|
+
const grants = await store.listGrants(addressId, Date.now());
|
|
3090
|
+
if (!grants.some((g) => g.grantId === grantId))
|
|
3091
|
+
return false;
|
|
3092
|
+
await store.revoke({ grantId }, revokeOptions());
|
|
3093
|
+
logger.info("oauth.revoked", {
|
|
3094
|
+
address: shortAddress(addressId),
|
|
3095
|
+
by: "tab"
|
|
3096
|
+
});
|
|
3097
|
+
grantRemoved(addressId, grantId, "revoked");
|
|
3098
|
+
return true;
|
|
3099
|
+
},
|
|
3100
|
+
async revokeAll(session) {
|
|
3101
|
+
const addressId = session.addressId;
|
|
3102
|
+
if (addressId === undefined)
|
|
3103
|
+
return;
|
|
3104
|
+
const grants = await store.listGrants(addressId, Date.now());
|
|
3105
|
+
await store.revoke({ addressId }, revokeOptions());
|
|
3106
|
+
logger.info("oauth.revoked_all", {
|
|
3107
|
+
address: shortAddress(addressId),
|
|
3108
|
+
grants: grants.length
|
|
3109
|
+
});
|
|
3110
|
+
for (const g of grants)
|
|
3111
|
+
grantRemoved(addressId, g.grantId, "revoked");
|
|
3112
|
+
},
|
|
3113
|
+
claimAddress(session) {
|
|
3114
|
+
const id = session.addressId;
|
|
3115
|
+
if (id === undefined)
|
|
3116
|
+
return;
|
|
3117
|
+
const previous = sessions.get(id);
|
|
3118
|
+
sessions.set(id, session);
|
|
3119
|
+
return previous === session ? undefined : previous;
|
|
3120
|
+
},
|
|
3121
|
+
released(session) {
|
|
3122
|
+
allSessions.delete(session);
|
|
3123
|
+
if (session.addressId !== undefined && sessions.get(session.addressId) === session) {
|
|
3124
|
+
sessions.delete(session.addressId);
|
|
3125
|
+
}
|
|
3126
|
+
releaseIp(session.ip);
|
|
3127
|
+
}
|
|
3128
|
+
};
|
|
3129
|
+
function releaseIp(ip) {
|
|
3130
|
+
const n = (perIp.get(ip) ?? 1) - 1;
|
|
3131
|
+
if (n <= 0)
|
|
3132
|
+
perIp.delete(ip);
|
|
3133
|
+
else
|
|
3134
|
+
perIp.set(ip, n);
|
|
3135
|
+
}
|
|
3136
|
+
function clientIp(req, server) {
|
|
3137
|
+
if (config.trustProxy === "x-real-ip") {
|
|
3138
|
+
const v = req.headers.get("x-real-ip")?.trim();
|
|
3139
|
+
if (v)
|
|
3140
|
+
return v;
|
|
3141
|
+
} else if (config.trustProxy === "x-forwarded-for") {
|
|
3142
|
+
const parts = (req.headers.get("x-forwarded-for") ?? "").split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
3143
|
+
const last = parts.at(-1);
|
|
3144
|
+
if (last !== undefined)
|
|
3145
|
+
return last;
|
|
3146
|
+
}
|
|
3147
|
+
return server.requestIP(req)?.address ?? "unknown";
|
|
3148
|
+
}
|
|
3149
|
+
const auth = new AuthorizationServer({
|
|
3150
|
+
config,
|
|
3151
|
+
logger,
|
|
3152
|
+
store,
|
|
3153
|
+
publicUrl: () => publicUrl,
|
|
3154
|
+
session: (addressId) => {
|
|
3155
|
+
const s = sessions.get(addressId);
|
|
3156
|
+
return s?.ready === true ? s : undefined;
|
|
3157
|
+
},
|
|
3158
|
+
grantAdded: (addressId, grant) => {
|
|
3159
|
+
const session = sessions.get(addressId);
|
|
3160
|
+
if (session?.ready)
|
|
3161
|
+
session.send({ type: "grant.added", grant });
|
|
3162
|
+
},
|
|
3163
|
+
grantRemoved: (addressId, grantId, reason) => grantRemoved(addressId, grantId, reason)
|
|
3164
|
+
}, hooks);
|
|
3165
|
+
if (hooks !== undefined) {
|
|
3166
|
+
logger.warn("relay.test_hooks", {
|
|
3167
|
+
message: "test hooks are on: never use this relay outside tests"
|
|
3168
|
+
});
|
|
3169
|
+
}
|
|
3170
|
+
function handleTab(req, server) {
|
|
3171
|
+
const origin = req.headers.get("origin");
|
|
3172
|
+
const site = siteFor(config, origin);
|
|
3173
|
+
if (site === undefined) {
|
|
3174
|
+
logger.info("tab.refused", { reason: "origin" });
|
|
3175
|
+
return json(403, { error: "origin_not_allowed" });
|
|
3176
|
+
}
|
|
3177
|
+
const offered = (req.headers.get("sec-websocket-protocol") ?? "").split(",").map((s) => s.trim());
|
|
3178
|
+
if (!offered.includes(SUBPROTOCOL)) {
|
|
3179
|
+
return json(426, {
|
|
3180
|
+
error: "unsupported_protocol",
|
|
3181
|
+
supported: [SUBPROTOCOL]
|
|
3182
|
+
});
|
|
3183
|
+
}
|
|
3184
|
+
const realIp = clientIp(req, server);
|
|
3185
|
+
const ip = ipPrefixKey(realIp);
|
|
3186
|
+
if (allSessions.size >= config.maxSessions) {
|
|
3187
|
+
logger.warn("tab.refused", { reason: "max_sessions" });
|
|
3188
|
+
return json(429, { error: "too_many_sessions" });
|
|
3189
|
+
}
|
|
3190
|
+
const count = perIp.get(ip) ?? 0;
|
|
3191
|
+
if (count >= config.maxSessionsPerIp) {
|
|
3192
|
+
logger.warn("tab.refused", {
|
|
3193
|
+
reason: "max_sessions_per_ip",
|
|
3194
|
+
ip: truncateIp(realIp)
|
|
3195
|
+
});
|
|
3196
|
+
return json(429, { error: "too_many_sessions" });
|
|
3197
|
+
}
|
|
3198
|
+
perIp.set(ip, count + 1);
|
|
3199
|
+
const ok = server.upgrade(req, {
|
|
3200
|
+
data: { ip, origin: origin ?? "", site },
|
|
3201
|
+
headers: { "Sec-WebSocket-Protocol": SUBPROTOCOL }
|
|
3202
|
+
});
|
|
3203
|
+
if (ok)
|
|
3204
|
+
return;
|
|
3205
|
+
releaseIp(ip);
|
|
3206
|
+
return json(400, { error: "websocket_upgrade_expected" });
|
|
3207
|
+
}
|
|
3208
|
+
function wwwAuthenticate(id, presented) {
|
|
3209
|
+
const metadata = `${publicUrl}/.well-known/oauth-protected-resource/a/${id}/mcp`;
|
|
3210
|
+
const error = presented ? ', error="invalid_token"' : "";
|
|
3211
|
+
return `Bearer resource_metadata="${metadata}", scope="tools"${error}`;
|
|
3212
|
+
}
|
|
3213
|
+
async function authenticate(req, addressId) {
|
|
3214
|
+
const header = req.headers.get("authorization");
|
|
3215
|
+
if (header === null)
|
|
3216
|
+
return "missing";
|
|
3217
|
+
const match = /^Bearer ([A-Za-z0-9._~+/-]+=*)$/i.exec(header.trim());
|
|
3218
|
+
const token = match?.[1];
|
|
3219
|
+
if (token === undefined)
|
|
3220
|
+
return "invalid";
|
|
3221
|
+
let record;
|
|
3222
|
+
try {
|
|
3223
|
+
record = await store.get(hashToken(token));
|
|
3224
|
+
} catch (error) {
|
|
3225
|
+
logger.error("store.error", {
|
|
3226
|
+
op: "get",
|
|
3227
|
+
message: error instanceof Error ? error.message : "unknown"
|
|
3228
|
+
});
|
|
3229
|
+
return "unavailable";
|
|
3230
|
+
}
|
|
3231
|
+
if (record === undefined || record.kind !== "access" || record.addressId !== addressId || record.expiresAt <= Date.now() || !record.scopes.includes("tools")) {
|
|
3232
|
+
return "invalid";
|
|
3233
|
+
}
|
|
3234
|
+
return record;
|
|
3235
|
+
}
|
|
3236
|
+
async function readBody(req, maxBytes) {
|
|
3237
|
+
const length = Number(req.headers.get("content-length") ?? "0");
|
|
3238
|
+
if (length > maxBytes) {
|
|
3239
|
+
return rpcError(413, undefined, -32600, "Request body too large.", corsHeaders);
|
|
3240
|
+
}
|
|
3241
|
+
let bytes;
|
|
3242
|
+
try {
|
|
3243
|
+
const reading = req.arrayBuffer();
|
|
3244
|
+
reading.catch(() => {});
|
|
3245
|
+
let timer;
|
|
3246
|
+
const deadline = new Promise((resolve) => {
|
|
3247
|
+
timer = setTimeout(() => resolve("timeout"), config.bodyTimeoutMs);
|
|
3248
|
+
});
|
|
3249
|
+
const result = await Promise.race([reading, deadline]);
|
|
3250
|
+
clearTimeout(timer);
|
|
3251
|
+
if (result === "timeout") {
|
|
3252
|
+
return rpcError(408, undefined, -32600, "The request body did not arrive in time.", { ...corsHeaders, connection: "close" });
|
|
3253
|
+
}
|
|
3254
|
+
bytes = result;
|
|
3255
|
+
} catch {
|
|
3256
|
+
return rpcError(400, undefined, -32700, "Could not read the request body.", corsHeaders);
|
|
3257
|
+
}
|
|
3258
|
+
if (bytes.byteLength > maxBytes) {
|
|
3259
|
+
return rpcError(413, undefined, -32600, "Request body too large.", corsHeaders);
|
|
3260
|
+
}
|
|
3261
|
+
let text;
|
|
3262
|
+
let value;
|
|
3263
|
+
try {
|
|
3264
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
3265
|
+
value = JSON.parse(text);
|
|
3266
|
+
} catch {
|
|
3267
|
+
return rpcError(400, undefined, -32700, "Parse error.", corsHeaders);
|
|
3268
|
+
}
|
|
3269
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
3270
|
+
return rpcError(400, undefined, -32600, "The body must be one JSON-RPC message (no batches).", corsHeaders);
|
|
3271
|
+
}
|
|
3272
|
+
return { value, text };
|
|
3273
|
+
}
|
|
3274
|
+
function checkVersion(req, body, rpcId) {
|
|
3275
|
+
const header = req.headers.get("mcp-protocol-version");
|
|
3276
|
+
if (body["method"] === "initialize") {
|
|
3277
|
+
const params = body["params"];
|
|
3278
|
+
const requested = typeof params?.["protocolVersion"] === "string" ? params["protocolVersion"] : header ?? null;
|
|
3279
|
+
return rpcError(400, rpcId, -32022, `Unsupported protocol version: ${String(requested)}. This server supports only ${MCP_PROTOCOL_VERSION}.`, corsHeaders, { supported: [MCP_PROTOCOL_VERSION], requested });
|
|
3280
|
+
}
|
|
3281
|
+
if (header === MCP_PROTOCOL_VERSION)
|
|
3282
|
+
return { forwardVersion: header };
|
|
3283
|
+
if (header === CLAUDE_MISMATCHED_HEADER && metaVersion(body) === MCP_PROTOCOL_VERSION) {
|
|
3284
|
+
if (++claudeWorkarounds === 1) {
|
|
3285
|
+
logger.info("mcp.claude_header_workaround", {
|
|
3286
|
+
message: "accepting MCP-Protocol-Version 2025-11-25 with a 2026-07-28 body (claude-code #93290); counted in /healthz"
|
|
3287
|
+
});
|
|
3288
|
+
}
|
|
3289
|
+
return { forwardVersion: MCP_PROTOCOL_VERSION };
|
|
3290
|
+
}
|
|
3291
|
+
if (header === null) {
|
|
3292
|
+
return rpcError(400, rpcId, -32020, "Missing MCP-Protocol-Version header.", corsHeaders);
|
|
3293
|
+
}
|
|
3294
|
+
return rpcError(400, rpcId, -32022, `Unsupported protocol version: ${header}. This server supports only ${MCP_PROTOCOL_VERSION}.`, corsHeaders, { supported: [MCP_PROTOCOL_VERSION], requested: header });
|
|
3295
|
+
}
|
|
3296
|
+
async function handleMcp(req, server, addressId) {
|
|
3297
|
+
const started = Date.now();
|
|
3298
|
+
const logDone = (status, method, extra = {}) => logger.info("mcp.request", {
|
|
3299
|
+
address: shortAddress(addressId),
|
|
3300
|
+
method,
|
|
3301
|
+
status,
|
|
3302
|
+
durationMs: Date.now() - started,
|
|
3303
|
+
...extra
|
|
3304
|
+
});
|
|
3305
|
+
const body = await readBody(req, sessions.get(addressId)?.limits.maxMessageBytes ?? config.maxMessageBytes);
|
|
3306
|
+
if (body instanceof Response) {
|
|
3307
|
+
logDone(body.status, "?");
|
|
3308
|
+
return body;
|
|
3309
|
+
}
|
|
3310
|
+
const rpcId = rpcIdOf(body.value);
|
|
3311
|
+
const method = methodForLog(body.value);
|
|
3312
|
+
const version = checkVersion(req, body.value, rpcId);
|
|
3313
|
+
if (version instanceof Response) {
|
|
3314
|
+
logDone(version.status, method);
|
|
3315
|
+
return version;
|
|
3316
|
+
}
|
|
3317
|
+
const auth = await authenticate(req, addressId);
|
|
3318
|
+
if (auth === "unavailable") {
|
|
3319
|
+
logDone(503, method, { reason: "store_unavailable" });
|
|
3320
|
+
return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after": "5" });
|
|
3321
|
+
}
|
|
3322
|
+
if (typeof auth === "string") {
|
|
3323
|
+
logDone(401, method);
|
|
3324
|
+
return json(401, {
|
|
3325
|
+
error: "invalid_token",
|
|
3326
|
+
error_description: auth === "missing" ? "Authorization required." : "The access token is not valid."
|
|
3327
|
+
}, {
|
|
3328
|
+
...corsHeaders,
|
|
3329
|
+
"www-authenticate": wwwAuthenticate(addressId, auth === "invalid")
|
|
3330
|
+
});
|
|
3331
|
+
}
|
|
3332
|
+
const session = sessions.get(addressId);
|
|
3333
|
+
if (session === undefined || !session.ready) {
|
|
3334
|
+
logDone(503, method, { reason: "page_not_open" });
|
|
3335
|
+
return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, 'The page is not open. Open the web page that offers these tools and click "Connect your AI tool".', corsHeaders);
|
|
3336
|
+
}
|
|
3337
|
+
if (session.pending.size >= session.limits.maxConcurrentRequests) {
|
|
3338
|
+
logDone(429, method);
|
|
3339
|
+
return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many requests at once for this page.", corsHeaders);
|
|
3340
|
+
}
|
|
3341
|
+
const headers = {};
|
|
3342
|
+
for (const [k, v] of req.headers) {
|
|
3343
|
+
const key = k.toLowerCase();
|
|
3344
|
+
if (FORWARDED_HEADERS.has(key) || key.startsWith("mcp-param-"))
|
|
3345
|
+
headers[key] = v;
|
|
3346
|
+
}
|
|
3347
|
+
headers["mcp-protocol-version"] = version.forwardVersion;
|
|
3348
|
+
const clientHostName = (() => {
|
|
3349
|
+
try {
|
|
3350
|
+
return new URL(auth.clientId).host;
|
|
3351
|
+
} catch {
|
|
3352
|
+
return "";
|
|
3353
|
+
}
|
|
3354
|
+
})();
|
|
3355
|
+
if (req.signal.aborted) {
|
|
3356
|
+
logDone(499, method, { outcome: "client_closed" });
|
|
3357
|
+
return new Response(null, { status: 499 });
|
|
3358
|
+
}
|
|
3359
|
+
server.timeout(req, 0);
|
|
3360
|
+
const exchange = session.forward({
|
|
3361
|
+
method: "POST",
|
|
3362
|
+
headers,
|
|
3363
|
+
body: body.text,
|
|
3364
|
+
rpcId,
|
|
3365
|
+
isListen: body.value["method"] === "subscriptions/listen",
|
|
3366
|
+
grant: {
|
|
3367
|
+
grantId: auth.grantId,
|
|
3368
|
+
clientId: auth.clientId,
|
|
3369
|
+
scopes: auth.scopes
|
|
3370
|
+
},
|
|
3371
|
+
extraHeaders: corsHeaders,
|
|
3372
|
+
onDone: (e) => logDone(e.status, method, {
|
|
3373
|
+
session: session.sessionId,
|
|
3374
|
+
client: clientHostName,
|
|
3375
|
+
outcome: e.outcome
|
|
3376
|
+
})
|
|
3377
|
+
});
|
|
3378
|
+
req.signal.addEventListener("abort", () => exchange.clientClosed(), {
|
|
3379
|
+
once: true
|
|
3380
|
+
});
|
|
3381
|
+
return exchange.response;
|
|
3382
|
+
}
|
|
3383
|
+
function preflight(req) {
|
|
3384
|
+
if (!config.cors)
|
|
3385
|
+
return new Response(null, { status: 405, headers: { allow: "POST" } });
|
|
3386
|
+
const requested = req.headers.get("access-control-request-headers") ?? "";
|
|
3387
|
+
return new Response(null, {
|
|
3388
|
+
status: 204,
|
|
3389
|
+
headers: {
|
|
3390
|
+
...corsHeaders,
|
|
3391
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
3392
|
+
"access-control-allow-headers": requested !== "" ? requested : "authorization, content-type, accept, mcp-protocol-version, mcp-method, mcp-name",
|
|
3393
|
+
"access-control-max-age": "600"
|
|
3394
|
+
}
|
|
3395
|
+
});
|
|
3396
|
+
}
|
|
3397
|
+
const methodNotAllowed = () => new Response(null, {
|
|
3398
|
+
status: 405,
|
|
3399
|
+
headers: { ...corsHeaders, allow: "POST" }
|
|
3400
|
+
});
|
|
3401
|
+
const postOnly = () => new Response(null, { status: 405, headers: { allow: "POST, OPTIONS" } });
|
|
3402
|
+
const largest = largestMessageBytes(config);
|
|
3403
|
+
const ipOf = (req, srv) => ipPrefixKey(clientIp(req, srv));
|
|
3404
|
+
const server = Bun.serve({
|
|
3405
|
+
port: config.port,
|
|
3406
|
+
hostname: config.host,
|
|
3407
|
+
development: false,
|
|
3408
|
+
maxRequestBodySize: largest + 1024,
|
|
3409
|
+
idleTimeout: config.httpIdleTimeoutS,
|
|
3410
|
+
routes: {
|
|
3411
|
+
"/healthz": {
|
|
3412
|
+
GET: () => json(200, {
|
|
3413
|
+
status: "ok",
|
|
3414
|
+
version: VERSION,
|
|
3415
|
+
counters: { claudeHeaderWorkaround: claudeWorkarounds }
|
|
3416
|
+
})
|
|
3417
|
+
},
|
|
3418
|
+
"/": {
|
|
3419
|
+
GET: () => new Response(INDEX_HTML, {
|
|
3420
|
+
headers: {
|
|
3421
|
+
"content-type": "text/html; charset=utf-8",
|
|
3422
|
+
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
|
3423
|
+
"x-content-type-options": "nosniff"
|
|
3424
|
+
}
|
|
3425
|
+
})
|
|
3426
|
+
},
|
|
3427
|
+
"/tab": (req, srv) => handleTab(req, srv),
|
|
3428
|
+
"/.well-known/oauth-authorization-server": {
|
|
3429
|
+
GET: () => auth.metadata(),
|
|
3430
|
+
OPTIONS: () => auth.preflight("GET")
|
|
3431
|
+
},
|
|
3432
|
+
"/.well-known/oauth-protected-resource/a/:id/mcp": {
|
|
3433
|
+
GET: (req) => auth.protectedResource(req.params.id),
|
|
3434
|
+
OPTIONS: () => auth.preflight("GET")
|
|
3435
|
+
},
|
|
3436
|
+
"/authorize": {
|
|
3437
|
+
GET: (req, srv) => auth.authorize(req, ipOf(req, srv)),
|
|
3438
|
+
POST: (req, srv) => auth.authorizeSubmit(req, ipOf(req, srv))
|
|
3439
|
+
},
|
|
3440
|
+
"/authorize.css": { GET: () => auth.stylesheet() },
|
|
3441
|
+
"/token": {
|
|
3442
|
+
POST: (req, srv) => auth.token(req, ipOf(req, srv)),
|
|
3443
|
+
OPTIONS: () => auth.preflight("POST"),
|
|
3444
|
+
GET: postOnly
|
|
3445
|
+
},
|
|
3446
|
+
"/revoke": {
|
|
3447
|
+
POST: (req, srv) => auth.revoke(req, ipOf(req, srv)),
|
|
3448
|
+
OPTIONS: () => auth.preflight("POST"),
|
|
3449
|
+
GET: postOnly
|
|
3450
|
+
},
|
|
3451
|
+
"/a/:id/mcp": {
|
|
3452
|
+
POST: (req, srv) => {
|
|
3453
|
+
const id = req.params.id;
|
|
3454
|
+
if (!isAddressId(id))
|
|
3455
|
+
return new Response("Not found", { status: 404 });
|
|
3456
|
+
return handleMcp(req, srv, id);
|
|
3457
|
+
},
|
|
3458
|
+
OPTIONS: (req) => preflight(req),
|
|
3459
|
+
GET: methodNotAllowed,
|
|
3460
|
+
DELETE: methodNotAllowed,
|
|
3461
|
+
PUT: methodNotAllowed,
|
|
3462
|
+
PATCH: methodNotAllowed
|
|
3463
|
+
}
|
|
3464
|
+
},
|
|
3465
|
+
fetch: () => new Response("Not found", { status: 404 }),
|
|
3466
|
+
websocket: {
|
|
3467
|
+
data: {},
|
|
3468
|
+
maxPayloadLength: largest * 4,
|
|
3469
|
+
backpressureLimit: config.backpressureLimitBytes,
|
|
3470
|
+
closeOnBackpressureLimit: false,
|
|
3471
|
+
idleTimeout: 120,
|
|
3472
|
+
sendPings: true,
|
|
3473
|
+
perMessageDeflate: false,
|
|
3474
|
+
open(ws) {
|
|
3475
|
+
const session = new TabSession(ws, ws.data.ip, ws.data.origin, ws.data.site, host);
|
|
3476
|
+
ws.data.session = session;
|
|
3477
|
+
allSessions.add(session);
|
|
3478
|
+
},
|
|
3479
|
+
message(ws, message) {
|
|
3480
|
+
ws.data.session?.handleFrame(typeof message === "string" ? message : new Uint8Array(message));
|
|
3481
|
+
},
|
|
3482
|
+
close(ws, code) {
|
|
3483
|
+
ws.data.session?.closed(code);
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
});
|
|
3487
|
+
const port = server.port ?? config.port;
|
|
3488
|
+
if (publicUrl === "")
|
|
3489
|
+
publicUrl = `http://localhost:${port}`;
|
|
3490
|
+
const sweep = setInterval(() => {
|
|
3491
|
+
auth.sweep();
|
|
3492
|
+
store.deleteExpired(Date.now()).then((n) => {
|
|
3493
|
+
if (n > 0)
|
|
3494
|
+
logger.debug("store.swept", { deleted: n });
|
|
3495
|
+
}).catch((error) => logger.error("store.error", {
|
|
3496
|
+
op: "deleteExpired",
|
|
3497
|
+
message: error instanceof Error ? error.message : "unknown"
|
|
3498
|
+
}));
|
|
3499
|
+
}, config.sweepIntervalMs);
|
|
3500
|
+
const siteCount = config.allowedOrigins === "*" ? "*" : config.allowedOrigins.length + config.sites.length;
|
|
3501
|
+
if (config.allowedOrigins === "*") {
|
|
3502
|
+
logger.warn("relay.open_relay", {
|
|
3503
|
+
message: "TAB_MCP_ALLOWED_ORIGINS=* lets any website open tab sessions on this relay"
|
|
3504
|
+
});
|
|
3505
|
+
} else if (siteCount === 0) {
|
|
3506
|
+
logger.warn("relay.no_origins", {
|
|
3507
|
+
message: "TAB_MCP_ALLOWED_ORIGINS and TAB_MCP_SITES are empty: no page can connect"
|
|
3508
|
+
});
|
|
3509
|
+
}
|
|
3510
|
+
logger.info("relay.start", {
|
|
3511
|
+
version: VERSION,
|
|
3512
|
+
url: publicUrl,
|
|
3513
|
+
port,
|
|
3514
|
+
store: ownsStore ? config.store : "custom",
|
|
3515
|
+
origins: siteCount
|
|
3516
|
+
});
|
|
3517
|
+
let stopping;
|
|
3518
|
+
return {
|
|
3519
|
+
url: publicUrl,
|
|
3520
|
+
port,
|
|
3521
|
+
config,
|
|
3522
|
+
addressFor: (secret) => `${publicUrl}/a/${addressIdFromSecret(secret)}/mcp`,
|
|
3523
|
+
get sessionCount() {
|
|
3524
|
+
return allSessions.size;
|
|
3525
|
+
},
|
|
3526
|
+
stop() {
|
|
3527
|
+
stopping ??= (async () => {
|
|
3528
|
+
clearInterval(sweep);
|
|
3529
|
+
for (const session of [...allSessions]) {
|
|
3530
|
+
session.end(CloseCode.shuttingDown, "relay shutting down", "shutdown");
|
|
3531
|
+
}
|
|
3532
|
+
await Bun.sleep(20);
|
|
3533
|
+
await server.stop(true);
|
|
3534
|
+
if (ownsStore)
|
|
3535
|
+
await store.close?.();
|
|
3536
|
+
logger.info("relay.stop", {});
|
|
3537
|
+
})();
|
|
3538
|
+
return stopping;
|
|
3539
|
+
}
|
|
3540
|
+
};
|
|
3541
|
+
}
|
|
3542
|
+
export {
|
|
3543
|
+
ConfigError,
|
|
3544
|
+
DEFAULT_CONFIG,
|
|
3545
|
+
MemoryTokenStore,
|
|
3546
|
+
SUBPROTOCOL,
|
|
3547
|
+
SqliteTokenStore,
|
|
3548
|
+
VERSION,
|
|
3549
|
+
addressIdFromSecret,
|
|
3550
|
+
configFromEnv,
|
|
3551
|
+
createLogger,
|
|
3552
|
+
createRelay,
|
|
3553
|
+
newSecret,
|
|
3554
|
+
resolveConfig
|
|
3555
|
+
};
|
|
3556
|
+
|
|
3557
|
+
//# debugId=5AAD754DD6F4F7B264756E2164756E21
|
|
3558
|
+
//# sourceMappingURL=index.js.map
|