evOo-local/packages/adapters/src/mcp-connector.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
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.
2026-09-20 17:15:16 +02:00

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;
}
}
}