server/chat-apps-mcp.ts
Rama main · réplica de lectura del repositorio original.
import { approvalModeFor } from "../shared/approval-mode.ts";
import { APP_LIMITS, APP_MIME, appBlockedText, appDoneOf, mcpAppFallbackText, oneLine, type AppBlockedReason, type AppCan, type AppDocument, type AppDoneState, type AppPayload, type AppToolResult } from "../shared/app.ts";
import { mcpAppNetwork } from "../shared/app-frame/csp.ts";
import { allowsTool, canUseMcpServer } from "../shared/tool-scope.ts";
import { appRow, rowRate, type ChatApps } from "./chat-apps.ts";
import { speakerIn, type AgentCardResult } from "./chat-widgets.ts";
import type { McpServerSpec } from "./contracts.ts";
import { McpAppCatalog, mcpServerConfigHash } from "./mcp-app-catalog.ts";
import { AppConnections } from "./mcp-app-connections.ts";
import { toolUiResource, toolVisibility, uiMeta } from "./mcp-app-observer.ts";
import { McpHttpError, McpRpcError } from "./mcp-http.ts";
import type { McpOAuthManager } from "./mcp-oauth.ts";
import { withMcpSignIn } from "./mcp-oauth.ts";
import { probeMcpServer, sendsCredentials, type McpProbeOptions } from "./mcp-probe.ts";
import type { McpKeys } from "./vault/mcp-keys.ts";
import { isRemoteMcpServer, parseStoredMcpServer, type StoredMcpServer } from "./mcp-registry.ts";
import { lenientToolValidator, schemaProblems } from "./mcp-schema-validator.ts";
import { redactSecretsInText } from "./redact.ts";
import type { McpRpcAnswer, McpRpcRequest } from "./routes/chat-apps.ts";
import type { BotRecord, Message, Store } from "./store.ts";
export const APP_RESOURCE_CACHE_MS = 10 * 60_000;
const STARTUP_PROBE_DELAY_MS = 20_000;
const RPC_CALL_MS = 120_000;
const RPC_OTHER_MS = 30_000;
const REMOTE_ERROR_CHARS = 1_000;
/** A command server started through `npx` may first have to fetch its package. */
const BACKGROUND_PROBE_MS = 45_000;
const AUTOMATIC_PROBE_EVERY_MS = 10 * 60_000;
/** An app's change outside Full access: declined, with no card of our own. */
export const APP_WRITE_DECLINED = "This bot asks before it changes anything, so its apps can only look. Ask the bot to make the change, or set it to Full access.";
type Json = Record<string, unknown>;
function isRecord(value: unknown): value is Json {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export type ChatAppsMcpStore = Pick<Store, "bot" | "botByThread" | "groupByThread" | "messagesFor" | "appendMessage" | "patchMessage" | "projectBotForTask">;
export type ChatAppsMcpOAuth = Pick<McpOAuthManager, "authState" | "accessToken" | "requireSignIn">;
export interface ChatAppsMcpDeps {
store: ChatAppsMcpStore;
apps: ChatApps;
storedServers(): Record<string, unknown>;
/** A server's `vault:<id>` keys, read when it is probed. */
keys?: McpKeys;
mountedServers(bot: BotRecord): Record<string, McpServerSpec>;
personMcp?(): readonly string[];
/** How a command server is started outside a turn: as the turns of its bot, or of any bot (server/mcp-probe.ts `asBot`). */
probeAs?(server: { bot?: string }): NonNullable<McpProbeOptions["asBot"]>;
oauth: ChatAppsMcpOAuth;
dataDir?: string;
catalog?: McpAppCatalog;
connections?: AppConnections;
probe?: typeof probeMcpServer;
startupProbes?: boolean;
log?(line: string): void;
now?(): number;
}
export interface McpAppCallArgs {
bot: { id: string; name: string };
threadId: string;
input: Record<string, unknown>;
authored?(messageId: string): void;
}
export interface ChatAppsMcp {
readonly catalog: McpAppCatalog;
readonly connections: AppConnections;
recordMcpAppCall(args: McpAppCallArgs): Promise<AgentCardResult>;
access(row: Message & { app: AppPayload }, threadId: string): { can: AppCan; blocked?: AppDocument["blocked"] };
rpc(request: McpRpcRequest): Promise<McpRpcAnswer>;
turnMounts(custom: Record<string, McpServerSpec>, toolScope?: unknown): { mcpApps?: Record<string, { extension: boolean }> };
hasApps(name: string): boolean | undefined;
probeOptions(name: string, server: StoredMcpServer): McpProbeOptions;
serverChanged(name: string): void;
forgetServer(name: string): void;
close(): void;
}
function storedServer(name: string, raw: unknown): StoredMcpServer | null {
if (raw === undefined) return null;
const parsed = parseStoredMcpServer(name, raw);
return parsed.ok ? parsed.server : null;
}
function loopbackServer(server: McpServerSpec): boolean {
if (!("url" in server)) return true;
try {
const host = new URL(server.url).hostname.replace(/^\[|\]$/g, "");
return host === "localhost" || host === "127.0.0.1" || host === "::1";
} catch { return false; }
}
function clip(text: string, limit: number): string {
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
}
type ResourceRead =
| { ok: true; html: string; ui: Json | undefined }
| { ok: false; reason: AppBlockedReason };
function mcpToolTitle(definition: Json): string {
const name = String(definition.name);
const annotations = isRecord(definition.annotations) ? definition.annotations : {};
return oneLine(String((typeof definition.title === "string" && definition.title) || (typeof annotations.title === "string" && annotations.title) || name)).slice(0, APP_LIMITS.title) || name.slice(0, APP_LIMITS.title);
}
/** The note is stored and shown, so a secret in the result never is; a link a secret was found in is dropped. */
function redactedDone(done: AppDoneState): AppDoneState {
const { text, url, ...rest } = done;
const clean = text && redactSecretsInText(text);
return { ...rest, ...(clean ? { text: clean } : {}), ...(url && redactSecretsInText(url) === url ? { url } : {}) };
}
const rpcError = (status: number, code: number, message: string, extra: Json = {}): McpRpcAnswer => ({ status, body: { error: { code, message }, ...extra } });
export function createChatAppsMcp(deps: ChatAppsMcpDeps): ChatAppsMcp {
const { store, apps } = deps;
const now = deps.now ?? Date.now;
const log = deps.log ?? ((line: string) => console.info(`chat apps: ${line}`));
const catalog = deps.catalog ?? new McpAppCatalog(deps.dataDir ? `${deps.dataDir}/mcp-app-catalog.json` : null);
// A command server OpenMausBot starts by itself (the background probe, an app's own connection) runs where the bots'
// turns run it: in the bot box where that is on, never beside the server.
const asBot = (name: string) => {
const stored = storedServer(name, deps.storedServers()[name]);
return deps.probeAs?.(stored && !isRemoteMcpServer(stored) ? stored : {}) ?? {};
};
const connections = deps.connections ?? new AppConnections({
asBot,
onTools: (name, server, tools, extensions) => {
if (storedServer(name, deps.storedServers()[name])?.apps === false) return;
catalog.recordList(name, server, tools, extensions);
},
});
const probe = deps.probe ?? probeMcpServer;
const rate = rowRate(APP_LIMITS.rpcPerMinute, 60_000);
const resources = new Map<string, { at: number; read: Promise<ResourceRead> }>();
const extensionFor = (name: string, server: McpServerSpec | StoredMcpServer) => catalog.get(name, server)?.uiExtension !== "refused";
const signedIn = async (name: string, server: McpServerSpec): Promise<McpServerSpec | null> => {
if (!("url" in server)) return server;
const out = await withMcpSignIn({ [name]: server }, deps.oauth as McpOAuthManager);
return out[name] ?? null;
};
const readResource = (name: string, server: McpServerSpec, uri: string): Promise<ResourceRead> => {
const key = `${name}\u0000${mcpServerConfigHash(server)}\u0000${uri}`;
const cached = resources.get(key);
if (cached && now() - cached.at < APP_RESOURCE_CACHE_MS) return cached.read;
const read = (async (): Promise<ResourceRead> => {
try {
const extension = extensionFor(name, server);
const result = await connections.request(name, server, "resources/read", { uri }, AbortSignal.timeout(RPC_OTHER_MS), extension);
const contents = isRecord(result) && Array.isArray(result.contents) ? result.contents.filter(isRecord) : [];
const item = contents.find((content) => content.uri === uri) ?? contents[0];
if (!item || item.mimeType !== APP_MIME) return { ok: false, reason: "unavailable" };
let html: string | undefined;
if (typeof item.text === "string") html = item.text;
else if (typeof item.blob === "string" && item.blob.length <= Math.ceil(APP_LIMITS.mcpHtmlBytes / 3) * 4 + 4) html = Buffer.from(item.blob, "base64").toString("utf8");
if (html === undefined || Buffer.byteLength(html) > APP_LIMITS.mcpHtmlBytes) return { ok: false, reason: html === undefined ? "unavailable" : "too-large" };
let ui = uiMeta(item);
if (!ui) {
// The MCP Apps draft also allows `_meta.ui` on the resources/list entry.
const listed = await connections.request(name, server, "resources/list", {}, AbortSignal.timeout(RPC_OTHER_MS), extension).catch(() => undefined);
const entry = isRecord(listed) && Array.isArray(listed.resources) ? listed.resources.find((resource) => isRecord(resource) && resource.uri === uri) : undefined;
ui = uiMeta(entry);
}
return { ok: true, html, ui };
} catch (error) {
return { ok: false, reason: error instanceof McpHttpError && error.status === 401 ? "signed-out" : "unavailable" };
}
})();
resources.set(key, { at: now(), read });
void read.then((value) => { if (!value.ok && resources.get(key)?.read === read) resources.delete(key); });
if (resources.size > 200) for (const [other, entry] of resources) if (now() - entry.at >= APP_RESOURCE_CACHE_MS) resources.delete(other);
return read;
};
async function recordMcpAppCall(args: McpAppCallArgs): Promise<AgentCardResult> {
const { threadId, input } = args;
// No row for an unmounted server, one outside the bot's scope or one whose apps are off; the observer reads nothing
// back.
const bot = store.bot(args.bot.id);
if (!bot) return { ok: false, status: 404, error: "No such bot." };
const name = input.server;
const definition = input.definition;
const call = input.call;
const result = input.result;
if (typeof name !== "string" || !isRecord(definition) || typeof definition.name !== "string" || !definition.name || !isRecord(definition._meta)
|| !isRecord(call) || (call.arguments !== undefined && !isRecord(call.arguments))
|| !isRecord(result) || !Array.isArray(result.content)) {
return { ok: false, status: 400, error: "mcp_app takes {server, definition, call, result}." };
}
const resourceUri = toolUiResource(definition);
if (!resourceUri) return { ok: false, status: 400, error: "That tool has no app." };
const mounted = deps.mountedServers(bot);
const server = Object.hasOwn(mounted, name) ? mounted[name] : undefined;
if (!server || !canUseMcpServer(bot.toolScope, name)) return { ok: false, status: 404, error: "That server is not one of this bot's." };
if (storedServer(name, deps.storedServers()[name])?.apps === false) return { ok: false, status: 409, error: "Apps are off for that server." };
const spec = await signedIn(name, server);
const read: ResourceRead = spec ? await readResource(name, spec, resourceUri) : { ok: false, reason: "signed-out" };
const info = isRecord(input.serverInfo) ? input.serverInfo : (spec ? await connections.serverInfo(name, spec) : undefined);
const serverTitle = oneLine(String((typeof info?.title === "string" && info.title) || (typeof info?.name === "string" && info.name) || name)).slice(0, 80);
const toolTitle = mcpToolTitle(definition);
const toolResult: AppToolResult = {
content: result.content,
...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}),
...(isRecord(result._meta) ? { _meta: result._meta } : {}),
...(typeof result.isError === "boolean" ? { isError: result.isError } : {}),
};
const tool = {
name: definition.name,
...(typeof definition.title === "string" ? { title: definition.title } : {}),
...(typeof definition.description === "string" ? { description: definition.description } : {}),
...(isRecord(definition.inputSchema) ? { inputSchema: definition.inputSchema } : {}),
...(isRecord(definition.annotations) ? { annotations: definition.annotations } : {}),
_meta: definition._meta,
};
let payload: AppPayload;
const ref = {
server: name, serverTitle, tool: definition.name, resourceUri,
...(toolResult.isError ? { isError: true } : {}),
};
if (read.ok) {
const { network } = mcpAppNetwork(read.ui?.csp, loopbackServer(server));
const stored = apps.docs.put({
source: "mcp", html: read.html,
data: { tool, toolInput: isRecord(call.arguments) ? call.arguments : {}, toolResult, network },
});
payload = {
v: 1, source: "mcp", title: toolTitle, doc: stored.ok ? stored.doc : "", rev: 0, bytes: Buffer.byteLength(read.html),
border: read.ui?.prefersBorder !== false, network, mcp: ref,
...(stored.ok ? {} : { blocked: "too-large" as const }),
};
} else {
payload = { v: 1, source: "mcp", title: toolTitle, doc: "", rev: 0, bytes: 0, border: true, network: { connect: [], resources: [] }, mcp: ref, blocked: read.reason };
}
const message = store.appendMessage(threadId, {
role: "bot", kind: "app", text: redactSecretsInText(mcpAppFallbackText(serverTitle, toolTitle, toolResult)), app: payload, ...speakerIn(store, threadId, bot),
});
args.authored?.(message.id);
catalog.recordTool(name, server, definition);
log(`app row ${message.id}: bot=${bot.id} server=${name} tool=${definition.name}${payload.blocked ? ` blocked=${payload.blocked}` : ""}`);
return { ok: true, messageId: message.id };
}
const OPEN: AppCan = { serverTools: true, serverResources: true, message: true, openLinks: true, modelContext: true };
const CLOSED: AppCan = { ...OPEN, serverTools: false, serverResources: false };
function blockedReason(name: string, threadId: string): AppBlockedReason | null {
const stored = storedServer(name, deps.storedServers()[name]);
if (!stored) return "server-removed";
if (!stored.enabled) return "server-disabled";
if (stored.apps === false) return "apps-off";
if (isRemoteMcpServer(stored) && deps.oauth.authState(name, stored.url) === "needs-sign-in") return "signed-out";
const bot = store.botByThread(threadId);
if (!bot || !Object.hasOwn(deps.mountedServers(bot), name) || !canUseMcpServer(bot.toolScope, name)) return "server-removed";
return null;
}
function access(row: Message & { app: AppPayload }, threadId: string): { can: AppCan; blocked?: AppDocument["blocked"] } {
const ref = row.app.mcp;
const reason = ref ? blockedReason(ref.server, threadId) : "server-removed";
if (!reason) return { can: { ...OPEN } };
return { can: { ...CLOSED }, blocked: { reason, text: appBlockedText(reason, ref?.serverTitle ?? ref?.server) } };
}
const approvalOf = (botId: string, threadId: string): "full" | "confirm" => {
const task = store.projectBotForTask(botId, threadId);
return task && approvalModeFor({ ...task, threadId }) === "full" ? "full" : "confirm";
};
/** Nothing configured and nothing secret-shaped reaches the view. */
const serverErrorText = (detail: string, server: McpServerSpec): string => {
let text = detail.slice(0, REMOTE_ERROR_CHARS * 4);
const configured = "url" in server ? [server.url, ...Object.values(server.headers)] : Object.values(server.env);
for (const value of configured) if (value && value.length >= 4) text = text.split(value).join("«redacted»");
return clip(redactSecretsInText(text).trim(), REMOTE_ERROR_CHARS) || "The app's server refused this.";
};
async function answer(request: McpRpcRequest, name: string, server: McpServerSpec): Promise<McpRpcAnswer> {
const { bot, method, params } = request;
const extension = extensionFor(name, server);
if (method === "resources/read") {
if (typeof params.uri !== "string" || !params.uri || params.uri.length > 2_048) return rpcError(400, -32602, "resources/read needs a uri.");
const result = await connections.request(name, server, method, { uri: params.uri }, AbortSignal.timeout(RPC_OTHER_MS), extension);
if (Buffer.byteLength(JSON.stringify(result ?? null)) > APP_LIMITS.rpcResourceBytes) return rpcError(413, -32000, "That resource is too large for this app.");
return { status: 200, body: isRecord(result) ? result : {} };
}
if (method === "resources/list") {
const cursor = typeof params.cursor === "string" ? { cursor: params.cursor } : {};
const result = await connections.request(name, server, method, cursor, AbortSignal.timeout(RPC_OTHER_MS), extension);
return { status: 200, body: isRecord(result) ? result : {} };
}
const tools = await connections.tools(name, server, AbortSignal.timeout(RPC_OTHER_MS), extension);
const callable = (tool: Json) => {
const visibility = toolVisibility(tool);
if (!visibility.includes("app")) return false;
return visibility.includes("model")
? allowsTool(bot.toolScope, { kind: "mcp", server: name, name: String(tool.name) })
: canUseMcpServer(bot.toolScope, name);
};
if (method === "tools/list") return { status: 200, body: { tools: tools.filter(callable) } };
if (typeof params.name !== "string" || !params.name || (params.arguments !== undefined && !isRecord(params.arguments))) {
return rpcError(400, -32602, "tools/call needs {name, arguments?}.");
}
const tool = tools.find((candidate) => candidate.name === params.name);
if (!tool) return rpcError(403, -32602, "This app's server has no tool by that name.");
const visibility = toolVisibility(tool);
if (!visibility.includes("app")) return rpcError(403, -32602, "That tool is not available to apps.");
if (!callable(tool)) return rpcError(403, -32602, "The bot's tool selection does not include that tool.");
const callArgs = isRecord(params.arguments) ? params.arguments : {};
if (Buffer.byteLength(JSON.stringify(callArgs)) > APP_LIMITS.rpcArgumentsBytes) return rpcError(400, -32602, "Those arguments are too large.");
const validate = lenientToolValidator(tool.inputSchema);
let problems: string[] | undefined;
try { problems = validate && !validate(callArgs) ? schemaProblems(validate.errors) : undefined; } catch { problems = undefined; }
if (problems?.length) return rpcError(400, -32602, `Invalid arguments: ${problems.join("; ")}`);
// A change follows the bot's approval level: it runs at Full access, and at any other level, where the bot
// itself would ask first, it is declined. OpenMausBot draws no Allow card of its own.
const annotations = isRecord(tool.annotations) ? tool.annotations : {};
const runs = annotations.readOnlyHint === true
|| (!visibility.includes("model") && annotations.destructiveHint !== true)
|| approvalOf(bot.id, request.threadId) === "full";
if (!runs) return rpcError(403, -32000, APP_WRITE_DECLINED);
const result = await connections.request(name, server, "tools/call", { name: tool.name, arguments: callArgs }, AbortSignal.timeout(RPC_CALL_MS), extension);
const body = isRecord(result) ? result : { content: [] };
// A change the card made itself stays on its row, so the reopened conversation says it is done.
if (visibility.includes("model") && annotations.readOnlyHint !== true && body.isError !== true) {
const row = appRow(store, request.threadId, request.row.id);
if (row) store.patchMessage(request.threadId, row.id, { app: { ...row.app, done: redactedDone(appDoneOf(mcpToolTitle(tool), body, now())) } });
}
return { status: 200, body };
}
async function rpc(request: McpRpcRequest): Promise<McpRpcAnswer> {
const { bot, threadId, row, method, params } = request;
const ref = row.app.mcp;
const tool = method === "tools/call" && typeof params.name === "string" ? ` tool=${params.name.slice(0, 120)}` : "";
const done = (answer: McpRpcAnswer): McpRpcAnswer => {
// Every app-initiated call is logged; its arguments never are.
log(`rpc bot=${bot.id} server=${ref?.server ?? "-"} method=${method}${tool} → ${answer.status}`);
return answer;
};
if (!ref) return done({ status: 404, body: { error: "That app has no server to call." } });
const reason = blockedReason(ref.server, threadId);
const text = (why: AppBlockedReason) => appBlockedText(why, ref.serverTitle ?? ref.server);
const signIn = () => rpcError(401, -32000, "Sign-in needed", { signIn: { server: ref.server } });
if (reason === "signed-out") return done(signIn());
if (reason) return done(rpcError(410, -32000, text(reason), { blocked: { reason, text: text(reason) } }));
if (!rate.take(`${threadId}:${row.id}`)) return done(rpcError(429, -32000, "Too many calls from this app. Wait a minute."));
const mounted = deps.mountedServers(bot)[ref.server];
const server = mounted ? await signedIn(ref.server, mounted) : null;
if (!server) return done(signIn());
try {
return done(await answer(request, ref.server, server));
} catch (error) {
if (error instanceof McpHttpError && error.status === 401) return done(signIn());
if (error instanceof McpRpcError) return done(rpcError(502, error.code ?? -32603, serverErrorText(error.detail, server)));
return done(rpcError(502, -32603, "The app's server could not be reached."));
}
}
// A probe of a command server starts it on this computer, so one is probed only by Settings → Test, or in
// the background for a turn whose bot may use it (that turn starts it anyway).
const queued = new Set<string>();
const lastAutomatic = new Map<string, number>();
const probeStale = (name: string) => {
const last = lastAutomatic.get(name);
if (last !== undefined && now() - last < AUTOMATIC_PROBE_EVERY_MS) return;
lastAutomatic.set(name, now());
queueProbe(name);
};
const remote = (name: string) => {
const stored = storedServer(name, deps.storedServers()[name]);
return stored !== null && isRemoteMcpServer(stored);
};
let probing: Promise<void> = Promise.resolve();
let closed = false;
function probeOptions(name: string, server: StoredMcpServer): McpProbeOptions {
if (server.apps === false) return {};
return {
extension: extensionFor(name, server),
observe: (info) => { catalog.recordList(name, server, info.tools, info.uiExtension); },
};
}
function serverChanged(name: string): void {
if (remote(name)) queueProbe(name);
}
function queueProbe(name: string): void {
if (closed || queued.has(name)) return;
queued.add(name);
probing = probing.then(async () => {
queued.delete(name);
if (closed || deps.personMcp?.().includes(name)) return;
const stored = storedServer(name, deps.storedServers()[name]);
if (!stored?.enabled || stored.apps === false || stored.runsOn) return;
if (isRemoteMcpServer(stored) && deps.oauth.authState(name, stored.url) === "needs-sign-in") return;
await deps.keys?.warm(name, stored);
const keyed = deps.keys ? deps.keys.resolve(name, stored) : { ok: true as const, server: stored };
if (!keyed.ok) return;
const target = isRemoteMcpServer(keyed.server) ? await signedIn(name, keyed.server) : keyed.server;
if (!target) return;
const probed = { ...target, enabled: true } as StoredMcpServer;
const result = await probe(probed, BACKGROUND_PROBE_MS, undefined, { ...probeOptions(name, keyed.server), ...(isRemoteMcpServer(probed) ? {} : { asBot: asBot(name) }) });
if (!result.ok && result.auth === "required" && isRemoteMcpServer(probed) && !sendsCredentials(probed)) deps.oauth.requireSignIn(name, probed.url);
log(`probed ${name}: ${result.ok ? `apps=${catalog.get(name, stored)?.apps === true}` : result.auth === "required" ? "needs sign-in" : "failed"}`);
}).catch((error: unknown) => log(`probe of ${name} failed: ${String(error)}`));
}
function turnMounts(custom: Record<string, McpServerSpec>, toolScope?: unknown): { mcpApps?: Record<string, { extension: boolean }> } {
const mcpApps: Record<string, { extension: boolean }> = {};
const stored = deps.storedServers();
for (const [name, server] of Object.entries(custom)) {
if (storedServer(name, stored[name])?.apps === false) continue;
const entry = catalog.get(name, server);
if (catalog.stale(name, server) && canUseMcpServer(toolScope, name)) probeStale(name);
if (entry?.apps) mcpApps[name] = { extension: entry.uiExtension !== "refused" };
}
return Object.keys(mcpApps).length ? { mcpApps } : {};
}
const onExit = () => connections.closeAll();
process.once("exit", onExit);
const startup = deps.startupProbes === false ? undefined : setTimeout(() => {
for (const [name, raw] of Object.entries(deps.storedServers())) {
const stored = storedServer(name, raw);
if (stored?.enabled && stored.apps !== false && !stored.runsOn && isRemoteMcpServer(stored) && catalog.stale(name, stored)) probeStale(name);
}
}, STARTUP_PROBE_DELAY_MS);
startup?.unref?.();
const service: ChatAppsMcp = {
catalog, connections, recordMcpAppCall, access, rpc, turnMounts, probeOptions, serverChanged,
hasApps(name) {
const raw = deps.storedServers()[name];
return raw && typeof raw === "object" ? catalog.get(name, raw as Record<string, unknown>)?.apps : undefined;
},
forgetServer(name) {
catalog.forget(name);
connections.closeServer(name);
for (const key of resources.keys()) if (key.startsWith(`${name}\u0000`)) resources.delete(key);
},
close() {
closed = true;
if (startup) clearTimeout(startup);
process.off("exit", onExit);
connections.closeAll();
},
};
apps.mcpAccess = access;
apps.recordMcpAppCall = recordMcpAppCall;
return service;
}