import type { AdapterContext, ConnectorCall, ConnectorEvent, ConnectorProvider, ConnectorTool, } from "@rakazo/adapter-kit"; import type { PrismaClient } from "@rakazo/db"; 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", "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 { const installs = await this.prisma.capabilityInstall.findMany({ where: { spaceId: context.spaceId, 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 { // 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, spaceId: context.spaceId, 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, spaceId: context.spaceId, userId: context.userId, }, }); return row ? this.secrets.load(row.ciphertext, row.id) : undefined; } } 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; }