| 1 | /** |
| 2 | * Connectors (docs.g1t.sh/guides/customize/, "Connectors"): MCP servers a |
| 3 | * workspace connects once, then gives to agents. One comes from the |
| 4 | * directory (@g1t/contracts connectors.ts, the servers tools publish), |
| 5 | * from a plugin's `.mcp.json`, or from an address an owner types. It |
| 6 | * signs in the way the server asks: |
| 7 | * |
| 8 | * - `oauth`: the browser. g1t finds the server's authorization server |
| 9 | * (RFC 9728, then RFC 8414), registers itself as a client (RFC 7591) |
| 10 | * when the server allows it, and sends the person there with PKCE. The |
| 11 | * tokens come back here and are refreshed before they expire. |
| 12 | * - `header`: a key an owner pastes, sent as a header's value. |
| 13 | * - `none`: the server is open. |
| 14 | * |
| 15 | * Secrets (the key, the tokens, the client registration) are sealed with |
| 16 | * AES-GCM under `CONNECTORS_KEY`, bound to the connector's id, and never |
| 17 | * returned. Where a connector reaches (`connector_grants`) works like a |
| 18 | * skill's attachments: every agent, a team's, or one. An agent's turn gets |
| 19 | * the connectors that reach it in the same shape as its own MCP servers, |
| 20 | * so levels, Ask-first cards and audit are unchanged (abilities.ts). |
| 21 | * |
| 22 | * Pure apart from the sealing key and `fetch`, so it is tested on SQLite |
| 23 | * with fakes. |
| 24 | */ |
| 25 | import type { FailureCode, Result } from "@g1t/contracts"; |
| 26 | import { MAX_MCP_TOOLS, type McpServer, type McpTool, checkMcpName, checkMcpUrl } from "../../../packages/contracts/src/abilities.ts"; |
| 27 | import { CONNECTORS, type Connector } from "../../../packages/contracts/src/connectors.ts"; |
| 28 | import { MAX_CONNECTORS, type AgentConnector, type ConnectorAuth, type ConnectorFrom, type ConnectorGrant, type ConnectorInput, type ConnectorState, type WorkspaceConnector } from "../../../packages/contracts/src/customize.ts"; |
| 29 | import { newId } from "../../../packages/contracts/src/ids.ts"; |
| 30 | import type { SkillScope } from "../../../packages/contracts/src/skill-library.ts"; |
| 31 | import { type Fetch, listMcpTools } from "./mcp-client.ts"; |
| 32 | |
| 33 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 34 | const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } }); |
| 35 | |
| 36 | export type ConnectorRow = { |
| 37 | id: string; |
| 38 | workspace_id: string; |
| 39 | name: string; |
| 40 | title: string; |
| 41 | url: string; |
| 42 | category: string | null; |
| 43 | about: string | null; |
| 44 | auth: string; |
| 45 | header_name: string | null; |
| 46 | secrets: string | null; |
| 47 | signed_in_as: string | null; |
| 48 | signed_in_at: string | null; |
| 49 | pending: string | null; |
| 50 | pending_at: string | null; |
| 51 | from_json: string; |
| 52 | tools: string; |
| 53 | checked_at: string | null; |
| 54 | problem: string | null; |
| 55 | added_by: string; |
| 56 | added_at: string; |
| 57 | updated_at: string; |
| 58 | removed_at: string | null; |
| 59 | }; |
| 60 | |
| 61 | export type GrantRow = { id: string; workspace_id: string; connector_id: string; scope: SkillScope; target: string; granted_by: string; granted_at: string }; |
| 62 | |
| 63 | /** What a connector keeps sealed. */ |
| 64 | type Secrets = { |
| 65 | header?: string; |
| 66 | oauth?: { |
| 67 | client_id: string; |
| 68 | client_secret?: string; |
| 69 | access_token: string; |
| 70 | refresh_token?: string; |
| 71 | /** Unix seconds. */ |
| 72 | expires_at?: number; |
| 73 | token_endpoint: string; |
| 74 | resource?: string; |
| 75 | scope?: string; |
| 76 | }; |
| 77 | }; |
| 78 | |
| 79 | /** An OAuth sign-in under way. */ |
| 80 | type Pending = { |
| 81 | state: string; |
| 82 | verifier: string; |
| 83 | client_id: string; |
| 84 | client_secret?: string; |
| 85 | token_endpoint: string; |
| 86 | redirect_uri: string; |
| 87 | resource: string; |
| 88 | scope?: string; |
| 89 | }; |
| 90 | |
| 91 | function json<T>(text: string | null, fallback: T): T { |
| 92 | if (!text) return fallback; |
| 93 | try { |
| 94 | return JSON.parse(text) as T; |
| 95 | } catch { |
| 96 | return fallback; |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | // ── Sealing ────────────────────────────────────────────────────────────── |
| 101 | |
| 102 | /** The sealing key from `CONNECTORS_KEY` (64 hex characters), or null when none is set: then nothing secret can be kept. */ |
| 103 | export async function sealingKey(hex: string | undefined): Promise<CryptoKey | null> { |
| 104 | if (!hex || !/^[0-9a-fA-F]{64}$/.test(hex.trim())) return null; |
| 105 | const bytes = new Uint8Array(hex.trim().match(/.{2}/g)!.map((b) => parseInt(b, 16))); |
| 106 | return crypto.subtle.importKey("raw", bytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); |
| 107 | } |
| 108 | |
| 109 | const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes)); |
| 110 | const unb64 = (text: string): Uint8Array => Uint8Array.from(atob(text), (c) => c.charCodeAt(0)); |
| 111 | |
| 112 | /** `value` sealed for `id`: base64 of the nonce and the ciphertext. */ |
| 113 | export async function seal(key: CryptoKey, id: string, value: unknown): Promise<string> { |
| 114 | const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 115 | const data = new TextEncoder().encode(JSON.stringify(value)); |
| 116 | const out = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(id) }, key, data)); |
| 117 | const joined = new Uint8Array(iv.length + out.length); |
| 118 | joined.set(iv); |
| 119 | joined.set(out, iv.length); |
| 120 | return b64(joined); |
| 121 | } |
| 122 | |
| 123 | /** What was sealed for `id`, or null when it can't be opened (another key, another id, damaged). */ |
| 124 | export async function open<T>(key: CryptoKey, id: string, sealed: string | null): Promise<T | null> { |
| 125 | if (!sealed) return null; |
| 126 | try { |
| 127 | const bytes = unb64(sealed); |
| 128 | const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: bytes.subarray(0, 12), additionalData: new TextEncoder().encode(id) }, key, bytes.subarray(12)); |
| 129 | return JSON.parse(new TextDecoder().decode(plain)) as T; |
| 130 | } catch { |
| 131 | return null; |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // ── Rows ───────────────────────────────────────────────────────────────── |
| 136 | |
| 137 | export type Deps = { |
| 138 | key: CryptoKey | null; |
| 139 | fetchFn?: Fetch; |
| 140 | /** The site's address, for the OAuth redirect and the client's name. */ |
| 141 | site: string; |
| 142 | now?: Date; |
| 143 | }; |
| 144 | |
| 145 | const at = (deps: Deps) => (deps.now ?? new Date()).toISOString(); |
| 146 | |
| 147 | export async function listConnectors(db: D1Database, workspaceId: string): Promise<ConnectorRow[]> { |
| 148 | const { results } = await db.prepare("SELECT * FROM connectors WHERE workspace_id = ? AND removed_at IS NULL ORDER BY title COLLATE NOCASE").bind(workspaceId).all<ConnectorRow>(); |
| 149 | return results; |
| 150 | } |
| 151 | |
| 152 | export async function connectorById(db: D1Database, workspaceId: string, id: string): Promise<ConnectorRow | null> { |
| 153 | return db.prepare("SELECT * FROM connectors WHERE workspace_id = ? AND id = ? AND removed_at IS NULL").bind(workspaceId, id).first<ConnectorRow>(); |
| 154 | } |
| 155 | |
| 156 | async function grantsOf(db: D1Database, connectorIds: string[]): Promise<GrantRow[]> { |
| 157 | if (!connectorIds.length) return []; |
| 158 | const { results } = await db.prepare("SELECT * FROM connector_grants WHERE connector_id IN (SELECT value FROM json_each(?)) ORDER BY CASE scope WHEN 'workspace' THEN 0 WHEN 'team' THEN 1 ELSE 2 END, granted_at").bind(JSON.stringify(connectorIds)).all<GrantRow>(); |
| 159 | return results; |
| 160 | } |
| 161 | |
| 162 | /** Whether the connector can be used now. */ |
| 163 | export function stateOf(row: ConnectorRow): ConnectorState { |
| 164 | if (row.auth === "oauth" && !row.signed_in_at) return "needs_sign_in"; |
| 165 | if (row.auth === "header" && !row.secrets) return "needs_key"; |
| 166 | if (row.problem) return "problem"; |
| 167 | return "connected"; |
| 168 | } |
| 169 | |
| 170 | type Labels = { agents: Map<string, { handle: string; display_name: string }>; teams: Map<string, string> }; |
| 171 | |
| 172 | function grantOut(g: GrantRow, labels: Labels): ConnectorGrant { |
| 173 | const agent = g.scope === "agent" ? labels.agents.get(g.target) : null; |
| 174 | const target = g.scope === "agent" ? (agent?.handle ?? null) : g.scope === "team" ? g.target : null; |
| 175 | const label = g.scope === "workspace" ? "Every agent" : g.scope === "agent" ? (agent ? `@${agent.handle}` : "An archived agent") : (labels.teams.get(g.target) ?? g.target); |
| 176 | return { id: g.id, scope: g.scope, target, label, granted_by: g.granted_by, granted_at: g.granted_at }; |
| 177 | } |
| 178 | |
| 179 | export function connectorOut(row: ConnectorRow, grants: GrantRow[], labels: Labels): WorkspaceConnector { |
| 180 | return { |
| 181 | id: row.id, |
| 182 | name: row.name, |
| 183 | title: row.title, |
| 184 | url: row.url, |
| 185 | category: (row.category as WorkspaceConnector["category"]) ?? null, |
| 186 | about: row.about, |
| 187 | auth: row.auth as ConnectorAuth, |
| 188 | state: stateOf(row), |
| 189 | problem: row.problem, |
| 190 | from: json<ConnectorFrom>(row.from_json, { kind: "address" }), |
| 191 | tools: json<McpTool[]>(row.tools, []), |
| 192 | checked_at: row.checked_at, |
| 193 | header_name: row.header_name, |
| 194 | signed_in_as: row.signed_in_as, |
| 195 | signed_in_at: row.signed_in_at, |
| 196 | grants: grants.map((g) => grantOut(g, labels)), |
| 197 | added_by: row.added_by, |
| 198 | added_at: row.added_at, |
| 199 | }; |
| 200 | } |
| 201 | |
| 202 | /** Every connector as the page lists them. */ |
| 203 | export async function connectorsOut(db: D1Database, workspaceId: string, labels: Labels): Promise<WorkspaceConnector[]> { |
| 204 | const rows = await listConnectors(db, workspaceId); |
| 205 | const grants = await grantsOf( |
| 206 | db, |
| 207 | rows.map((r) => r.id), |
| 208 | ); |
| 209 | return rows.map((row) => connectorOut(row, grants.filter((g) => g.connector_id === row.id), labels)); |
| 210 | } |
| 211 | |
| 212 | // ── Headers and tools ──────────────────────────────────────────────────── |
| 213 | |
| 214 | /** The headers that sign a call in, refreshing an OAuth token first when it is about to expire. Null when the connector can't be used. */ |
| 215 | export async function headersFor(db: D1Database, deps: Deps, row: ConnectorRow): Promise<Record<string, string> | null> { |
| 216 | if (row.auth === "none") return {}; |
| 217 | if (!deps.key) return null; |
| 218 | const secrets = await open<Secrets>(deps.key, row.id, row.secrets); |
| 219 | if (!secrets) return null; |
| 220 | if (row.auth === "header") return secrets.header && row.header_name ? { [row.header_name]: secrets.header } : null; |
| 221 | const oauth = secrets.oauth; |
| 222 | if (!oauth) return null; |
| 223 | const now = Math.floor((deps.now ?? new Date()).getTime() / 1000); |
| 224 | if (oauth.expires_at && oauth.expires_at - 60 < now) { |
| 225 | if (!oauth.refresh_token) return null; |
| 226 | const refreshed = await tokenRequest(deps.fetchFn ?? ((u, i) => fetch(u, i)), oauth.token_endpoint, { |
| 227 | grant_type: "refresh_token", |
| 228 | refresh_token: oauth.refresh_token, |
| 229 | client_id: oauth.client_id, |
| 230 | ...(oauth.client_secret ? { client_secret: oauth.client_secret } : {}), |
| 231 | ...(oauth.resource ? { resource: oauth.resource } : {}), |
| 232 | }); |
| 233 | if (!refreshed.ok) { |
| 234 | await db.prepare("UPDATE connectors SET problem = ?, updated_at = ? WHERE id = ?").bind(`Its sign-in couldn't be renewed: ${refreshed.message} Sign in again.`, at(deps), row.id).run(); |
| 235 | return null; |
| 236 | } |
| 237 | const fresh: Secrets = { ...secrets, oauth: { ...oauth, access_token: refreshed.access_token, refresh_token: refreshed.refresh_token ?? oauth.refresh_token, expires_at: refreshed.expires_in ? now + refreshed.expires_in : undefined } }; |
| 238 | await db.prepare("UPDATE connectors SET secrets = ?, updated_at = ? WHERE id = ?").bind(await seal(deps.key, row.id, fresh), at(deps), row.id).run(); |
| 239 | return { Authorization: `Bearer ${fresh.oauth!.access_token}` }; |
| 240 | } |
| 241 | return { Authorization: `Bearer ${oauth.access_token}` }; |
| 242 | } |
| 243 | |
| 244 | /** Lists the server's tools with its headers and keeps them; the problem, if any, is kept too. */ |
| 245 | export async function refreshTools(db: D1Database, deps: Deps, row: ConnectorRow): Promise<ConnectorRow> { |
| 246 | const headers = await headersFor(db, deps, row); |
| 247 | const when = at(deps); |
| 248 | if (headers === null) { |
| 249 | const problem = row.auth === "oauth" ? (row.signed_in_at ? "Its sign-in can't be used. Sign in again." : null) : row.auth === "header" ? null : "Its secrets can't be opened (is CONNECTORS_KEY set?)."; |
| 250 | await db.prepare("UPDATE connectors SET problem = ?, checked_at = ?, updated_at = ? WHERE id = ?").bind(problem, when, when, row.id).run(); |
| 251 | return { ...row, problem, checked_at: when, updated_at: when }; |
| 252 | } |
| 253 | const listed = await listMcpTools(row.url, deps.fetchFn, headers); |
| 254 | const tools = listed.ok ? JSON.stringify(listed.tools) : row.tools; |
| 255 | const problem = listed.ok ? null : listed.message; |
| 256 | await db.prepare("UPDATE connectors SET tools = ?, checked_at = ?, problem = ?, updated_at = ? WHERE id = ?").bind(tools, when, problem, when, row.id).run(); |
| 257 | return { ...row, tools, checked_at: when, problem, updated_at: when }; |
| 258 | } |
| 259 | |
| 260 | // ── Adding and removing ────────────────────────────────────────────────── |
| 261 | |
| 262 | /** A name for a connector from a title: lowercase, hyphens, 2 to 32. */ |
| 263 | function nameFrom(text: string): string { |
| 264 | const name = text |
| 265 | .toLowerCase() |
| 266 | .replace(/[^a-z0-9]+/g, "-") |
| 267 | .replace(/^-+|-+$/g, "") |
| 268 | .slice(0, 32) |
| 269 | .replace(/-+$/, ""); |
| 270 | return name.length >= 2 ? name : `${name}x`.slice(0, 2); |
| 271 | } |
| 272 | |
| 273 | /** Adds a connector; a plugin's or an address's needs an owner to sign in or paste a key next, unless the server is open. */ |
| 274 | export async function addConnector( |
| 275 | db: D1Database, |
| 276 | deps: Deps, |
| 277 | workspaceId: string, |
| 278 | input: ConnectorInput, |
| 279 | by: string, |
| 280 | pluginServer?: { title: string; url: string } | null, |
| 281 | ): Promise<Result<ConnectorRow>> { |
| 282 | const count = await db.prepare("SELECT COUNT(*) AS n FROM connectors WHERE workspace_id = ? AND removed_at IS NULL").bind(workspaceId).first<{ n: number }>(); |
| 283 | if ((count?.n ?? 0) >= MAX_CONNECTORS) return fail("limit", `A workspace keeps at most ${MAX_CONNECTORS} connectors.`); |
| 284 | let title: string; |
| 285 | let url: string; |
| 286 | let name: string; |
| 287 | let category: string | null = null; |
| 288 | let about: string | null = null; |
| 289 | let auth: ConnectorAuth = input.auth; |
| 290 | let headerName: string | null = input.header?.name?.trim() || null; |
| 291 | const from = input.from; |
| 292 | if (from.kind === "directory") { |
| 293 | const entry: Connector | undefined = CONNECTORS.find((c) => c.id === from.id && c.mcp); |
| 294 | if (!entry?.mcp) return fail("not_found", "There is no such connector in the directory."); |
| 295 | title = entry.name; |
| 296 | url = entry.mcp.url; |
| 297 | name = nameFrom(entry.id.replace(/-mcp$/, "")); |
| 298 | category = entry.category; |
| 299 | about = entry.description; |
| 300 | auth = entry.mcp.auth; |
| 301 | if (auth === "header") headerName = headerName ?? entry.mcp.header ?? "Authorization"; |
| 302 | } else if (from.kind === "plugin") { |
| 303 | if (!pluginServer) return fail("not_found", "That plugin names no such server."); |
| 304 | title = pluginServer.title; |
| 305 | url = pluginServer.url; |
| 306 | name = nameFrom(from.server); |
| 307 | about = `From the ${from.ref} plugin.`; |
| 308 | } else { |
| 309 | const checkedName = checkMcpName(from.name); |
| 310 | if (!checkedName.ok) return fail("invalid", checkedName.message); |
| 311 | title = from.name.trim(); |
| 312 | url = from.url; |
| 313 | name = checkedName.value; |
| 314 | } |
| 315 | const checkedUrl = checkMcpUrl(url); |
| 316 | if (!checkedUrl.ok) return fail("invalid", checkedUrl.message); |
| 317 | if (auth === "header" && !headerName) headerName = "Authorization"; |
| 318 | if (auth === "header" && input.header?.value && !deps.key) return fail("invalid", "Keys can't be kept here: CONNECTORS_KEY isn't set on the agents service."); |
| 319 | const now = at(deps); |
| 320 | const id = newId("con", (deps.now ?? new Date()).getTime()); |
| 321 | const secrets = auth === "header" && input.header?.value && deps.key ? await seal(deps.key, id, { header: input.header.value.trim() } satisfies Secrets) : null; |
| 322 | const row: ConnectorRow = { |
| 323 | id, |
| 324 | workspace_id: workspaceId, |
| 325 | name, |
| 326 | title, |
| 327 | url: checkedUrl.value, |
| 328 | category, |
| 329 | about, |
| 330 | auth, |
| 331 | header_name: auth === "header" ? headerName : null, |
| 332 | secrets, |
| 333 | signed_in_as: null, |
| 334 | signed_in_at: null, |
| 335 | pending: null, |
| 336 | pending_at: null, |
| 337 | from_json: JSON.stringify(from), |
| 338 | tools: "[]", |
| 339 | checked_at: null, |
| 340 | problem: null, |
| 341 | added_by: by, |
| 342 | added_at: now, |
| 343 | updated_at: now, |
| 344 | removed_at: null, |
| 345 | }; |
| 346 | // Two connectors with the same name: the second gets a number. |
| 347 | for (let n = 2; n < 50; n++) { |
| 348 | try { |
| 349 | await db |
| 350 | .prepare( |
| 351 | `INSERT INTO connectors (id, workspace_id, name, title, url, category, about, auth, header_name, secrets, from_json, tools, added_by, added_at, updated_at) |
| 352 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?, ?, ?)`, |
| 353 | ) |
| 354 | .bind(row.id, row.workspace_id, row.name, row.title, row.url, row.category, row.about, row.auth, row.header_name, row.secrets, row.from_json, row.added_by, row.added_at, row.updated_at) |
| 355 | .run(); |
| 356 | break; |
| 357 | } catch (error) { |
| 358 | if (!String(error).includes("UNIQUE")) throw error; |
| 359 | row.name = `${name.slice(0, 29)}-${n}`; |
| 360 | if (n === 49) return fail("conflict", `The workspace already has a connector called ${name}.`); |
| 361 | } |
| 362 | } |
| 363 | // An open server, or one with its key: its tools can be listed now. |
| 364 | if (auth === "none" || (auth === "header" && secrets)) return ok(await refreshTools(db, deps, row)); |
| 365 | return ok(row); |
| 366 | } |
| 367 | |
| 368 | /** Puts a key on a header connector (or replaces it), then lists its tools. */ |
| 369 | export async function setKey(db: D1Database, deps: Deps, row: ConnectorRow, headerName: string | null, value: string): Promise<Result<ConnectorRow>> { |
| 370 | if (row.auth !== "header") return fail("invalid", "This connector doesn't take a key."); |
| 371 | if (!deps.key) return fail("invalid", "Keys can't be kept here: CONNECTORS_KEY isn't set on the agents service."); |
| 372 | const key = value.trim(); |
| 373 | if (!key || key.length > 4000) return fail("invalid", "Paste the key."); |
| 374 | const header = headerName?.trim() || row.header_name || "Authorization"; |
| 375 | if (!/^[A-Za-z0-9-]{1,64}$/.test(header)) return fail("invalid", "A header's name is letters, digits and hyphens."); |
| 376 | const sealed = await seal(deps.key, row.id, { header: key } satisfies Secrets); |
| 377 | const when = at(deps); |
| 378 | await db.prepare("UPDATE connectors SET secrets = ?, header_name = ?, problem = NULL, updated_at = ? WHERE id = ?").bind(sealed, header, when, row.id).run(); |
| 379 | return ok(await refreshTools(db, deps, { ...row, secrets: sealed, header_name: header, problem: null })); |
| 380 | } |
| 381 | |
| 382 | /** Removes a connector and its grants; the row is kept. */ |
| 383 | export async function removeConnector(db: D1Database, deps: Deps, workspaceId: string, id: string): Promise<Result<null>> { |
| 384 | const row = await connectorById(db, workspaceId, id); |
| 385 | if (!row) return fail("not_found", "There is no such connector."); |
| 386 | const when = at(deps); |
| 387 | await db.batch([ |
| 388 | db.prepare("UPDATE connectors SET removed_at = ?, secrets = NULL, pending = NULL, updated_at = ? WHERE id = ?").bind(when, when, id), |
| 389 | db.prepare("DELETE FROM connector_grants WHERE connector_id = ?").bind(id), |
| 390 | ]); |
| 391 | return ok(null); |
| 392 | } |
| 393 | |
| 394 | /** Removes every connector a plugin brought. */ |
| 395 | export async function removePluginConnectors(db: D1Database, deps: Deps, workspaceId: string, pluginRef: string): Promise<number> { |
| 396 | const rows = await listConnectors(db, workspaceId); |
| 397 | let n = 0; |
| 398 | for (const row of rows) { |
| 399 | const from = json<ConnectorFrom>(row.from_json, { kind: "address" }); |
| 400 | if (from.kind === "plugin" && from.ref === pluginRef) { |
| 401 | await removeConnector(db, deps, workspaceId, row.id); |
| 402 | n++; |
| 403 | } |
| 404 | } |
| 405 | return n; |
| 406 | } |
| 407 | |
| 408 | // ── OAuth ──────────────────────────────────────────────────────────────── |
| 409 | |
| 410 | type Metadata = { authorization_endpoint: string; token_endpoint: string; registration_endpoint?: string; scopes_supported?: string[]; code_challenge_methods_supported?: string[] }; |
| 411 | |
| 412 | async function getJson(fetchFn: Fetch, url: string): Promise<{ status: number; json: Record<string, unknown> | null; headers: Headers }> { |
| 413 | try { |
| 414 | const response = await fetchFn(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) }); |
| 415 | const text = await response.text().catch(() => ""); |
| 416 | let parsed: Record<string, unknown> | null = null; |
| 417 | try { |
| 418 | parsed = text ? (JSON.parse(text) as Record<string, unknown>) : null; |
| 419 | } catch { |
| 420 | parsed = null; |
| 421 | } |
| 422 | return { status: response.status, json: parsed, headers: response.headers }; |
| 423 | } catch { |
| 424 | return { status: 0, json: null, headers: new Headers() }; |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | /** |
| 429 | * The authorization server for an MCP server: from the 401's |
| 430 | * `WWW-Authenticate` resource metadata, from the protected-resource |
| 431 | * document at the server's host, or the host itself; then its metadata. |
| 432 | */ |
| 433 | export async function discover(fetchFn: Fetch, url: string): Promise<{ ok: true; metadata: Metadata; issuer: string } | { ok: false; message: string }> { |
| 434 | const server = new URL(url); |
| 435 | const candidates: string[] = []; |
| 436 | // 1. The server itself says where (RFC 9728). |
| 437 | try { |
| 438 | const probe = await fetchFn(url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "ping" }), signal: AbortSignal.timeout(15_000) }); |
| 439 | const challenge = probe.headers.get("www-authenticate") ?? ""; |
| 440 | const metadataUrl = /resource_metadata="([^"]+)"/.exec(challenge)?.[1]; |
| 441 | if (metadataUrl) candidates.push(metadataUrl); |
| 442 | } catch { |
| 443 | // The probe is a hint, not a requirement. |
| 444 | } |
| 445 | const path = server.pathname.replace(/\/$/, ""); |
| 446 | candidates.push(`${server.origin}/.well-known/oauth-protected-resource${path}`, `${server.origin}/.well-known/oauth-protected-resource`); |
| 447 | let issuers: string[] = []; |
| 448 | for (const candidate of candidates) { |
| 449 | const read = await getJson(fetchFn, candidate); |
| 450 | const servers = read.json?.authorization_servers; |
| 451 | if (read.status === 200 && Array.isArray(servers) && servers.length) { |
| 452 | issuers = servers.filter((s): s is string => typeof s === "string"); |
| 453 | break; |
| 454 | } |
| 455 | } |
| 456 | if (!issuers.length) issuers = [server.origin]; |
| 457 | // 2. The authorization server's metadata (RFC 8414, then OpenID). |
| 458 | for (const issuer of issuers) { |
| 459 | const base = new URL(issuer); |
| 460 | const issuerPath = base.pathname.replace(/\/$/, ""); |
| 461 | for (const candidate of [`${base.origin}/.well-known/oauth-authorization-server${issuerPath}`, `${base.origin}${issuerPath}/.well-known/oauth-authorization-server`, `${base.origin}/.well-known/openid-configuration${issuerPath}`, `${base.origin}${issuerPath}/.well-known/openid-configuration`]) { |
| 462 | const read = await getJson(fetchFn, candidate); |
| 463 | const m = read.json; |
| 464 | if (read.status === 200 && m && typeof m.authorization_endpoint === "string" && typeof m.token_endpoint === "string") { |
| 465 | return { |
| 466 | ok: true, |
| 467 | issuer, |
| 468 | metadata: { |
| 469 | authorization_endpoint: m.authorization_endpoint, |
| 470 | token_endpoint: m.token_endpoint, |
| 471 | ...(typeof m.registration_endpoint === "string" ? { registration_endpoint: m.registration_endpoint } : {}), |
| 472 | ...(Array.isArray(m.scopes_supported) ? { scopes_supported: m.scopes_supported.filter((s): s is string => typeof s === "string") } : {}), |
| 473 | ...(Array.isArray(m.code_challenge_methods_supported) ? { code_challenge_methods_supported: m.code_challenge_methods_supported.filter((s): s is string => typeof s === "string") } : {}), |
| 474 | }, |
| 475 | }; |
| 476 | } |
| 477 | } |
| 478 | } |
| 479 | return { ok: false, message: "The server doesn't say how to sign in (no OAuth metadata was found). If it takes a key instead, add it with a key." }; |
| 480 | } |
| 481 | |
| 482 | /** Registers g1t as a client (RFC 7591), when the server allows it. */ |
| 483 | async function register(fetchFn: Fetch, endpoint: string, redirectUri: string, site: string): Promise<{ ok: true; client_id: string; client_secret?: string } | { ok: false; message: string }> { |
| 484 | try { |
| 485 | const response = await fetchFn(endpoint, { |
| 486 | method: "POST", |
| 487 | headers: { "content-type": "application/json", accept: "application/json" }, |
| 488 | body: JSON.stringify({ client_name: "g1t", client_uri: site, redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none" }), |
| 489 | signal: AbortSignal.timeout(15_000), |
| 490 | }); |
| 491 | const body = (await response.json().catch(() => null)) as { client_id?: string; client_secret?: string; error_description?: string; error?: string } | null; |
| 492 | if (!response.ok || !body?.client_id) return { ok: false, message: `The server refused to register g1t as a client${body?.error_description ? ` (${body.error_description})` : body?.error ? ` (${body.error})` : ""}.` }; |
| 493 | return { ok: true, client_id: body.client_id, ...(body.client_secret ? { client_secret: body.client_secret } : {}) }; |
| 494 | } catch { |
| 495 | return { ok: false, message: "The server's client registration couldn't be reached." }; |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | async function tokenRequest(fetchFn: Fetch, endpoint: string, form: Record<string, string>): Promise<{ ok: true; access_token: string; refresh_token?: string; expires_in?: number; scope?: string } | { ok: false; message: string }> { |
| 500 | try { |
| 501 | const response = await fetchFn(endpoint, { |
| 502 | method: "POST", |
| 503 | headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, |
| 504 | body: new URLSearchParams(form).toString(), |
| 505 | signal: AbortSignal.timeout(15_000), |
| 506 | }); |
| 507 | const body = (await response.json().catch(() => null)) as { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string; error?: string; error_description?: string } | null; |
| 508 | if (!response.ok || !body?.access_token) return { ok: false, message: `${body?.error_description ?? body?.error ?? `the server answered ${response.status}`}.` }; |
| 509 | return { ok: true, access_token: body.access_token, ...(body.refresh_token ? { refresh_token: body.refresh_token } : {}), ...(typeof body.expires_in === "number" ? { expires_in: body.expires_in } : {}), ...(body.scope ? { scope: body.scope } : {}) }; |
| 510 | } catch { |
| 511 | return { ok: false, message: "the server couldn't be reached." }; |
| 512 | } |
| 513 | } |
| 514 | |
| 515 | const random = (bytes: number): string => |
| 516 | b64(crypto.getRandomValues(new Uint8Array(bytes))) |
| 517 | .replace(/\+/g, "-") |
| 518 | .replace(/\//g, "_") |
| 519 | .replace(/=+$/, ""); |
| 520 | |
| 521 | async function s256(verifier: string): Promise<string> { |
| 522 | const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))); |
| 523 | return b64(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 524 | } |
| 525 | |
| 526 | /** Where the browser goes to sign a connector in; the sign-in's state is kept sealed on the row until it completes. */ |
| 527 | export async function startSignIn(db: D1Database, deps: Deps, row: ConnectorRow, redirectUri: string): Promise<Result<{ url: string }>> { |
| 528 | if (row.auth !== "oauth") return fail("invalid", "This connector doesn't sign in with a browser."); |
| 529 | if (!deps.key) return fail("invalid", "Sign-ins can't be kept here: CONNECTORS_KEY isn't set on the agents service."); |
| 530 | const fetchFn = deps.fetchFn ?? ((u, i) => fetch(u, i)); |
| 531 | const found = await discover(fetchFn, row.url); |
| 532 | if (!found.ok) return fail("invalid", found.message); |
| 533 | if (found.metadata.code_challenge_methods_supported && !found.metadata.code_challenge_methods_supported.includes("S256")) return fail("invalid", "The server doesn't support PKCE (S256), which g1t requires."); |
| 534 | if (!found.metadata.registration_endpoint) return fail("invalid", "The server doesn't register clients on its own, and g1t has no client registered with it. If it takes a key instead, add it with a key."); |
| 535 | const client = await register(fetchFn, found.metadata.registration_endpoint, redirectUri, deps.site); |
| 536 | if (!client.ok) return fail("invalid", client.message); |
| 537 | const verifier = random(48); |
| 538 | const state = `${row.id}.${random(24)}`; |
| 539 | const scope = found.metadata.scopes_supported?.length ? found.metadata.scopes_supported.join(" ") : undefined; |
| 540 | const pending: Pending = { state, verifier, client_id: client.client_id, ...(client.client_secret ? { client_secret: client.client_secret } : {}), token_endpoint: found.metadata.token_endpoint, redirect_uri: redirectUri, resource: row.url, ...(scope ? { scope } : {}) }; |
| 541 | await db.prepare("UPDATE connectors SET pending = ?, pending_at = ?, updated_at = ? WHERE id = ?").bind(await seal(deps.key, row.id, pending), at(deps), at(deps), row.id).run(); |
| 542 | const url = new URL(found.metadata.authorization_endpoint); |
| 543 | url.searchParams.set("response_type", "code"); |
| 544 | url.searchParams.set("client_id", client.client_id); |
| 545 | url.searchParams.set("redirect_uri", redirectUri); |
| 546 | url.searchParams.set("state", state); |
| 547 | url.searchParams.set("code_challenge", await s256(verifier)); |
| 548 | url.searchParams.set("code_challenge_method", "S256"); |
| 549 | url.searchParams.set("resource", row.url); |
| 550 | if (scope) url.searchParams.set("scope", scope); |
| 551 | return ok({ url: url.toString() }); |
| 552 | } |
| 553 | |
| 554 | /** The connector a returning sign-in belongs to: the state's first part is its id. */ |
| 555 | export function connectorOfState(state: string): string | null { |
| 556 | const id = state.split(".")[0] ?? ""; |
| 557 | return /^con_[a-z0-9]+$/.test(id) ? id : null; |
| 558 | } |
| 559 | |
| 560 | /** Completes a sign-in with the code the browser brought back, keeps the tokens, and lists the server's tools. */ |
| 561 | export async function finishSignIn(db: D1Database, deps: Deps, row: ConnectorRow, state: string, code: string, by: string): Promise<Result<ConnectorRow>> { |
| 562 | if (!deps.key) return fail("invalid", "CONNECTORS_KEY isn't set on the agents service."); |
| 563 | const pending = await open<Pending>(deps.key, row.id, row.pending); |
| 564 | if (!pending || pending.state !== state) return fail("invalid", "This sign-in isn't the one that was started. Start it again."); |
| 565 | if (row.pending_at && (deps.now ?? new Date()).getTime() - Date.parse(row.pending_at) > 15 * 60 * 1000) return fail("invalid", "This sign-in took too long. Start it again."); |
| 566 | const fetchFn = deps.fetchFn ?? ((u, i) => fetch(u, i)); |
| 567 | const token = await tokenRequest(fetchFn, pending.token_endpoint, { |
| 568 | grant_type: "authorization_code", |
| 569 | code, |
| 570 | redirect_uri: pending.redirect_uri, |
| 571 | client_id: pending.client_id, |
| 572 | code_verifier: pending.verifier, |
| 573 | resource: pending.resource, |
| 574 | ...(pending.client_secret ? { client_secret: pending.client_secret } : {}), |
| 575 | }); |
| 576 | if (!token.ok) return fail("invalid", `The server didn't give g1t a token: ${token.message}`); |
| 577 | const now = Math.floor((deps.now ?? new Date()).getTime() / 1000); |
| 578 | const secrets: Secrets = { |
| 579 | oauth: { |
| 580 | client_id: pending.client_id, |
| 581 | ...(pending.client_secret ? { client_secret: pending.client_secret } : {}), |
| 582 | access_token: token.access_token, |
| 583 | ...(token.refresh_token ? { refresh_token: token.refresh_token } : {}), |
| 584 | ...(token.expires_in ? { expires_at: now + token.expires_in } : {}), |
| 585 | token_endpoint: pending.token_endpoint, |
| 586 | resource: pending.resource, |
| 587 | ...(token.scope ?? pending.scope ? { scope: token.scope ?? pending.scope } : {}), |
| 588 | }, |
| 589 | }; |
| 590 | const when = at(deps); |
| 591 | const sealed = await seal(deps.key, row.id, secrets); |
| 592 | await db.prepare("UPDATE connectors SET secrets = ?, pending = NULL, pending_at = NULL, signed_in_as = ?, signed_in_at = ?, problem = NULL, updated_at = ? WHERE id = ?").bind(sealed, by, when, when, row.id).run(); |
| 593 | return ok(await refreshTools(db, deps, { ...row, secrets: sealed, pending: null, pending_at: null, signed_in_as: by, signed_in_at: when, problem: null })); |
| 594 | } |
| 595 | |
| 596 | /** Forgets a sign-in: the tokens go, and the connector needs a sign-in again. */ |
| 597 | export async function signOut(db: D1Database, deps: Deps, row: ConnectorRow): Promise<Result<ConnectorRow>> { |
| 598 | if (row.auth !== "oauth") return fail("invalid", "This connector doesn't sign in with a browser."); |
| 599 | const when = at(deps); |
| 600 | await db.prepare("UPDATE connectors SET secrets = NULL, pending = NULL, pending_at = NULL, signed_in_as = NULL, signed_in_at = NULL, problem = NULL, updated_at = ? WHERE id = ?").bind(when, row.id).run(); |
| 601 | return ok({ ...row, secrets: null, pending: null, pending_at: null, signed_in_as: null, signed_in_at: null, problem: null, updated_at: when }); |
| 602 | } |
| 603 | |
| 604 | // ── Grants ─────────────────────────────────────────────────────────────── |
| 605 | |
| 606 | /** Gives a connector to every agent, a team's, or one. The same grant twice is one. */ |
| 607 | export async function grant(db: D1Database, deps: Deps, workspaceId: string, connectorId: string, scope: SkillScope, target: string, by: string): Promise<Result<GrantRow>> { |
| 608 | if (!["workspace", "team", "agent"].includes(scope)) return fail("invalid", "Give it to every agent, a team, or one agent."); |
| 609 | if (scope !== "workspace" && !target) return fail("invalid", "Say which team or agent."); |
| 610 | const row: GrantRow = { id: newId("cgr", (deps.now ?? new Date()).getTime()), workspace_id: workspaceId, connector_id: connectorId, scope, target: scope === "workspace" ? "" : target, granted_by: by, granted_at: at(deps) }; |
| 611 | const inserted = await db |
| 612 | .prepare("INSERT INTO connector_grants (id, workspace_id, connector_id, scope, target, granted_by, granted_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING RETURNING id") |
| 613 | .bind(row.id, row.workspace_id, row.connector_id, row.scope, row.target, row.granted_by, row.granted_at) |
| 614 | .first<{ id: string }>(); |
| 615 | if (!inserted) { |
| 616 | const existing = await db.prepare("SELECT * FROM connector_grants WHERE connector_id = ? AND scope = ? AND target = ?").bind(connectorId, scope, row.target).first<GrantRow>(); |
| 617 | return existing ? ok(existing) : fail("conflict", "It is already given there."); |
| 618 | } |
| 619 | return ok(row); |
| 620 | } |
| 621 | |
| 622 | export async function revoke(db: D1Database, workspaceId: string, grantId: string): Promise<Result<null>> { |
| 623 | const done = await db.prepare("DELETE FROM connector_grants WHERE id = ? AND workspace_id = ?").bind(grantId, workspaceId).run(); |
| 624 | return done.meta.changes ? ok(null) : fail("not_found", "There is no such grant."); |
| 625 | } |
| 626 | |
| 627 | /** The connectors that reach one agent, with how each reaches it (the closest grant wins), connected or not. A switched-off plugin's connectors don't reach anyone. */ |
| 628 | export async function reaching(db: D1Database, workspaceId: string, agentId: string, teams: readonly string[]): Promise<{ row: ConnectorRow; via: SkillScope }[]> { |
| 629 | const { results } = await db |
| 630 | .prepare( |
| 631 | `SELECT c.*, g.scope AS via FROM connector_grants g |
| 632 | JOIN connectors c ON c.id = g.connector_id AND c.removed_at IS NULL |
| 633 | LEFT JOIN plugin_installs p ON p.workspace_id = c.workspace_id AND p.ref = json_extract(c.from_json, '$.ref') AND p.uninstalled_at IS NULL |
| 634 | WHERE g.workspace_id = ?1 |
| 635 | AND (json_extract(c.from_json, '$.kind') <> 'plugin' OR p.enabled = 1) |
| 636 | AND (g.scope = 'workspace' OR (g.scope = 'agent' AND g.target = ?2) OR (g.scope = 'team' AND g.target IN (SELECT value FROM json_each(?3)))) |
| 637 | ORDER BY c.title COLLATE NOCASE LIMIT 200`, |
| 638 | ) |
| 639 | .bind(workspaceId, agentId, JSON.stringify(teams)) |
| 640 | .all<ConnectorRow & { via: SkillScope }>(); |
| 641 | const order: Record<SkillScope, number> = { agent: 0, team: 1, workspace: 2 }; |
| 642 | const best = new Map<string, { row: ConnectorRow; via: SkillScope }>(); |
| 643 | for (const found of results) { |
| 644 | const { via, ...row } = found; |
| 645 | const have = best.get(row.id); |
| 646 | if (!have || order[via] < order[have.via]) best.set(row.id, { row, via }); |
| 647 | } |
| 648 | return [...best.values()]; |
| 649 | } |
| 650 | |
| 651 | /** A connector as an agent's turn gets it: the same shape as its own MCP servers, with the connector's id. */ |
| 652 | export function asServer(row: ConnectorRow): McpServer { |
| 653 | return { id: row.id, name: row.name, url: row.url, tools: json<McpTool[]>(row.tools, []).slice(0, MAX_MCP_TOOLS), added_by: row.added_by, added_at: row.added_at, checked_at: row.checked_at, problem: row.problem }; |
| 654 | } |
| 655 | |
| 656 | /** What an agent's Abilities tab lists for connectors: those that reach it, and its own servers. */ |
| 657 | export function agentConnectorsOut(reach: { row: ConnectorRow; via: SkillScope }[], own: McpServer[], labels: { teamName: (slug: string) => string }): AgentConnector[] { |
| 658 | const out: AgentConnector[] = reach.map(({ row, via }) => ({ |
| 659 | id: row.id, |
| 660 | name: row.name, |
| 661 | title: row.title, |
| 662 | state: stateOf(row), |
| 663 | via, |
| 664 | via_label: via === "workspace" ? "Every agent" : via === "team" ? `Team ${labels.teamName(row.name)}` : "This agent", |
| 665 | tools: json<McpTool[]>(row.tools, []).length, |
| 666 | })); |
| 667 | for (const server of own) out.push({ id: server.id, name: server.name, title: server.name, state: server.problem ? "problem" : "connected", via: "agent-only", via_label: "Only this agent", tools: server.tools.length }); |
| 668 | return out; |
| 669 | } |