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.
335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
import type {
|
|
AdapterContext,
|
|
ConnectorCall,
|
|
ConnectorEvent,
|
|
ConnectorProvider,
|
|
ConnectorTool,
|
|
} from "@rakazo/adapter-kit";
|
|
import { isLocalMcpHost } from "@rakazo/contracts";
|
|
import type { McpServer, PrismaClient } from "@rakazo/db";
|
|
import { getLogger } from "@rakazo/logging";
|
|
import { catalogToolPrefix } from "./approval-effect.js";
|
|
import { redactConnectorPayload, sanitizeConnectorError } from "./connector-safety.js";
|
|
import {
|
|
CATALOG_EXECUTE,
|
|
catalogEntries,
|
|
DIRECT_TOOL_LIMIT,
|
|
executeLazyCatalogControl,
|
|
isLazyCatalogControlRoute,
|
|
lazyCatalogTools,
|
|
resolveCatalogCall,
|
|
} from "./lazy-tool-catalog.js";
|
|
import type { McpOAuthBroker, OAuthMaterial } from "./mcp-oauth.js";
|
|
import { oauthMaterialSecrets } from "./mcp-oauth.js";
|
|
import { McpSession } from "./mcp-transport.js";
|
|
import type { RemoteTransportDependencies } from "./remote-mcp.js";
|
|
import type { EncryptedSecretStore } from "./secrets.js";
|
|
|
|
type SessionEntry = { session: McpSession; revision: number; material: OAuthMaterial };
|
|
type PendingSession = { revision: number; promise: Promise<McpSession> };
|
|
|
|
/** Runtime MCP connector. Authorization is re-checked against the bot assignment on every call. */
|
|
/**
|
|
* Stale allowlist entries are filtered out below with no error. Discovery already has the
|
|
* live tool list, so compare once and warn when string allowlist names are missing from the
|
|
* server. Non-string JSON values are ignored for both the missing list and the ratio.
|
|
*/
|
|
export function allowlistDrift(
|
|
allowedTools: unknown,
|
|
offered: Array<{ name: string }>,
|
|
): { missing: string[]; offered: number; stringAllowedCount: number } {
|
|
const names = new Set(offered.map((tool) => tool.name));
|
|
const allowed = Array.isArray(allowedTools) ? allowedTools : [];
|
|
const stringAllowed = allowed.filter((name): name is string => typeof name === "string");
|
|
return {
|
|
missing: stringAllowed.filter((name) => !names.has(name)),
|
|
offered: names.size,
|
|
stringAllowedCount: stringAllowed.length,
|
|
};
|
|
}
|
|
|
|
function reportAllowlistDrift(
|
|
assignment: { allowAllTools: boolean; allowedTools: unknown; server: { slug: string } },
|
|
offered: Array<{ name: string }>,
|
|
context: { spaceId: string; botId?: string },
|
|
): void {
|
|
if (assignment.allowAllTools) return;
|
|
const drift = allowlistDrift(assignment.allowedTools, offered);
|
|
if (drift.missing.length === 0) return;
|
|
getLogger().warn(
|
|
`mcp allowlist drift on ${assignment.server.slug}: ${drift.missing.length}/${drift.stringAllowedCount} allowed tools are not offered (server offers ${drift.offered})`,
|
|
{
|
|
spaceId: context.spaceId,
|
|
botId: context.botId,
|
|
// Cap the list: the point is to name the drift, not to print an allowlist.
|
|
missing: drift.missing.slice(0, 10),
|
|
},
|
|
);
|
|
}
|
|
|
|
export class McpConnector implements ConnectorProvider {
|
|
private readonly sessions = new Map<string, SessionEntry>();
|
|
private readonly connecting = new Map<string, PendingSession>();
|
|
constructor(
|
|
private readonly prisma: PrismaClient,
|
|
private readonly secrets: EncryptedSecretStore,
|
|
private readonly options: {
|
|
stdioEnabled?: boolean;
|
|
allowedCommands?: string[];
|
|
network?: RemoteTransportDependencies;
|
|
} = {},
|
|
private readonly oauth?: McpOAuthBroker,
|
|
) {}
|
|
|
|
describe() {
|
|
return {
|
|
id: "mcp",
|
|
contractVersion: "1",
|
|
adapterVersion: "0.1.0",
|
|
capabilities: { discover: true, oauth: true, secretsBrokered: true },
|
|
};
|
|
}
|
|
|
|
async discoverTools(context: AdapterContext): Promise<ConnectorTool[]> {
|
|
const tools = await this.authorizedTools(context);
|
|
if (tools.length <= DIRECT_TOOL_LIMIT) return tools;
|
|
// The catalog wrappers must not be named `mcp_*`: Anthropic rejects Claude Code OAuth
|
|
// requests that carry a tool whose name starts with "mcp_" (single underscore) with a
|
|
// misleading "You're out of extra usage" 400, on every model. Direct MCP tools are
|
|
// `mcp__server__tool` (double underscore) and are accepted; keep the wrappers off "mcp_".
|
|
return lazyCatalogTools(catalogToolPrefix("mcp"), "mcp", "MCP", 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[]> {
|
|
if (!context.botId) return [];
|
|
const assignments = await this.prisma.botMcpServer.findMany({
|
|
where: {
|
|
botId: context.botId,
|
|
spaceId: context.spaceId,
|
|
userId: context.userId,
|
|
server: { enabled: true },
|
|
},
|
|
include: { server: true },
|
|
});
|
|
const groups = await Promise.all(
|
|
assignments.map(async (assignment): Promise<ConnectorTool[]> => {
|
|
try {
|
|
const session = await this.sessionFor(assignment.server, context);
|
|
const listed = await session.listTools({ signal: context.signal });
|
|
reportAllowlistDrift(assignment, listed.tools, context);
|
|
return listed.tools
|
|
.filter(
|
|
(tool) =>
|
|
assignment.allowAllTools ||
|
|
(assignment.allowedTools as unknown[]).includes(tool.name),
|
|
)
|
|
.map((tool) => ({
|
|
name: `mcp__${assignment.server.slug}__${tool.name}`,
|
|
description: tool.description ?? tool.name,
|
|
inputSchema: tool.inputSchema as Record<string, unknown>,
|
|
route: {
|
|
connectorId: "mcp",
|
|
resourceId: assignment.serverId,
|
|
resourceRevision: assignment.server.revision,
|
|
toolName: tool.name,
|
|
catalogGroup: assignment.server.slug,
|
|
},
|
|
}));
|
|
} catch (error) {
|
|
// A single unavailable server must not hide tools from other connectors.
|
|
getLogger().error(
|
|
`mcp discovery failed for server ${assignment.server.slug}:`,
|
|
sanitizeConnectorError(error),
|
|
);
|
|
await this.evict(this.sessionKey(assignment.server, context));
|
|
return [];
|
|
}
|
|
}),
|
|
);
|
|
return groups.flat();
|
|
}
|
|
|
|
async *execute(call: ConnectorCall, context: AdapterContext): AsyncIterable<ConnectorEvent> {
|
|
if (call.route?.connectorId !== "mcp") {
|
|
yield { type: "error", message: `MCP route required for ${call.tool}` };
|
|
return;
|
|
}
|
|
if (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;
|
|
}
|
|
if (!call.route.resourceId) {
|
|
yield { type: "error", message: `MCP route required for ${call.tool}` };
|
|
return;
|
|
}
|
|
if (!context.botId) {
|
|
yield { type: "error", message: "MCP tools require a bot context" };
|
|
return;
|
|
}
|
|
const assignment = await this.prisma.botMcpServer.findFirst({
|
|
where: {
|
|
botId: context.botId,
|
|
serverId: call.route.resourceId,
|
|
spaceId: context.spaceId,
|
|
userId: context.userId,
|
|
server: { enabled: true },
|
|
},
|
|
include: { server: true },
|
|
});
|
|
if (
|
|
!assignment ||
|
|
(!assignment.allowAllTools &&
|
|
!(assignment.allowedTools as unknown[]).includes(call.route.toolName))
|
|
) {
|
|
yield { type: "error", message: "MCP tool is not assigned to this bot" };
|
|
return;
|
|
}
|
|
// Capture material before callTool so a concurrent eviction/replace cannot
|
|
// drop this call's OAuth secrets from model-visible redaction. Recompute via
|
|
// oauthMaterialSecrets(material) so in-place token refresh stays covered.
|
|
let material: OAuthMaterial | undefined;
|
|
try {
|
|
const session = await this.sessionFor(assignment.server, context);
|
|
material = this.sessions.get(this.sessionKey(assignment.server, context))?.material;
|
|
const result = await session.callTool(call.route.toolName, call.args, {
|
|
signal: context.signal,
|
|
});
|
|
const secrets = material ? oauthMaterialSecrets(material) : [];
|
|
yield { type: "result", data: redactConnectorPayload(result, secrets) };
|
|
} catch (error) {
|
|
// A thrown call means the transport or auth broke; drop the session so the next call reconnects.
|
|
const secrets = material ? oauthMaterialSecrets(material) : [];
|
|
await this.evict(this.sessionKey(assignment.server, context));
|
|
yield { type: "error", message: sanitizeConnectorError(error, secrets) };
|
|
}
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
await Promise.allSettled([...this.connecting.values()].map(({ promise }) => promise));
|
|
await Promise.all([...this.sessions.values()].map(({ session }) => session.close()));
|
|
this.sessions.clear();
|
|
this.connecting.clear();
|
|
}
|
|
|
|
private sessionKey(server: McpServer, context: AdapterContext): string {
|
|
// Identity headers are applied once, at connect time, so a session is only
|
|
// valid for the identity it connected as. The key has to carry that identity.
|
|
return `${server.id} ${context.spaceId} ${context.userId}`;
|
|
}
|
|
|
|
private async evict(sessionKey: string): Promise<void> {
|
|
const entry = this.sessions.get(sessionKey);
|
|
if (!entry) return;
|
|
this.sessions.delete(sessionKey);
|
|
await entry.session.close();
|
|
}
|
|
|
|
private async sessionFor(server: McpServer, context: AdapterContext): Promise<McpSession> {
|
|
const sessionKey = this.sessionKey(server, context);
|
|
const existing = this.sessions.get(sessionKey);
|
|
if (existing && existing.revision === server.revision) return existing.session;
|
|
const pending = this.connecting.get(sessionKey);
|
|
if (pending?.revision === server.revision) return pending.promise;
|
|
if (pending) {
|
|
await pending.promise.catch(() => undefined);
|
|
await this.evict(sessionKey);
|
|
return this.sessionFor(server, context);
|
|
}
|
|
if (existing) await this.evict(sessionKey);
|
|
|
|
const promise = this.connectSession(server, context).then(({ session, material }) => {
|
|
this.sessions.set(sessionKey, { session, revision: server.revision, material });
|
|
return session;
|
|
});
|
|
this.connecting.set(sessionKey, { revision: server.revision, promise });
|
|
try {
|
|
return await promise;
|
|
} finally {
|
|
if (this.connecting.get(sessionKey)?.promise === promise) this.connecting.delete(sessionKey);
|
|
}
|
|
}
|
|
|
|
private async connectSession(
|
|
server: McpServer,
|
|
context: AdapterContext,
|
|
): Promise<{ session: McpSession; material: OAuthMaterial }> {
|
|
const session = new McpSession({ name: `rakazo-${server.slug}` });
|
|
try {
|
|
const secret = server.secretId
|
|
? await this.prisma.secret.findFirst({
|
|
where: {
|
|
id: server.secretId,
|
|
spaceId: context.spaceId,
|
|
userId: context.userId,
|
|
},
|
|
})
|
|
: null;
|
|
const material = secret
|
|
? (JSON.parse(this.secrets.load(secret.ciphertext, secret.id)) as OAuthMaterial)
|
|
: {};
|
|
const loaded = { material, ...(secret ? { secretId: secret.id } : {}) };
|
|
const args = Array.isArray(server.args) ? server.args.map(String) : [];
|
|
const env = { ...(material.env ?? {}) };
|
|
if (server.transport === "stdio") {
|
|
if (!this.options.stdioEnabled) throw new Error("MCP stdio is disabled");
|
|
await session.connectStdio({
|
|
command: String(server.command ?? ""),
|
|
args,
|
|
env,
|
|
allowedCommands: this.options.allowedCommands ?? [],
|
|
signal: context.signal,
|
|
});
|
|
} else {
|
|
if (!server.endpoint) throw new Error("MCP endpoint is required");
|
|
const endpoint = new URL(server.endpoint);
|
|
const localHttp = endpoint.protocol === "http:" && isLocalMcpHost(endpoint.hostname);
|
|
const authProvider =
|
|
!localHttp && this.oauth
|
|
? await this.oauth.providerFor(server, context, loaded)
|
|
: undefined;
|
|
const staticToken = material.secret
|
|
? material.secret.startsWith("Bearer ")
|
|
? material.secret
|
|
: `Bearer ${material.secret}`
|
|
: undefined;
|
|
const headers = {
|
|
...(material.headers ?? {}),
|
|
...(staticToken ? { Authorization: staticToken } : {}),
|
|
};
|
|
await session.connectRemote({
|
|
url: server.endpoint,
|
|
urlPolicy: { allowHttpLocalhost: localHttp, allowLocalHttpCredentials: localHttp },
|
|
transport: server.transport === "sse" ? "sse" : "streamable-http",
|
|
allowLegacySse: server.transport === "sse",
|
|
headerPolicy: { headers },
|
|
fallbackToSse: false,
|
|
authProvider,
|
|
network: this.options.network,
|
|
signal: context.signal,
|
|
});
|
|
}
|
|
return { session, material };
|
|
} catch (error) {
|
|
await session.close().catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|