import type { AdapterContext, ConnectorCall, ConnectorEvent, ConnectorProvider, ConnectorTool, } from "@rakazo/adapter-kit"; import { isGithubInstall } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import { z } from "zod"; import { AuthSchema, applyCredential, asRecord, assertNoSensitiveQuery, HeaderName, isSensitiveHeader, isTransportHeader, PublicHeadersSchema, readBoundedText, requireCredential, } from "./connector-http.js"; import { combineSignals, redactConnectorPayload, sanitizeConnectorError, } from "./connector-safety.js"; import { executeGraphqlOperation, GraphqlConfigSchema } from "./graphql-connectors.js"; import { CATALOG_EXECUTE, catalogEntries, catalogGroupLabel, DIRECT_TOOL_LIMIT, disambiguateInstalledToolNames, executeLazyCatalogControl, isLazyCatalogControlRoute, lazyCatalogTools, resolveCatalogCall, } from "./lazy-tool-catalog.js"; import { assertSafeRemoteUrl, callRemoteMcpTool, createSafeRemoteFetch, listRemoteMcpTools, type RemoteTransportDependencies, } from "./remote-mcp.js"; import type { EncryptedSecretStore } from "./secrets.js"; const ModelHeaderName = HeaderName.refine( (name) => !isSensitiveHeader(name), "Sensitive headers cannot be model-controlled", ); const McpAuthSchema = z .object({ type: z.enum(["none", "bearer", "header"]).default("none"), name: HeaderName.optional(), }) .default({ type: "none" }); const McpConfigSchema = z.object({ preset: z.enum(["treg", "github", "custom"]).default("custom"), auth: McpAuthSchema, headers: PublicHeadersSchema, }); const ApiOperationSchema = z.object({ id: z.string().min(1).max(160), name: z.string().min(1).max(160).optional(), description: z.string().max(2_000).optional(), method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), path: z.string().startsWith("/").max(2_048), inputSchema: z.record(z.string(), z.unknown()).default({ type: "object", properties: {}, }), readOnly: z.boolean().default(false), queryParameters: z.array(z.string().min(1).max(120)).default([]), headerParameters: z.array(ModelHeaderName).default([]), }); const ApiConfigSchema = z.object({ auth: AuthSchema, headers: PublicHeadersSchema, operations: z.array(ApiOperationSchema).min(1).max(100), }); type ApiOperation = z.infer; type InstalledRow = { id: string; kind: string; name: string; source: string; secretId: string | null; config: unknown; }; export type RemoteConnectorDependencies = RemoteTransportDependencies; export class InstalledConnectorProvider implements ConnectorProvider { constructor( private readonly prisma: PrismaClient, private readonly secrets: EncryptedSecretStore, private readonly remote: RemoteConnectorDependencies = {}, ) {} describe() { return { id: "installed", contractVersion: "1", adapterVersion: "0.1.0", capabilities: { discover: true, oauth: false, secretsBrokered: true }, }; } async discoverTools(context: AdapterContext): Promise { const tools = await this.authorizedTools(context); if (tools.length <= DIRECT_TOOL_LIMIT) return tools; return lazyCatalogTools("installed", "installed", "API", catalogEntries(tools)); } async resolveCall( call: ConnectorCall, context: AdapterContext, ): Promise<{ call: ConnectorCall; tool: ConnectorTool } | undefined> { // Wrappers have no resourceId; real tools always do. if (call.route?.resourceId || call.route?.toolName !== CATALOG_EXECUTE) return undefined; return resolveCatalogCall(call, catalogEntries(await this.authorizedTools(context))); } private async authorizedTools(context: AdapterContext): Promise { // A user's tool sources follow them into every space they work in. const installs = await this.prisma.capabilityInstall.findMany({ where: { userId: context.userId, kind: { in: ["mcp", "api", "graphql"] }, }, orderBy: { createdAt: "asc" }, }); const tools: ConnectorTool[] = []; for (let offset = 0; offset < installs.length; offset += 4) { const groups = await Promise.all( installs.slice(offset, offset + 4).map((install) => this.discoverInstall(install, context)), ); tools.push(...groups.flat()); } return disambiguateInstalledToolNames(tools); } private async discoverInstall( install: InstalledRow, context: AdapterContext, ): Promise { const catalogGroup = catalogGroupLabel(install.name, install.kind, install.id); try { if (install.kind === "mcp") { const config = McpConfigSchema.parse(install.config); const credential = await this.loadCredential(install, context); const remote = await listRemoteMcpTools({ endpoint: install.source, headers: connectorHeaders(config, credential), signal: context.signal, fetch: this.remote.fetch, resolveHostname: this.remote.resolveHostname, }); return remote.map((tool) => ({ ...tool, route: { connectorId: "installed", resourceId: install.id, toolName: tool.name, catalogGroup, }, })); } if (install.kind === "api") { const config = ApiConfigSchema.parse(install.config); return config.operations.map((operation) => ({ name: operation.name ?? operation.id, description: operation.description ?? `${operation.method} ${operation.path}`, inputSchema: operation.inputSchema, readOnly: operation.readOnly, route: { connectorId: "installed", resourceId: install.id, toolName: operation.id, catalogGroup, }, })); } if (install.kind === "graphql") { const config = GraphqlConfigSchema.parse(install.config); return config.operations.map((operation) => ({ name: operation.name ?? operation.id, description: operation.description ?? `${operation.operationType} ${operation.fieldName}`, inputSchema: operation.inputSchema, readOnly: operation.readOnly, route: { connectorId: "installed", resourceId: install.id, toolName: operation.id, catalogGroup, }, })); } } catch (error) { if (install.kind === "mcp") { const parsed = McpConfigSchema.safeParse(install.config); if (parsed.success && parsed.data.preset === "github") { getLogger().warn("GitHub MCP discovery failed", { installId: install.id, error: error instanceof Error ? error.message : String(error), }); } } // One unavailable user-installed source must not hide the other tools. } return []; } async *execute(call: ConnectorCall, context: AdapterContext): AsyncIterable { if (call.route?.connectorId === "installed" && isLazyCatalogControlRoute(call.route)) { try { yield* executeLazyCatalogControl( call, catalogEntries(await this.authorizedTools(context)), (resolved) => this.execute(resolved, context), ); } catch (error) { yield { type: "error", message: sanitizeConnectorError(error) }; } return; } const installId = call.route?.resourceId; if (!installId) { yield { type: "error", message: "Installed connector route is missing" }; return; } const install = await this.prisma.capabilityInstall.findFirst({ where: { id: installId, userId: context.userId, kind: { in: ["mcp", "api", "graphql"] }, }, }); if (!install) { yield { type: "error", message: "Installed connector is unavailable" }; return; } let credential: string | undefined; try { credential = await this.loadCredential(install, context); if (install.kind === "mcp") { const config = McpConfigSchema.parse(install.config); const result = await callRemoteMcpTool( { endpoint: install.source, headers: connectorHeaders(config, credential), signal: context.signal, fetch: this.remote.fetch, resolveHostname: this.remote.resolveHostname, }, call.route?.toolName ?? call.tool, call.args, ); yield { type: "result", data: redactConnectorPayload(result, credential ? [credential] : []), }; return; } if (install.kind === "graphql") { const config = GraphqlConfigSchema.parse(install.config); const operation = config.operations.find( (candidate) => candidate.id === (call.route?.toolName ?? call.tool), ); if (!operation) throw new Error("GraphQL operation is unavailable"); const result = await executeGraphqlOperation( install.source, config, operation, call.args, credential, context.signal, this.remote, ); yield { type: "result", data: redactConnectorPayload(result, credential ? [credential] : []), }; return; } const config = ApiConfigSchema.parse(install.config); const operation = config.operations.find( (candidate) => candidate.id === (call.route?.toolName ?? call.tool), ); if (!operation) throw new Error("API operation is unavailable"); const result = await executeApiOperation( install.source, config, operation, call.args, credential, context.signal, this.remote, ); yield { type: "result", data: redactConnectorPayload(result, credential ? [credential] : []), }; } catch (error) { yield { type: "error", message: sanitizeConnectorError(error, credential ? [credential] : []), }; } } private async loadCredential( install: Pick, context: AdapterContext, ): Promise { if (!install.secretId) return undefined; const row = await this.prisma.secret.findFirst({ where: { id: install.secretId, userId: context.userId }, }); return row ? this.secrets.load(row.ciphertext, row.id) : undefined; } } export async function githubIntegrationDiscoveryHint( prisma: PrismaClient, userId: string, tools: readonly ConnectorTool[], ): Promise { const installs = await prisma.capabilityInstall.findMany({ where: { userId, kind: "mcp" }, select: { id: true, kind: true, config: true, secretId: true }, }); const githubInstall = installs.find((install) => isGithubInstall({ kind: install.kind, config: asRecord(install.config) ?? {} }), ); if (!githubInstall?.secretId) return undefined; const hasGithubTools = tools.some((tool) => { const group = tool.route?.catalogGroup?.toLowerCase(); return group === "github" || tool.name.toLowerCase().includes("github"); }); if (hasGithubTools) return undefined; return "GitHub is connected under Integrations but its MCP tools are unreachable right now. Use git and gh on your computer (brokered auth) for repository work, or ask the user to reconnect GitHub if that fails."; } export async function verifyMcpInstall(input: { source: string; config: unknown; credential?: string; signal?: AbortSignal; remote?: RemoteConnectorDependencies; }): Promise<{ config: Record; toolCount: number }> { assertNoSensitiveQuery(input.source); const config = McpConfigSchema.parse(input.config); requireCredential(config.auth, input.credential); const tools = await listRemoteMcpTools({ endpoint: input.source, headers: connectorHeaders(config, input.credential), signal: input.signal, fetch: input.remote?.fetch, resolveHostname: input.remote?.resolveHostname, }); if (tools.length === 0) throw new Error("MCP server returned no tools"); return { config, toolCount: tools.length }; } export async function prepareApiInstall(input: { source: string; config: Record; credential?: string; signal?: AbortSignal; remote?: RemoteConnectorDependencies; }): Promise<{ source: string; config: Record; operationCount: number }> { const auth = AuthSchema.parse(input.config.auth); requireCredential(auth, input.credential); const headers = PublicHeadersSchema.parse(input.config.headers); assertNoSensitiveQuery(input.source); let source = input.source; let operationsValue = input.config.operations; if (input.config.openApi === true || input.config.openapi === true) { const documentUrl = new URL(input.source); const documentHeaders: Record = { accept: "application/json", ...headers }; applyCredential(documentUrl, documentHeaders, auth, input.credential); const document = await loadOpenApiDocument( documentUrl, documentHeaders, input.signal, input.remote, ); const imported = importOpenApiDocument(document); source = imported.baseUrl; operationsValue = imported.operations; } await assertSafeRemoteUrl(source, input.remote?.resolveHostname); const config = ApiConfigSchema.parse({ auth, headers, operations: operationsValue }); return { source, config, operationCount: config.operations.length, }; } async function loadOpenApiDocument( url: URL, headers: Record, signal?: AbortSignal, remote: RemoteConnectorDependencies = {}, ): Promise> { const safeFetch = createSafeRemoteFetch(remote.fetch, remote.resolveHostname); try { const response = await safeFetch(url, { headers, signal: combineSignals(signal, AbortSignal.timeout(15_000)), }); if (!response.ok) throw new Error(`OpenAPI document returned HTTP ${response.status}`); const size = Number(response.headers.get("content-length") ?? 0); if (size > 2_000_000) throw new Error("OpenAPI document is too large"); const { text, truncated } = await readBoundedText(response, 2_000_000); if (truncated) throw new Error("OpenAPI document is too large"); return JSON.parse(text) as Record; } finally { await safeFetch.close().catch(() => undefined); } } export function importOpenApiDocument(document: Record): { baseUrl: string; operations: ApiOperation[]; } { const server = Array.isArray(document.servers) ? document.servers[0] : undefined; const baseUrl = server && typeof server === "object" && typeof (server as { url?: unknown }).url === "string" ? String((server as { url: string }).url) : ""; if (!baseUrl) throw new Error("OpenAPI document must define servers[0].url"); assertNoSensitiveQuery(baseUrl); const paths = document.paths; if (!paths || typeof paths !== "object" || Array.isArray(paths)) { throw new Error("OpenAPI document has no paths"); } const operations: ApiOperation[] = []; for (const [path, pathValue] of Object.entries(paths)) { if (!pathValue || typeof pathValue !== "object" || Array.isArray(pathValue)) continue; for (const method of ["get", "post", "put", "patch", "delete"] as const) { const raw = (pathValue as Record)[method]; if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; const operation = raw as Record; const id = typeof operation.operationId === "string" ? operation.operationId : undefined; if (!id) continue; const properties: Record = {}; const required: string[] = []; const queryParameters: string[] = []; const headerParameters: string[] = []; const commonParameters = Array.isArray((pathValue as Record).parameters) ? ((pathValue as Record).parameters as unknown[]) : []; const ownParameters = Array.isArray(operation.parameters) ? (operation.parameters as unknown[]) : []; for (const parameterValue of [...commonParameters, ...ownParameters]) { if (!parameterValue || typeof parameterValue !== "object") continue; const parameter = parameterValue as Record; if (typeof parameter.name !== "string") continue; properties[parameter.name] = asRecord(parameter.schema) ?? { type: "string" }; if (parameter.in === "query") queryParameters.push(parameter.name); if (parameter.in === "header") { if (isSensitiveHeader(parameter.name) || isTransportHeader(parameter.name)) { throw new Error(`OpenAPI operation ${id} contains unsafe header ${parameter.name}`); } headerParameters.push(parameter.name); } if (parameter.required === true) required.push(parameter.name); } const requestBody = asRecord(operation.requestBody); const content = asRecord(requestBody?.content); const jsonBody = asRecord(content?.["application/json"]); const bodySchema = asRecord(jsonBody?.schema); if (bodySchema) { properties.body = bodySchema; if (requestBody?.required === true) required.push("body"); } operations.push({ id, name: id, description: typeof operation.description === "string" ? operation.description : typeof operation.summary === "string" ? operation.summary : `${method.toUpperCase()} ${path}`, method: method.toUpperCase() as ApiOperation["method"], path, inputSchema: { type: "object", properties, ...(required.length > 0 ? { required: [...new Set(required)] } : {}), }, readOnly: method === "get", queryParameters, headerParameters, }); if (operations.length >= 100) break; } if (operations.length >= 100) break; } if (operations.length === 0) { throw new Error("OpenAPI document has no operations with operationId"); } return { baseUrl, operations }; } function connectorHeaders( config: z.infer, credential?: string, ): Record { const headers = { ...config.headers }; if (!credential || config.auth.type === "none") return headers; const name = config.auth.type === "header" ? config.auth.name : "authorization"; if (!name) throw new Error("Authentication header name is required"); headers[name] = config.auth.type === "bearer" ? `Bearer ${credential}` : credential; return headers; } async function executeApiOperation( baseUrl: string, config: z.infer, operation: ApiOperation, args: Record, credential: string | undefined, signal: AbortSignal, remote: RemoteConnectorDependencies, ): Promise { const consumed = new Set(); const path = operation.path.replace(/\{([^}]+)\}/g, (_match, name: string) => { const value = args[name]; if (value == null) throw new Error(`Missing path parameter ${name}`); consumed.add(name); return encodeURIComponent(String(value)); }); const url = joinApiUrl(baseUrl, path); const headers: Record = { accept: "application/json", ...config.headers }; let body: string | undefined; for (const key of operation.headerParameters) { const value = args[key]; consumed.add(key); if (value != null) headers[key] = String(value); } if (operation.method === "GET" || operation.method === "DELETE") { for (const [key, value] of Object.entries(args)) { if (consumed.has(key) || value == null) continue; if (Array.isArray(value)) { for (const item of value) url.searchParams.append(key, String(item)); } else if (typeof value !== "object") { url.searchParams.set(key, String(value)); } } } else { for (const key of operation.queryParameters) { const value = args[key]; consumed.add(key); if (value == null) continue; if (Array.isArray(value)) { for (const item of value) url.searchParams.append(key, String(item)); } else { url.searchParams.set(key, String(value)); } } const payload = args.body ?? Object.fromEntries(Object.entries(args).filter(([key]) => !consumed.has(key))); body = JSON.stringify(payload); headers["content-type"] = "application/json"; } applyCredential(url, headers, config.auth, credential); const safeFetch = createSafeRemoteFetch(remote.fetch, remote.resolveHostname); try { const response = await safeFetch(url, { method: operation.method, headers, body, signal: combineSignals(signal, AbortSignal.timeout(30_000)), }); const { text, truncated } = await readBoundedText(response, 1_000_000); let data: unknown = text; try { data = text ? JSON.parse(text) : null; } catch { // Keep non-JSON API responses as bounded text. } if (!response.ok) { return { error: true, status: response.status, data, ...(truncated ? { truncated: true } : {}), }; } return { status: response.status, data, ...(truncated ? { truncated: true } : {}) }; } finally { await safeFetch.close().catch(() => undefined); } } function joinApiUrl(baseUrl: string, path: string): URL { const base = new URL(baseUrl); const prefix = base.pathname.replace(/\/$/, ""); base.pathname = `${prefix}/${path.replace(/^\//, "")}`; base.search = ""; base.hash = ""; return base; }