Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
590 lines
20 KiB
TypeScript
590 lines
20 KiB
TypeScript
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<typeof ApiOperationSchema>;
|
|
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<ConnectorTool[]> {
|
|
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<ConnectorTool[]> {
|
|
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<ConnectorTool[]> {
|
|
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<ConnectorEvent> {
|
|
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<InstalledRow, "secretId">,
|
|
context: AdapterContext,
|
|
): Promise<string | undefined> {
|
|
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<string, unknown>; 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<string, unknown>;
|
|
credential?: string;
|
|
signal?: AbortSignal;
|
|
remote?: RemoteConnectorDependencies;
|
|
}): Promise<{ source: string; config: Record<string, unknown>; 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<string, string> = { 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<string, string>,
|
|
signal?: AbortSignal,
|
|
remote: RemoteConnectorDependencies = {},
|
|
): Promise<Record<string, unknown>> {
|
|
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<string, unknown>;
|
|
} finally {
|
|
await safeFetch.close().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
export function importOpenApiDocument(document: Record<string, unknown>): {
|
|
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<string, unknown>)[method];
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
const operation = raw as Record<string, unknown>;
|
|
const id = typeof operation.operationId === "string" ? operation.operationId : undefined;
|
|
if (!id) continue;
|
|
const properties: Record<string, unknown> = {};
|
|
const required: string[] = [];
|
|
const queryParameters: string[] = [];
|
|
const headerParameters: string[] = [];
|
|
const commonParameters = Array.isArray((pathValue as Record<string, unknown>).parameters)
|
|
? ((pathValue as Record<string, unknown>).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<string, unknown>;
|
|
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<typeof McpConfigSchema>,
|
|
credential?: string,
|
|
): Record<string, string> {
|
|
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<typeof ApiConfigSchema>,
|
|
operation: ApiOperation,
|
|
args: Record<string, unknown>,
|
|
credential: string | undefined,
|
|
signal: AbortSignal,
|
|
remote: RemoteConnectorDependencies,
|
|
): Promise<unknown> {
|
|
const consumed = new Set<string>();
|
|
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<string, string> = { 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;
|
|
}
|