/** * Shared MCP server builder. * * Extracted from `server.ts` so the stateless Streamable-HTTP mount * (`mountMCP`) and the stdio transport (`runMCPStdio --standalone`) build the * *same* MCP server from the *same* `ActionEntry` registry. Both surfaces: * * - expose every action as an MCP tool (+ the `ask-agent` meta-tool), * - append the framework deep-link block / `_meta` to every tool result, * - wrap `run()` / `askAgent()` in `runWithRequestContext` so per-user / * per-org scoping (accessFilter, resolveCredential, MCP visibility) is * honoured. * * `server.ts` re-exports `createMCPServerForRequest` and the auth helpers so * any (future) external importer of `@agent-native/core/mcp` keeps resolving. * * Node-only at the SDK level, but this module itself has no Node-only imports * — it can be bundled into the serverless function alongside `mountMCP`. */ import { MCP_APP_EXTENSION_ID, MCP_APP_MIME_TYPE, MCP_APP_RESOURCE_URI_META_KEY, type ActionMcpAppCsp, type ActionMcpAppResourceConfig, } from "../action.js"; import type { ActionEntry } from "../agent/production-agent.js"; import { isMcpActionResult } from "../mcp-client/app-result.js"; import { getConfiguredAppBasePath } from "../server/app-base-path.js"; import { buildDeepLink, toAbsoluteOpenUrl, toDesktopOpenUrl, toVsCodeOpenUrl, } from "../server/deep-link.js"; import { getRequestContext, getRequestOrgId, getRequestUserEmail, runWithRequestContext, } from "../server/request-context.js"; import { isAgentNativeOpenDeepLink, withCollapsedAgentSidebarParam, } from "../shared/agent-sidebar-url.js"; import { MCP_APP_CHAT_BRIDGE_QUERY_PARAM } from "../shared/embed-auth.js"; import { getBuiltinCrossAppTools } from "./builtin-tools.js"; import { MCP_CONNECT_OAUTH_CLIENT_ID, MCP_CONNECT_SCOPE, } from "./connect-store.js"; import { MCP_APP_REQUEST_ORIGIN_CSP_SOURCE } from "./embed-app.js"; import type { ExternalAgentPolicy } from "./external-agent-policy.js"; import { MCP_OAUTH_SCOPES, hasMcpOAuthScope, verifyMcpOAuthAccessToken, } from "./oauth-token.js"; export interface MCPConfig { /** App name shown in MCP server info */ name: string; /** Optional human-facing app title shown by MCP hosts that support titles. */ title?: string; /** * Canonical app id (directory under `apps/`, e.g. `mail`) this MCP server * is mounted for. Optional & back-compat: when omitted the builtin * cross-app tools fall back to lowercasing `name`. Used by `open_app` / * `ask_app` / `create_workspace_app` to tell "this app" from a cross-app * target so they resolve the *target* app's origin rather than echoing the * current request origin. */ appId?: string; /** App description */ description: string; /** Optional canonical website URL for hosts that surface MCP app details. */ websiteUrl?: string; /** Optional app icons for MCP hosts that render server branding. */ icons?: Array<{ src: string; mimeType?: string; sizes?: string[]; theme?: "light" | "dark"; }>; /** Version string (default "1.0.0") */ version?: string; /** Action registry — same as agent chat and A2A */ actions: Record; /** * Full ("production") action surface served to an **authenticated real * caller** — a connect-minted token, an `agent-native mcp install` stdio * proxy (owner-email header / `AGENT_NATIVE_OWNER_EMAIL`), or a deployed / * `AGENT_MODE=production` app. In local dev `actions` is intentionally the * sparse, dev-toggled surface (builtins + read-only public-agent actions) * so the local agent chat and unauthenticated dev probes don't see every * mutating tool; but per the external-agents contract a real caller that * connected with a token MUST get the full surface even in dev. When unset * (production, where `actions` already IS the full set) the swap is a * no-op. See `external-agents` skill, "Dev vs production tool surface". */ productionActions?: Record; /** Handler for the ask-agent meta-tool — runs the full agent loop */ askAgent?: (message: string) => Promise; /** * Disable the generic cross-app builtin tools (`list_apps`, `open_app`, * `ask_app`, `create_workspace_app`, `list_templates`). They are merged in * by default so external agents get a stable verb set; a template action of * the same name always wins (template precedence). Set to `false` only for * a constrained / locked-down mount. */ builtinCrossAppTools?: boolean; /** * Curated allow-list of action names served to **external connector** clients * on a hosted multi-tenant deployment. * * Whenever this list is non-empty it is active by default for **every** * caller — hosted connectors, code/stdio clients, and the local CLI alike. * The MCP server trims both the advertised tool list *and* the callable * surface to exactly these names (plus any builtin cross-app tools such as * `list_apps` / `open_app`). Any tool call for a name **not** in the list is * rejected — it is not merely hidden. This prevents the ~105-tool full * catalog from landing in every external agent's context window and removes * footguns (db-exec, seed-*, extension tools, browser-session tools, etc.) * from connectors. It is no longer gated behind an environment variable, and * the catalog is never inferred from the client name/user-agent. * * `tool-search` stays available in the compact catalog for discovery. A * searched action still needs the connector catalog or authenticated-read * policy before `tools/call`; callers who need the full surface up front opt in * explicitly with `agent-native connect --full-catalog` (embeds a * `catalog_scope: "full"` claim in the connect-minted JWT) or the * deployment-wide `AGENT_NATIVE_MCP_FULL_CATALOG=1` env override. * * Declare this in your template's `createAgentChatPlugin` options rather than * setting it on `MCPConfig` directly; the plugin copies it through. */ connectorCatalog?: string[]; /** * Optional policy for automatically exposing explicitly annotated, * authenticated read actions to external MCP callers. */ externalAgents?: ExternalAgentPolicy; } /** * Identity extracted from a verified MCP bearer token / JWT. Used to wrap * `entry.run()` and `config.askAgent()` calls in `runWithRequestContext` * so downstream tools (db-query, accessFilter, resolveCredential) honour * per-user / per-org scoping. Without this wrap the MCP endpoint would * silently bypass tenant isolation. See finding #6 in * /tmp/security-audit/12-mcp-a2a-agent.md. */ export interface MCPCallerIdentity { userEmail: string | undefined; orgId?: string | undefined; orgDomain: string | undefined; /** Present only for standard remote MCP OAuth access tokens. */ oauthScopes?: string[]; /** Present only for standard remote MCP OAuth access tokens. */ oauthClientId?: string; /** Present only for framework-minted first-party MCP client tokens. */ firstPartyMcp?: boolean; } /** Per-request context used to turn an action's relative deep link into the * absolute web URL (and desktop `agentnative://` URL) the external agent * surfaces. Derived from the inbound request headers in `mountMCP`, or from * the resolved local app origin in the stdio standalone path. */ export interface MCPRequestMeta { /** Origin of the running app, e.g. `http://localhost:8100`. */ origin?: string; /** Optional mount prefix for path-mounted apps, e.g. `/mail`. */ basePath?: string; /** Optional client preference for which URL the *markdown* link uses. */ target?: "browser" | "desktop" | "terminal"; /** * Best-effort caller label derived from MCP transport headers. Chat-style * remote hosts should stay on the compact catalog; code/stdio clients can * explicitly identify themselves to keep the full action surface. */ clientName?: string; /** Explicit framework client hint from `x-agent-native-mcp-client`. */ clientHint?: string; /** Explicit opt-in to the full tool catalog for code/stdio style clients. */ fullCatalog?: boolean; /** * The caller authenticated with a real credential (verified A2A/connect * JWT, matching ACCESS_TOKEN, or a forwarded owner-email header from * `agent-native mcp install`) — not the unauthenticated local dev-open * path. When true, `createMCPServerForRequest` serves * `config.productionActions` (the full surface) instead of the sparse dev * `config.actions`. Set by `mountMCP` from `verifyAuth`. */ fullSurface?: boolean; /** * Whether this request may receive inline MCP App embeds (the `ui://` * resource reference hosts render in an iframe). Resolved once per request by * `createMCPServerForRequest` from `isMcpAppsInlineEnabled(identity)` — the * deploy-toggleable kill switch. When `false`, no MCP App resource is * advertised or referenced and tool results fall back to their deep-link * text. Defaults to disabled when unset. */ inlineMcpApps?: boolean; } const ASK_AGENT_DEFAULT_INLINE_WAIT_MS = 20_000; const ASK_AGENT_MAX_INLINE_WAIT_MS = 25_000; function boundedAskAgentWaitMs(raw: unknown): number { if (raw == null || raw === "") return ASK_AGENT_DEFAULT_INLINE_WAIT_MS; const parsed = Number(raw); if (!Number.isFinite(parsed)) return ASK_AGENT_DEFAULT_INLINE_WAIT_MS; return Math.max( 0, Math.min(ASK_AGENT_MAX_INLINE_WAIT_MS, Math.trunc(parsed)), ); } function isExplicitAsyncAskAgent(raw: unknown): boolean { return raw === true || raw === "true" || raw === 1 || raw === "1"; } function formatAskAgentResult(result: unknown): string { if (typeof result === "string") return result; if (result && typeof result === "object") { const record = result as Record; if (record.status === "completed" && typeof record.response === "string") { return record.response; } } const serialized = JSON.stringify(result); return serialized === undefined ? String(result) : serialized; } /** * Deploy-toggleable kill switch for inline MCP App embeds — the `ui://` * resource reference hosts like Codex / Cursor / ChatGPT render in a sandboxed * iframe. **Off by default**, so a not-yet-verified inline embed never reaches * normal users; flip it on per environment with `AGENT_NATIVE_MCP_APPS_INLINE=1` * and a redeploy. While the global switch is off, accounts listed in * `AGENT_NATIVE_MCP_APPS_INLINE_ALLOW_EMAILS` (comma/space separated) still get * inline embeds, so you can keep verifying a fix in production before enabling * it for everyone. Requires no skills/instructions change — when disabled, tool * results simply fall back to their deep-link text. */ export function isMcpAppsInlineEnabled( identity: MCPCallerIdentity | undefined, ): boolean { const flag = process.env.AGENT_NATIVE_MCP_APPS_INLINE?.trim().toLowerCase(); if (flag === "1" || flag === "true" || flag === "yes" || flag === "on") { return true; } const email = identity?.userEmail?.trim().toLowerCase(); if (email) { const allowed = ( process.env.AGENT_NATIVE_MCP_APPS_INLINE_ALLOW_EMAILS ?? "" ) .split(/[\s,]+/) .map((value) => value.trim().toLowerCase()) .filter(Boolean); if (allowed.includes(email)) return true; } return false; } type McpOAuthScope = (typeof MCP_OAUTH_SCOPES)[number]; function isActionVisibleForOAuthScope( entry: ActionEntry, scopes: string[] | undefined, ): boolean { if (!scopes) return true; const required: McpOAuthScope = entry.readOnly === true ? "mcp:read" : "mcp:write"; return hasMcpOAuthScope(scopes, required); } const COMPACT_MCP_APP_CATALOG_BUILTINS = new Set([ "list_apps", "open_app", "ask_app", "ask_app_status", "create_embed_session", // `tool-search` MUST stay in every compact/connector surface: it is how a // compacted client discovers and loads any action on demand, which is what // makes "small catalog by default" safe instead of limiting. "tool-search", ]); function isActionAdvertisedInCompactMcpAppCatalog( name: string, entry: ActionEntry, config: MCPConfig, ): boolean { if (COMPACT_MCP_APP_CATALOG_BUILTINS.has(name)) return true; if ( (entry.mcpApp as { compactCatalog?: unknown } | undefined) ?.compactCatalog === true ) { return true; } if (config.builtinCrossAppTools === false && entry.mcpApp?.resource) { return true; } return false; } function explicitlyRequestsFullMcpCatalog( requestMeta: MCPRequestMeta | undefined, ): boolean { // Full catalog is a deliberate, rare opt-in — NEVER a default, and NEVER // inferred from the client name / user-agent. It is reached only by an // explicit deployment env or a token minted with // `agent-native connect --full-catalog` (which embeds `catalog_scope: "full"`, // surfaced here as requestMeta.fullCatalog). Dumping ~105 tool schemas // (100k+ tokens) into a context window just because a client called itself // "code"/"cursor"/"codex" was a recurring footgun. Everything else gets the // connector/compact catalog plus `tool-search`, which keeps every tool // discoverable; only permitted actions are callable without full opt-in. if (process.env.AGENT_NATIVE_MCP_FULL_CATALOG === "1") return true; return requestMeta?.fullCatalog === true; } const warnedFullCatalogKeys = new Set(); /** * Loud, deduped warning emitted whenever the full MCP catalog is actually * served. Full catalog is a deliberate, rare opt-in (env or a `--full-catalog` * token claim); logging it makes an accidental ~100k-token tool dump visible * instead of silent, so a regression can't quietly reintroduce the footgun. */ function warnFullCatalogServed(toolCount: number): void { const source = process.env.AGENT_NATIVE_MCP_FULL_CATALOG === "1" ? "AGENT_NATIVE_MCP_FULL_CATALOG=1" : "a token minted with --full-catalog (catalog_scope:full)"; const key = `${source}:${toolCount}`; if (warnedFullCatalogKeys.has(key)) return; warnedFullCatalogKeys.add(key); console.warn( `[agent-native] Serving the FULL MCP tool catalog (${toolCount} tools) via ${source}. ` + `This is a large context payload meant to be a rare, explicit opt-in — most ` + `clients should use the default compact/connector catalog + tool-search instead.`, ); } export function isAuthenticatedReadAction(entry: ActionEntry): boolean { return ( entry.http !== false && entry.http?.method === "GET" && entry.readOnly === true && entry.publicAgent?.expose === true && entry.publicAgent.readOnly === true && entry.publicAgent.requiresAuth === true ); } /** * Hard exclusion list for the `authenticatedReads: "auto"` derivation ONLY * (see `autoAuthenticatedReadNames` below). Explicit `connectorCatalog` * entries are a deliberate, reviewed choice made by the app and are NOT * affected by this list — an app can still list any of these names in * `connectorCatalog` on purpose. * * These are the footgun families this file's other comments already call * out ("removes footguns (db-exec, seed-*, extension tools, browser-session * tools, etc.)" above, and "keeps db-exec / seed-* / extension / * browser-session footguns off the external surface" near the connector * tier below): generic core SQL access, template demo/seed data, the * extension-management suite, live browser-session control, and Context * X-Ray internals. `isAuthenticatedReadAction` only inspects action * metadata (http/readOnly/publicAgent flags) — nothing stops a future * change from mis-annotating one of these with that exact flag set again, * the way `db-query`/`db-schema` were briefly (and accidentally) annotated * before it was caught in review. These names can never be auto-derived * from metadata alone; exposing one to external callers requires an * explicit `connectorCatalog` entry. */ const AUTO_READ_EXCLUDED_ACTION_NAMES = new Set([ "db-query", "db-schema", "db-exec", "db-patch", "context-manifest-get", "context-preview-get", "context-pin", "context-evict", "context-restore", "context-report", ]); /** * Substring/prefix patterns for excluded name *families* that aren't a * fixed, enumerable set: `seed-*` varies per app/template, and the * extension-management and browser-session tool suites use varying verb * prefixes around a shared noun (e.g. `list-extensions`, `create-extension`, * `hide-extension`; `list-browser-sessions`, `run-browser-session-action`) — * so a leading-prefix match alone would miss most of them. */ const AUTO_READ_EXCLUDED_ACTION_PATTERNS: RegExp[] = [ /^seed-/, /extension/, /browser-session/, ]; export function isAutoReadExcludedActionName(name: string): boolean { return ( AUTO_READ_EXCLUDED_ACTION_NAMES.has(name) || AUTO_READ_EXCLUDED_ACTION_PATTERNS.some((pattern) => pattern.test(name)) ); } function autoAuthenticatedReadNames( actions: Record, config: MCPConfig, ): Set { if (config.externalAgents?.authenticatedReads !== "auto") return new Set(); return new Set( Object.entries(actions) .filter( ([name, entry]) => isAuthenticatedReadAction(entry) && !isAutoReadExcludedActionName(name), ) .map(([name]) => name), ); } function externalAgentDenySet(config: MCPConfig): Set { return new Set( (config.externalAgents?.denyActions ?? []) .map((name) => name.trim()) .filter(Boolean), ); } function externalAgentWritesAreAskAppOnly(config: MCPConfig): boolean { return ( config.externalAgents?.writes === "ask_app_only" || (config.externalAgents?.authenticatedReads === "auto" && config.externalAgents?.writes !== "allowlisted") ); } interface ResolvedMcpAppResource { uri: string; legacyUris?: string[]; name: string; title?: string; description?: string; html: ActionMcpAppResourceConfig["html"]; mimeType: typeof MCP_APP_MIME_TYPE; _meta?: Record; } interface McpAppResourceContext { actionName: string; appId?: string; requestOrigin?: string; } interface VersionedMcpAppResourceUri { uri: string; legacyUris?: string[]; } function metadataObject(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function originString(value: unknown): string | undefined { if (typeof value !== "string" || !value.trim()) return undefined; try { return new URL(value).origin; } catch { return undefined; } } function hostSpecificDomainString(value: unknown): string | undefined { if (typeof value !== "string" || !value.trim()) return undefined; const trimmed = value.trim(); try { new URL(trimmed); return undefined; } catch { return trimmed; } } function withMcpChatBridgeParam(urlOrPath: string): string { try { const base = "http://agent-native.invalid"; const url = urlOrPath.startsWith("/") ? new URL(urlOrPath, base) : new URL(urlOrPath); url.searchParams.set(MCP_APP_CHAT_BRIDGE_QUERY_PARAM, "1"); return urlOrPath.startsWith("/") ? `${url.pathname}${url.search}${url.hash}` : url.toString(); } catch { return urlOrPath; } } function isEmbedStartUrl(value: string): boolean { try { const base = "http://agent-native.invalid"; const url = value.startsWith("/") ? new URL(value, base) : new URL(value); return url.pathname.includes("/_agent-native/embed/start"); } catch { return value.includes("/_agent-native/embed/start"); } } function routePathFromOpenUrl(value: string): string | null { try { const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value); const url = hasScheme ? new URL(value) : new URL(value, "http://agent-native.invalid"); const route = `${url.pathname}${url.search}${url.hash}`; if (!route.startsWith("/") || route.startsWith("//")) return null; if (route.startsWith("/\\")) return null; if (/^\/[a-z][a-z0-9+.-]*:/i.test(route)) return null; return route; } catch { return null; } } /** * Recursively redact embed-ticket-bearing URLs from any value before it gets * serialized into a model-visible text payload. Embed start URLs carry a * single-use ticket that grants iframe access to the user's session — they * MUST stay in `_meta` (where the embed runtime can consume them) and never * appear in `content[].text` for the LLM. This is the generic safety net for * actions that return `{ embedStartUrl, ... }` without declaring * `mcpApp.resource` (the resource path already strips them via * `mcpAppStructuredContent`). * * Circular structures are replaced with a marker. Strings that embed an * `isEmbedStartUrl` substring (e.g. a longer message that includes the URL) * are replaced with `[hidden embed URL]`. Credential-like `ticket` fields are * removed only inside an embed-signaled object/branch, so ordinary business * fields from unrelated read actions remain faithful. */ const EMBED_RESULT_SENSITIVE_KEYS = new Set([ "embedTargetPath", "embedExpiresAt", "embedTicket", ]); function isEmbedCredentialKey(key: string): boolean { return key === "ticket" || /Ticket$/.test(key); } function containsEmbedRoutingSignal( value: unknown, seen = new WeakSet(), ): boolean { if (typeof value === "string") return isEmbedStartUrl(value); if (!value || typeof value !== "object") return false; if (seen.has(value)) return false; seen.add(value); if (Array.isArray(value)) { const result = value.some((item) => containsEmbedRoutingSignal(item, seen)); seen.delete(value); return result; } for (const [key, val] of Object.entries(value)) { if (EMBED_RESULT_SENSITIVE_KEYS.has(key)) { seen.delete(value); return true; } if (containsEmbedRoutingSignal(val, seen)) { seen.delete(value); return true; } } seen.delete(value); return false; } function purgeEmbedStartUrls( value: unknown, seen = new WeakSet(), embedContext = false, ): unknown { if (typeof value === "string") { return isEmbedStartUrl(value) ? "[hidden embed URL]" : value; } if (Array.isArray(value)) { if (seen.has(value)) return "[circular result]"; seen.add(value); // An embed marker in one array item puts the whole result in the embed // routing context. Credential fields in sibling items must not survive // just because the marker lives elsewhere in the array. const arrayEmbedContext = embedContext || containsEmbedRoutingSignal(value); const out = value.map((item) => purgeEmbedStartUrls(item, seen, arrayEmbedContext), ); seen.delete(value); return out; } if (value && typeof value === "object") { if (seen.has(value)) return "[circular result]"; seen.add(value); const entries = Object.entries(value as Record); const localEmbedContext = embedContext || containsEmbedRoutingSignal(value); const out: Record = {}; for (const [key, val] of entries) { if ( EMBED_RESULT_SENSITIVE_KEYS.has(key) || (localEmbedContext && isEmbedCredentialKey(key)) ) { continue; } if (typeof val === "string" && isEmbedStartUrl(val)) { // Drop the key entirely for object-typed inputs so a tool result like // `{ embedStartUrl: "..." }` does not appear at all in the LLM text. continue; } out[key] = purgeEmbedStartUrls(val, seen, localEmbedContext); } seen.delete(value); return out; } return value; } // True when a tool result carries SOME content. An errored/no-plan result comes // back empty (`{}` / null) and would render an empty embed box. We gate on "has // content", not "has a URL" — valid embeds often carry data but no URL (the // shell mints the embed-start itself). function mcpResultHasContent(result: unknown): boolean { if (result == null) return false; if (typeof result === "string") return result.trim().length > 0; if (Array.isArray(result)) return result.length > 0; if (typeof result === "object") return Object.keys(result).length > 0; return Boolean(result); } function mcpAppEmbedOpenLinkMeta( result: unknown, resource: ResolvedMcpAppResource, meta: MCPRequestMeta | undefined, ): Record { const out = metadataObject(result); const embedStartUrl = typeof out.embedStartUrl === "string" ? out.embedStartUrl : out.embed === true && typeof out.url === "string" && out.url.includes("/_agent-native/embed/start") ? out.url : null; if (!embedStartUrl) return {}; const webUrl = toAbsoluteOpenUrl( withMcpChatBridgeParam(embedStartUrl), meta?.origin, ); const deepLinkUrl = typeof out.deepLinkUrl === "string" ? out.deepLinkUrl : null; const fallbackLabel = resource.title ?? resource.name ?? "app"; const label = typeof out.app === "string" && out.app.trim() ? `Open ${out.app.trim()}` : fallbackLabel; const view = typeof out.view === "string" && out.view.trim() ? out.view.trim() : typeof out.path === "string" && out.path.trim() ? out.path.trim() : undefined; // Only fabricate an open URL when there is a real path-like value: an // explicit deepLinkUrl, or a non-embed `out.url`, or a leading-slash // `view`/`path` that's already a route. Bare view-name strings like // "inbox" or "deck" must NOT be turned into `${origin}/inbox` — apps // route views at app-specific paths (e.g. slides routes `view: "deck"` // at `/deck/:id`), so a synthesized origin-relative URL is just a 404. // In that case omit `openLink` entirely; the embedStart meta carries // the actual launch reference. const pathFromRouteLike = view && view.startsWith("/") ? view : typeof out.path === "string" && out.path.trim().startsWith("/") ? out.path.trim() : undefined; const explicitOpenUrl = deepLinkUrl ? deepLinkUrl : typeof out.url === "string" && !isEmbedStartUrl(out.url) ? out.url : pathFromRouteLike; const safeOpenUrl = explicitOpenUrl ? toAbsoluteOpenUrl(explicitOpenUrl, meta?.origin) : null; // Embed open links expose the safe browser target in `webUrl`, but the // desktop URL must enter the app through the registered scheme so Electron // can focus the right webview. Preserve the full route/query in the `to` // param; focus ids are often only present on `url`, not `out.params`. const desktopDeepLinkUrl = (() => { if (!safeOpenUrl) return null; const app = typeof out.app === "string" && out.app.trim() ? out.app.trim() : undefined; if (!app) return safeOpenUrl; if (isAgentNativeOpenDeepLink(safeOpenUrl)) { return toDesktopOpenUrl(safeOpenUrl); } const targetRoute = routePathFromOpenUrl(safeOpenUrl); if (!targetRoute) return safeOpenUrl; const viewParam = typeof out.view === "string" && out.view.trim() ? out.view.trim() : ""; const params = out.params && typeof out.params === "object" && !Array.isArray(out.params) ? (out.params as Record< string, string | number | boolean | null | undefined >) : undefined; return toDesktopOpenUrl( buildDeepLink({ app, view: viewParam, to: targetRoute, ...(params ? { params } : {}), }), ); })(); return { "agent-native/embedStart": { startUrl: webUrl, ...(typeof out.embedExpiresAt === "number" ? { expiresAt: out.embedExpiresAt } : {}), }, ...(safeOpenUrl ? { "agent-native/openLink": { label, ...(view ? { view } : {}), webUrl: safeOpenUrl, desktopUrl: desktopDeepLinkUrl ?? safeOpenUrl, vscodeUrl: toVsCodeOpenUrl(safeOpenUrl), }, } : {}), }; } async function withServerMintedMcpAppEmbedStart( result: unknown, meta: MCPRequestMeta | undefined, ): Promise { if (!result || typeof result !== "object" || Array.isArray(result)) { return result; } const out = result as Record; if (out.embed !== true) return result; if (typeof out.embedStartUrl === "string" && out.embedStartUrl.trim()) { return result; } if ( typeof out.url === "string" && out.url.trim() && isEmbedStartUrl(out.url) ) { return result; } const candidate = [out.url, out.path, out.deepLinkUrl].find( (value): value is string => typeof value === "string" && value.trim().length > 0, ); if (!candidate) return result; const trimmed = candidate.trim(); const isPath = trimmed.startsWith("/") && !trimmed.startsWith("//"); const isAbsoluteHttp = /^https?:\/\//i.test(trimmed); if (!isPath && !isAbsoluteHttp) return result; if (isAbsoluteHttp && !meta?.origin) return result; const ctx = getRequestContext(); const ownerEmail = ctx?.userEmail?.trim(); if (!ownerEmail) return result; const { normalizeEmbedTargetPath, createEmbedSessionTicket } = await import("../server/embed-session.js"); const { buildEmbedStartPath } = await import("../server/embed-route.js"); const targetPath = normalizeEmbedTargetPath( withMcpChatBridgeParam(trimmed), meta?.origin, ); if (!targetPath) return result; const ticket = await createEmbedSessionTicket({ ownerEmail, orgId: ctx?.orgId, targetPath, scope: typeof out.chrome === "string" ? out.chrome : null, }); const startPath = buildEmbedStartPath(ticket.ticket); const embedStartUrl = meta?.origin ? new URL(startPath, meta.origin).toString() : startPath; return { ...out, embedStartUrl, embedTargetPath: targetPath, embedExpiresAt: ticket.expiresAt, }; } /** * Build the deep-link content block + structured `_meta` for a tool result. * Best-effort: any throw / nullish link is swallowed so a bad `link` builder * never fails the tool call. */ export function buildLinkArtifacts( entry: ActionEntry, args: Record, result: any, meta: MCPRequestMeta | undefined, ): { block?: { type: "text"; text: string }; _meta?: Record; } { if (typeof entry.link !== "function") return {}; try { const lk = entry.link({ args: args ?? {}, result }); if (!lk?.url) return {}; const linkUrl = isAgentNativeOpenDeepLink(lk.url) ? withCollapsedAgentSidebarParam(lk.url) : lk.url; const webUrl = toAbsoluteOpenUrl(linkUrl, meta?.origin); const desktopUrl = toDesktopOpenUrl(linkUrl); const vscodeUrl = toVsCodeOpenUrl(webUrl); const markdownUrl = meta?.target === "desktop" ? desktopUrl : webUrl; return { block: { type: "text", text: `\n\n[${lk.label} →](${markdownUrl})` }, _meta: { "agent-native/openLink": { label: lk.label, view: lk.view, webUrl, desktopUrl, vscodeUrl, }, }, }; } catch { return {}; } } /** * Merge the generic cross-app builtin tools into the config's action * registry. **Template actions take precedence**: if a template defines an * action with the same name as a builtin (e.g. its own `list_apps`), the * template entry wins and the builtin is dropped. This mirrors the * template-over-workspace-core precedence in `autoDiscoverActions`. * * The builtins are pure-ish navigators / scaffolders; they call back into the * same `config.actions` / `config.askAgent` so there is no second agent loop. */ function mergeBuiltinTools( config: MCPConfig, baseActions: Record, requestMeta?: MCPRequestMeta, ): Record { if (config.builtinCrossAppTools === false) return baseActions; const builtins = getBuiltinCrossAppTools(config, requestMeta); const merged: Record = { ...builtins }; // Template / app actions overwrite same-named builtins. for (const [name, entry] of Object.entries(baseActions)) { merged[name] = entry; } return merged; } function absoluteMetadataUrl( value: string | undefined, requestMeta?: MCPRequestMeta, ): string | undefined { const trimmed = value?.trim(); if (!trimmed) return undefined; try { if (requestMeta?.origin) { const basePath = requestMeta.basePath ?? getConfiguredAppBasePath(); const appBase = `${requestMeta.origin.replace(/\/+$/, "")}${basePath}/`; const appLocalValue = trimmed.startsWith("/") && !trimmed.startsWith("//") ? trimmed.replace(/^\/+/, "") : trimmed; return new URL(appLocalValue, appBase).href; } return new URL(trimmed).href; } catch { return trimmed; } } function mcpServerInfo(config: MCPConfig, requestMeta?: MCPRequestMeta) { const websiteUrl = absoluteMetadataUrl(config.websiteUrl, requestMeta); const icons = config.icons ?.map((icon) => { const src = absoluteMetadataUrl(icon.src, requestMeta); if (!src) return null; return { src, ...(icon.mimeType ? { mimeType: icon.mimeType } : {}), ...(icon.sizes?.length ? { sizes: icon.sizes } : {}), ...(icon.theme ? { theme: icon.theme } : {}), }; }) .filter((icon): icon is NonNullable => Boolean(icon)); return { name: config.name, version: config.version ?? "1.0.0", ...(config.title?.trim() ? { title: config.title.trim() } : {}), ...(config.description?.trim() ? { description: config.description.trim() } : {}), ...(websiteUrl ? { websiteUrl } : {}), ...(icons?.length ? { icons } : {}), }; } function safeUiSegment(value: string | undefined, fallback: string): string { const normalized = (value || fallback) .trim() .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, ""); return normalized || fallback; } // ChatGPT and Claude cache MCP App resource HTML by `ui://` URI. Bump this // when the shared shell changes in a way that must invalidate host caches. const MCP_APP_RESOURCE_SHELL_VERSION = "shell-v64"; function legacyDefaultMcpAppUri(config: MCPConfig, actionName: string): string { const app = safeUiSegment(config.appId ?? config.name, "agent-native"); const action = safeUiSegment(actionName, "tool"); return `ui://${app}/${action}`; } function versionMcpAppResourceUri( rawUri: string, ): VersionedMcpAppResourceUri | null { const uri = rawUri.trim(); if (!uri.startsWith("ui://")) return null; const versionSuffix = `/${MCP_APP_RESOURCE_SHELL_VERSION}`; let versionedUri: string; try { const parsed = new URL(uri); const path = parsed.pathname.replace(/\/+$/g, ""); parsed.pathname = /\/shell-v\d+$/.test(path) ? path.replace(/\/shell-v\d+$/, versionSuffix) : `${path}${versionSuffix}`; versionedUri = parsed.toString(); } catch { return null; } return { uri: versionedUri, ...(versionedUri !== uri ? { legacyUris: [uri] } : {}), }; } function unversionMcpAppResourceUri(uri: string): string | null { if (!uri.startsWith("ui://")) return null; try { const parsed = new URL(uri); parsed.pathname = parsed.pathname .replace(/\/+$/g, "") .replace(/\/shell-v\d+$/g, ""); return parsed.toString(); } catch { return null; } } function normalizeMcpAppResourceUriForMatch(uri: unknown): string | null { if (typeof uri !== "string") return null; const trimmed = uri.trim(); if (!trimmed.startsWith("ui://")) return null; return ( unversionMcpAppResourceUri(trimmed) ?? trimmed.replace(/\/+$/g, "").replace(/\/shell-v\d+(?=([?#]|$))/g, "") ); } function matchesMcpAppResourceUri( resourceUri: VersionedMcpAppResourceUri, requestedUri: unknown, ): boolean { if (typeof requestedUri !== "string") return false; const requested = requestedUri.trim(); if (resourceUri.uri === requested) return true; if (resourceUri.legacyUris?.includes(requested)) return true; const requestedBase = normalizeMcpAppResourceUriForMatch(requested); const currentBase = normalizeMcpAppResourceUriForMatch(resourceUri.uri); return Boolean(requestedBase && currentBase && requestedBase === currentBase); } function getMcpAppResourceUri( config: MCPConfig, actionName: string, entry: ActionEntry, ): VersionedMcpAppResourceUri | null { const resource = entry.mcpApp?.resource; if (!resource) return null; const baseUri = resource.uri?.trim() || legacyDefaultMcpAppUri(config, actionName); return versionMcpAppResourceUri(baseUri); } function expandRequestOriginSources( sources: string[] | undefined, requestMeta?: MCPRequestMeta, ): string[] | undefined { if (!sources) return undefined; const origin = requestMeta?.origin; return sources.flatMap((source) => source === MCP_APP_REQUEST_ORIGIN_CSP_SOURCE && origin ? [origin] : [source], ); } function openAiWidgetCsp( cspConfig: ActionMcpAppCsp | undefined, requestMeta?: MCPRequestMeta, ): Record | undefined { if (!cspConfig) return undefined; const csp: Record = {}; const connectDomains = expandRequestOriginSources( cspConfig.connectDomains, requestMeta, ); const resourceDomains = expandRequestOriginSources( cspConfig.resourceDomains, requestMeta, ); const frameDomains = expandRequestOriginSources( cspConfig.frameDomains, requestMeta, ); if (connectDomains?.length) csp.connect_domains = connectDomains; if (resourceDomains?.length) csp.resource_domains = resourceDomains; if (frameDomains?.length) csp.frame_domains = frameDomains; return Object.keys(csp).length > 0 ? csp : undefined; } function mcpAppUiMeta( resource: ActionMcpAppResourceConfig, resolvedCsp: ActionMcpAppCsp | undefined, requestMeta?: MCPRequestMeta, description?: string, ): Record | undefined { const base = resource._meta && typeof resource._meta === "object" ? { ...resource._meta } : {}; const existingUi = base.ui && typeof base.ui === "object" && !Array.isArray(base.ui) ? (base.ui as Record) : {}; const ui: Record = { ...existingUi }; delete ui.domain; if (resolvedCsp) { ui.csp = { ...resolvedCsp, connectDomains: expandRequestOriginSources( resolvedCsp.connectDomains, requestMeta, ), resourceDomains: expandRequestOriginSources( resolvedCsp.resourceDomains, requestMeta, ), frameDomains: expandRequestOriginSources( resolvedCsp.frameDomains, requestMeta, ), baseUriDomains: expandRequestOriginSources( resolvedCsp.baseUriDomains, requestMeta, ), }; } if (resource.permissions) ui.permissions = resource.permissions; const hostSpecificDomain = hostSpecificDomainString(resource.domain) ?? hostSpecificDomainString(existingUi.domain); if (hostSpecificDomain) ui.domain = hostSpecificDomain; const openAiWidgetDomain = originString(resource.domain) ?? originString(ui.domain) ?? originString(existingUi.domain) ?? originString(requestMeta?.origin); if (typeof resource.prefersBorder === "boolean") { ui.prefersBorder = resource.prefersBorder; } if (Object.keys(ui).length > 0) base.ui = ui; if (description && base["openai/widgetDescription"] == null) { base["openai/widgetDescription"] = description; } if ( typeof resource.prefersBorder === "boolean" && base["openai/widgetPrefersBorder"] == null ) { base["openai/widgetPrefersBorder"] = resource.prefersBorder; } const openAiCsp = openAiWidgetCsp(resolvedCsp, requestMeta); if (openAiCsp && base["openai/widgetCSP"] == null) { base["openai/widgetCSP"] = openAiCsp; } if (openAiWidgetDomain && base["openai/widgetDomain"] == null) { base["openai/widgetDomain"] = openAiWidgetDomain; } return Object.keys(base).length > 0 ? base : undefined; } async function resolveMcpAppCsp( resource: ActionMcpAppResourceConfig, ctx: McpAppResourceContext, ): Promise { if (!resource.csp) return undefined; return typeof resource.csp === "function" ? await resource.csp(ctx) : resource.csp; } async function resolveMcpAppResource( config: MCPConfig, actionName: string, entry: ActionEntry, requestMeta?: MCPRequestMeta, ): Promise { const resource = entry.mcpApp?.resource; if (!resource) return null; // NB: the inline kill switch is intentionally NOT enforced here. This // resolver also backs `resources/read`, which must keep serving the shell // for a URI the host already holds (e.g. a cached descriptor) so it degrades // gracefully instead of throwing a hard `-32603`. The switch is enforced at // the *advertisement/render* sites (`tools/list` descriptor meta, // `tools/call` result meta, `resources/list`) so disabled embeds never get // advertised in the first place. const resolvedUri = getMcpAppResourceUri(config, actionName, entry); if (!resolvedUri) return null; const description = resource.description ?? entry.tool.description; const resolvedCsp = await resolveMcpAppCsp(resource, { actionName, appId: config.appId, requestOrigin: requestMeta?.origin, }); const resourceMeta = mcpAppUiMeta( resource, resolvedCsp, requestMeta, description, ); return { uri: resolvedUri.uri, ...(resolvedUri.legacyUris ? { legacyUris: resolvedUri.legacyUris } : {}), name: resource.name?.trim() || actionName, ...(resource.title ? { title: resource.title } : {}), ...(description ? { description } : {}), html: resource.html, mimeType: resource.mimeType ?? MCP_APP_MIME_TYPE, ...(resourceMeta ? { _meta: resourceMeta } : {}), }; } async function resolveMcpAppResourceSafely( config: MCPConfig, actionName: string, entry: ActionEntry, requestMeta?: MCPRequestMeta, ): Promise { try { return await resolveMcpAppResource(config, actionName, entry, requestMeta); } catch (error) { console.warn( `[mcp] Skipping MCP App resource for action "${actionName}" because its metadata could not be resolved.`, error, ); return null; } } async function getMcpAppResources( config: MCPConfig, actions: Record, requestMeta?: MCPRequestMeta, ): Promise { // Advertisement path (resources/list + resources/templates/list): suppressed // by the inline kill switch so disabled embeds are never listed. if (!requestMeta?.inlineMcpApps) return []; const resources = await Promise.all( Object.entries(actions).map(([name, entry]) => resolveMcpAppResourceSafely(config, name, entry, requestMeta), ), ); return resources.filter((resource): resource is ResolvedMcpAppResource => Boolean(resource), ); } function renderMcpAppHtml( resource: ResolvedMcpAppResource, actionName: string, config: MCPConfig, requestMeta?: MCPRequestMeta, ): string { if (typeof resource.html === "function") { return resource.html({ actionName, appId: config.appId, requestOrigin: requestMeta?.origin, }); } return resource.html; } function openAiToolDescriptorMeta( resource: ResolvedMcpAppResource, ): Record { const label = resource.title ?? resource.name; const widgetCsp = metadataObject(resource._meta?.["openai/widgetCSP"]); return { "openai/outputTemplate": resource.uri, "openai/toolInvocation/invoking": `Opening ${label}`, "openai/toolInvocation/invoked": `${label} ready`, "openai/widgetAccessible": true, ...(Object.keys(widgetCsp).length > 0 ? { "openai/widgetCSP": widgetCsp } : {}), }; } function openAiToolResultMeta( resource: ResolvedMcpAppResource, ): Record { const label = resource.title ?? resource.name; const widgetCsp = metadataObject(resource._meta?.["openai/widgetCSP"]); return { "openai/outputTemplate": resource.uri, "openai/toolInvocation/invoking": `Opening ${label}`, "openai/toolInvocation/invoked": `${label} ready`, "openai/widgetAccessible": true, ...(Object.keys(widgetCsp).length > 0 ? { "openai/widgetCSP": widgetCsp } : {}), }; } function mcpAppToolUiMeta( resource: ResolvedMcpAppResource, visibility: unknown, ): Record { return { resourceUri: resource.uri, visibility: Array.isArray(visibility) ? visibility : ["model", "app"], }; } function primitiveValue(value: unknown): value is string | number | boolean { return ( typeof value === "string" || typeof value === "number" || typeof value === "boolean" ); } function mcpAppStructuredContent( result: unknown, meta: Record | undefined, ): Record { const purged = purgeEmbedStartUrls(result); const out: Record = purged && typeof purged === "object" && !Array.isArray(purged) ? { ...(purged as Record) } : primitiveValue(purged) ? { result: purged } : {}; for (const key of ["embedStartUrl", "startUrl"]) { const value = out[key]; if (typeof value === "string" && isEmbedStartUrl(value)) delete out[key]; } if (typeof out.url === "string" && isEmbedStartUrl(out.url)) { delete out.url; } // Internal embed-routing fields belong in `_meta["agent-native/embedStart"]` // (consumed by the embed runtime), not in `structuredContent` (read by the // LLM). `embedTargetPath` reveals the exact route + thread/draft id the user // is looking at; `embedExpiresAt` is an unintended timestamp; ticket-bearing // fields are single-use credentials. Drop all of them unconditionally. const openLink = meta?.["agent-native/openLink"]; if (openLink && typeof openLink === "object" && !Array.isArray(openLink)) { const webUrl = (openLink as Record).webUrl; if (typeof webUrl === "string" && isEmbedStartUrl(webUrl)) { return Object.keys(out).length > 0 ? out : { status: "ok" }; } out.openLink = openLink; if (typeof webUrl === "string" && !out.url) out.url = webUrl; } return Object.keys(out).length > 0 ? out : { status: "ok" }; } function truncateToolText(value: string, max = 2000): string { if (value.length <= max) return value; return `${value.slice(0, max - 1)}…`; } function conciseMcpAppToolText( name: string, result: unknown, structuredContent: Record, ): string { if (typeof result === "string") return truncateToolText(result); const message = structuredContent.message; if (typeof message === "string" && message.trim()) { return truncateToolText(message.trim()); } const title = structuredContent.title ?? structuredContent.name; if (typeof title === "string" && title.trim()) { return `${title.trim()} is ready.`; } const id = structuredContent.id; if (typeof id === "string" && id.trim()) { return `${name} completed for ${id.trim()}.`; } return `${name} completed.`; } function isSuccessOnlyResult(value: Record): boolean { const keys = Object.keys(value); if (keys.length === 0) return true; return keys.every((key) => { const item = value[key]; if (key === "ok" || key === "success") return item === true; if (key === "status") { return item === "ok" || item === "success" || item === "completed"; } return false; }); } function conciseToolResultText( name: string, result: unknown, options?: { preserveObjectResult?: boolean }, ): string { const purged = purgeEmbedStartUrls(result); if (typeof purged === "string") return truncateToolText(purged); if (purged === true || purged == null) return `${name} completed.`; if (purged && typeof purged === "object" && !Array.isArray(purged)) { const record = purged as Record; // Read-only actions are data reads, not mutations. Keep their object // payload available to MCP clients in the text fallback too; the // structuredContent branch below is the lossless path for clients that // support it. Mutating/action-style results retain the concise status // text so we do not unexpectedly dump write results into conversations. if (options?.preserveObjectResult) { const text = JSON.stringify(purged); return text === undefined ? `${name} completed.` : truncateToolText(text); } const message = record.message ?? record.summary; if (typeof message === "string" && message.trim()) { return truncateToolText(message.trim()); } const id = record.id ?? record.planId ?? record.commentId; const title = record.title ?? record.name; if (typeof title === "string" && title.trim()) { const titleText = title.trim(); return typeof id === "string" && id.trim() ? `${titleText} (${id.trim()}) is ready.` : `${titleText} is ready.`; } if (typeof id === "string" && id.trim()) { return `${name} completed for ${id.trim()}.`; } const link = record.url ?? record.webUrl ?? record.path; if (typeof link === "string" && link.trim()) { return `${name} completed: ${truncateToolText(link.trim(), 500)}`; } if (isSuccessOnlyResult(record)) return `${name} completed.`; } const text = JSON.stringify(purged); return text === undefined ? `${name} completed.` : truncateToolText(text); } // --------------------------------------------------------------------------- // MCP Server creation — converts ActionEntry registry to MCP tools // --------------------------------------------------------------------------- /** * Build a fully-wired MCP `Server` for a single request / session. * * Shared by the stateless Streamable-HTTP mount (`mountMCP`) and the stdio * standalone transport. The HTTP mount passes the per-request origin via * `requestMeta`; the stdio standalone path passes the resolved local app * origin so deep links still become absolute URLs. */ export async function createMCPServerForRequest( config: MCPConfig, identity: MCPCallerIdentity | undefined, requestMeta?: MCPRequestMeta, ) { const { Server } = await import("@modelcontextprotocol/sdk/server/index.js"); const { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListResourceTemplatesRequestSchema, } = await import("@modelcontextprotocol/sdk/types.js"); // Resolve the effective caller identity. JWT / header-derived identity // (passed by `mountMCP` via `verifyAuth`) wins. When the caller passed no // identity — the stdio **standalone** path — fall back to the // `AGENT_NATIVE_OWNER_EMAIL` env the `agent-native mcp install` flow writes // into the `agent-native mcp serve` process env, so standalone tool runs are // tenant-scoped to the configured owner instead of running unscoped. Stays // undefined for true dev-open (no token, no secret, no owner) — behavior // there is unchanged. const ownerFromEnv = process.env.AGENT_NATIVE_OWNER_EMAIL?.trim(); const effectiveIdentity: MCPCallerIdentity | undefined = identity ?? (ownerFromEnv ? { userEmail: ownerFromEnv, orgDomain: undefined } : undefined); // Resolve the inline-MCP-App kill switch once per request from the effective // identity + environment, then thread it through `requestMeta` so every // resource/tool handler below honors the same decision. An explicit value on // the incoming meta (tests / embedded callers) wins. requestMeta = { ...(requestMeta ?? {}), inlineMcpApps: requestMeta?.inlineMcpApps ?? isMcpAppsInlineEnabled(effectiveIdentity), }; // The action set the request handlers operate on = base actions + generic // cross-app builtins (template wins on name collision). An authenticated // real caller (connect-minted token / `mcp install` owner / production — // `requestMeta.fullSurface`, or the stdio standalone path identified by // `AGENT_NATIVE_OWNER_EMAIL`) gets the full `productionActions` surface // even in local dev; the unauthenticated dev-open path keeps the sparse // `config.actions`. See `external-agents` skill, "Dev vs production tool // surface". const useFullSurface = requestMeta?.fullSurface === true || !!ownerFromEnv; const baseActions = useFullSurface && config.productionActions ? config.productionActions : config.actions; const actions = mergeBuiltinTools(config, baseActions, requestMeta); const visibleActions = Object.fromEntries( Object.entries(actions).filter(([, entry]) => isActionVisibleForOAuthScope(entry, effectiveIdentity?.oauthScopes), ), ); const fullCatalogRequested = explicitlyRequestsFullMcpCatalog(requestMeta); // Compact/connector is the DEFAULT for every caller — hosted connectors, // code clients (Claude Code / Cursor / Codex), and the local CLI alike. The // full ~105-tool catalog is served only on the explicit opt-in above, so a // host can never dump every action schema into one giant tool card. The // `mcp:apps` scope still lands on this compact MCP-Apps surface; with no // opt-in, everyone else does too. const compactMcpAppCatalog = !fullCatalogRequested; const advertisedActionsBeforeConnector = compactMcpAppCatalog ? Object.fromEntries( Object.entries(visibleActions).filter(([name, entry]) => isActionAdvertisedInCompactMcpAppCatalog(name, entry, config), ), ) : visibleActions; const autoReadNames = autoAuthenticatedReadNames(visibleActions, config); const connectorNames = new Set([ ...(config.connectorCatalog ?? []), ...autoReadNames, ]); const denyNames = externalAgentDenySet(config); const automaticConnectorPolicyActive = config.externalAgents?.authenticatedReads === "auto"; // Connector-catalog tier: when a template declares a connector allow-list, // serve exactly that curated surface plus any explicitly annotated // authenticated reads from `externalAgents.authenticatedReads: "auto"`. // This stays compact by default and keeps db-exec / seed-* / extension / // browser-session footguns off the external surface. const connectorCatalogActive = (connectorNames.size > 0 || automaticConnectorPolicyActive) && !fullCatalogRequested; // When the connector catalog is active, filter directly from visibleActions // rather than advertisedActionsBeforeConnector. This ensures the connector // tier is an independent, template-declared surface that doesn't accidentally // narrow to just the compact-catalog builtins when shouldUseCompactMcpCatalogByDefault // would have activated the compact catalog for the same caller. const advertisedActions = connectorCatalogActive ? Object.fromEntries( Object.entries(visibleActions).filter(([name, entry]) => { if (denyNames.has(name)) return false; if (COMPACT_MCP_APP_CATALOG_BUILTINS.has(name)) return true; if (!connectorNames.has(name)) return false; if ( externalAgentWritesAreAskAppOnly(config) && entry.readOnly !== true ) { return false; } return true; }), ) : advertisedActionsBeforeConnector; if (fullCatalogRequested) { warnFullCatalogServed(Object.keys(advertisedActions).length); } const supportsMcpApps = compactMcpAppCatalog || Object.values(advertisedActions).some((entry) => Boolean(entry.mcpApp?.resource), ); const server = new Server(mcpServerInfo(config, requestMeta), { capabilities: { tools: {}, ...(supportsMcpApps ? { resources: {}, extensions: { [MCP_APP_EXTENSION_ID]: { mimeTypes: [MCP_APP_MIME_TYPE], }, }, } : {}), }, }); // Resolve orgId once per request (DB lookup) so subsequent wraps are // synchronous. The caller identity may be undefined for true dev-open — // in that case we run with no userEmail/orgId, which makes downstream // tools that require per-user scope return empty results rather than // cross-tenant data (the safe default). const orgIdPromise = resolveMcpIdentityOrgId(effectiveIdentity); /** * Wrap a callback in * `runWithRequestContext({ userEmail, orgId, requestOrigin }, fn)`. * Both the tools/list and tools/call handlers go through this so * downstream `accessFilter`, `resolveCredential`, and per-user MCP * visibility checks see the verified caller's identity. `requestOrigin` * is the live server origin derived from the inbound request (same value * used to absolutize deep links) so actions that build fetchable URLs * (e.g. design `export-coding-handoff`'s signed raw-code URL) resolve the * correct local-workspace origin instead of a prod/localhost fallback. */ async function withCallerContext(fn: () => Promise): Promise { const orgId = await orgIdPromise; return runWithRequestContext( { userEmail: effectiveIdentity?.userEmail, orgId, ...(requestMeta?.origin ? { requestOrigin: requestMeta.origin } : {}), }, fn, ) as Promise; } // tools/list — return all actions + ask-agent meta-tool. Wrapped in the // request context so per-user MCP visibility (mcp-client/visibility.ts) // applies to the listing too. server.setRequestHandler(ListToolsRequestSchema, async () => { return withCallerContext(async () => { const tools = await Promise.all( Object.entries(advertisedActions).map(async ([name, entry]) => { const hasLink = typeof entry.link === "function"; const mcpAppResource = await resolveMcpAppResourceSafely( config, name, entry, requestMeta, ); const rawToolMeta = (entry.tool as any)._meta && typeof (entry.tool as any)._meta === "object" && !Array.isArray((entry.tool as any)._meta) ? { ...((entry.tool as any)._meta as Record) } : {}; const toolMeta = { ...rawToolMeta, // Advertisement path: only tag the tool with its inline-embed // descriptor when the kill switch is on, so disabled embeds never // prompt a host to render/read the `ui://` resource. ...(mcpAppResource && requestMeta?.inlineMcpApps ? { ...openAiToolDescriptorMeta(mcpAppResource), [MCP_APP_RESOURCE_URI_META_KEY]: mcpAppResource.uri, ui: mcpAppToolUiMeta( mcpAppResource, entry.mcpApp?.visibility ?? metadataObject(rawToolMeta.ui).visibility, ), } : {}), }; const baseDescription = entry.tool.description ?? name; const annotations: Record = { readOnlyHint: entry.readOnly === true, destructiveHint: entry.publicAgent?.isConsequential === true, openWorldHint: false, }; if (hasLink) annotations["agent-native/producesOpenLink"] = true; return { name, description: hasLink ? `${baseDescription} After calling, surface the returned "Open in … →" link to the user.` : baseDescription, inputSchema: entry.tool.parameters ?? { type: "object" as const, properties: {}, }, ...(Object.keys(toolMeta).length > 0 ? { _meta: toolMeta } : {}), annotations, }; }), ); if ( !compactMcpAppCatalog && !connectorCatalogActive && config.askAgent && hasMcpOAuthScope(effectiveIdentity?.oauthScopes, "mcp:write") ) { tools.push({ name: "ask-agent", description: "Send a natural-language message to the app's AI agent and get a response. " + "Use this for complex, multi-step tasks that require the agent's reasoning " + "and full context about the app. On hosted MCP, the server waits briefly " + "for fast completions and returns a taskId plus ask_app_status polling " + "instructions when the agent needs longer.", inputSchema: { type: "object" as const, properties: { message: { type: "string", description: "The message to send to the agent", }, async: { type: "boolean", description: "Start a durable task and return immediately with a taskId.", }, maxWaitMs: { type: "number", description: "Maximum inline wait in milliseconds. Hosted MCP clamps this to 25000ms.", }, }, required: ["message"], }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false, }, }); } return { tools }; }); }); // tools/call — dispatch to action registry or ask-agent. Wrapped in the // request context so the action's `run(args)` and `askAgent()` execute // with the verified caller's identity, not the platform default. server.setRequestHandler(CallToolRequestSchema, async (request: any) => { return withCallerContext(async () => { const { name, arguments: args } = request.params; if (name === "ask-agent" && config.askAgent) { if (compactMcpAppCatalog || connectorCatalogActive) { return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, }; } if (!hasMcpOAuthScope(effectiveIdentity?.oauthScopes, "mcp:write")) { return { content: [ { type: "text", text: "Forbidden: OAuth scope does not allow ask-agent", }, ], isError: true, }; } const message = args?.message ?? ""; try { // Keep the legacy meta-tool compatible for local callers, but use // the same durable A2A submission path as ask_app whenever this is // an HTTP/hosted request. A full agent loop must never be held open // for minutes behind one MCP tools/call request. Always route // through ask_app's own run() — even with no request origin, since // it now bounds that case too via its process-local inline-task // fallback (ask-app-inline-tasks.ts) instead of us awaiting // config.askAgent() here unbounded. This is the shared helper: no // second task map to keep in sync. const hostedAskApp = getBuiltinCrossAppTools( config, requestMeta, ).ask_app; const result = await hostedAskApp.run({ message, async: isExplicitAsyncAskAgent(args?.async), maxWaitMs: isExplicitAsyncAskAgent(args?.async) ? 0 : boundedAskAgentWaitMs(args?.maxWaitMs), }); return { content: [{ type: "text", text: formatAskAgentResult(result) }], }; } catch (err: any) { return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true, }; } } // Connector-catalog tier: when active, callableActions === advertisedActions // (the filtered set). Non-listed tools are not callable — mirroring how // compactMcpAppCatalog gates calls on advertisedActions. const callableActions = compactMcpAppCatalog || connectorCatalogActive ? advertisedActions : actions; const entry = callableActions[name]; if (!entry) { return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, }; } if ( !isActionVisibleForOAuthScope(entry, effectiveIdentity?.oauthScopes) ) { return { content: [ { type: "text", text: `Forbidden: OAuth scope does not allow tool ${name}`, }, ], isError: true, }; } try { // We're inside `withCallerContext`, so the request-context getters // resolve the verified MCP caller's identity (do NOT inject a dev // fallback). Tag the call as an external-agent MCP dispatch. const result = await entry.run((args as Record) ?? {}, { userEmail: getRequestUserEmail(), orgId: getRequestOrgId() ?? null, caller: "mcp", actionName: name, }); const mcpResult = isMcpActionResult(result) ? result : null; const rawResult = mcpResult ? mcpResult.raw : result; const resultForClient = mcpResult ? mcpResult.text : result; const mcpResultIsError = !!mcpResult && !!mcpResult.raw && typeof mcpResult.raw === "object" && (mcpResult.raw as Record).isError === true; // Render path: only treat the result as an inline embed when the kill // switch is on. When off, `mcpAppResource` is null so every embed // branch below degrades to the plain deep-link artifacts the tool would // otherwise return — no `openai/outputTemplate`, no minted embed-start, // no embed structuredContent — so the host shows a link, not an iframe. const mcpAppResourceCandidate = requestMeta?.inlineMcpApps ? await resolveMcpAppResourceSafely(config, name, entry, requestMeta) : null; const rawResultForClient = mcpAppResourceCandidate ? await withServerMintedMcpAppEmbedStart(rawResult, requestMeta) : rawResult; // Only attach the embed widget for a non-error result that has content. const embedHasContent = mcpResultHasContent(rawResultForClient); const mcpAppResource = mcpAppResourceCandidate && !mcpResultIsError && embedHasContent ? mcpAppResourceCandidate : null; // `openai/outputTemplate` is declared at the tool level, so the host // renders a widget for every call regardless of result _meta. The only // per-result signal it honors is `isError` (shows error text, no widget), // so treat an embed tool that produced nothing as an error. const embedProducedNothing = !!mcpAppResourceCandidate && !mcpResultIsError && !embedHasContent; const { block, _meta } = buildLinkArtifacts( entry, (args as Record) ?? {}, rawResultForClient, requestMeta, ); const responseMeta: Record = { ...(_meta ?? {}), ...(mcpAppResource ? mcpAppEmbedOpenLinkMeta( rawResultForClient, mcpAppResource, requestMeta, ) : {}), ...(mcpAppResource ? openAiToolResultMeta(mcpAppResource) : {}), }; const toolUiMeta = metadataObject((entry.tool as any)._meta?.ui); const toolVisibility = toolUiMeta.visibility; const isAppOnlyVisibility = Array.isArray(toolVisibility) && toolVisibility.length > 0 && toolVisibility.every((v) => v === "app"); const readOnlyStructuredResult = entry.readOnly === true && rawResultForClient && typeof rawResultForClient === "object" ? Array.isArray(rawResultForClient) ? { items: rawResultForClient } : rawResultForClient : undefined; const structuredContent = mcpAppResource ? mcpAppStructuredContent(rawResultForClient, responseMeta) : isAppOnlyVisibility && rawResult && typeof rawResult === "object" && !Array.isArray(rawResult) ? (rawResult as Record) : readOnlyStructuredResult ? mcpAppStructuredContent(readOnlyStructuredResult, responseMeta) : undefined; const text = mcpAppResource ? conciseMcpAppToolText(name, resultForClient, structuredContent!) : conciseToolResultText(name, resultForClient, { preserveObjectResult: entry.readOnly === true, }); const content: any[] = [{ type: "text", text }]; if (block) content.push(block); return { content, ...(mcpResultIsError || embedProducedNothing ? { isError: true } : {}), ...(structuredContent ? { structuredContent } : {}), ...(Object.keys(responseMeta).length > 0 ? { _meta: responseMeta } : {}), }; } catch (err: any) { return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true, }; } }); }); if (supportsMcpApps) { server.setRequestHandler(ListResourcesRequestSchema, async () => { return withCallerContext(async () => { const mcpAppResources = await getMcpAppResources( config, advertisedActions, requestMeta, ); return { resources: mcpAppResources.map((resource) => ({ uri: resource.uri, name: resource.name, ...(resource.title ? { title: resource.title } : {}), ...(resource.description ? { description: resource.description } : {}), mimeType: resource.mimeType, ...(resource._meta ? { _meta: resource._meta } : {}), })), }; }); }); server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { return withCallerContext(async () => { const mcpAppResources = await getMcpAppResources( config, advertisedActions, requestMeta, ); return { resourceTemplates: mcpAppResources.map((resource) => ({ uriTemplate: resource.uri, name: resource.name, ...(resource.title ? { title: resource.title } : {}), ...(resource.description ? { description: resource.description } : {}), mimeType: resource.mimeType, ...(resource._meta ? { _meta: resource._meta } : {}), })), }; }); }); server.setRequestHandler( ReadResourceRequestSchema, async (request: any) => { return withCallerContext(async () => { const uri = request.params?.uri; let found: { actionName: string; resource: ResolvedMcpAppResource; } | null = null; for (const [name, entry] of Object.entries(advertisedActions)) { const resourceUri = getMcpAppResourceUri(config, name, entry); if (!resourceUri || !matchesMcpAppResourceUri(resourceUri, uri)) { continue; } const resource = await resolveMcpAppResourceSafely( config, name, entry, requestMeta, ); if (resource) { found = { actionName: name, resource }; break; } // resolveMcpAppResourceSafely returned null (e.g. an async resolver // threw) — keep scanning the remaining candidates rather than // aborting and reporting the resource as missing. } if (!found) { throw new Error(`MCP App resource not found: ${uri}`); } return { contents: [ { uri, mimeType: found.resource.mimeType, text: renderMcpAppHtml( found.resource, found.actionName, config, requestMeta, ), ...(found.resource._meta ? { _meta: found.resource._meta } : {}), }, ], }; }); }, ); } return server; } // --------------------------------------------------------------------------- // Auth — reuses the same pattern as A2A (Bearer token or JWT). Shared so the // HTTP mount and any stdio-side auth-aware helper resolve identity identically. // --------------------------------------------------------------------------- export function getAccessTokens(): string[] { const single = process.env.ACCESS_TOKEN; const multi = process.env.ACCESS_TOKENS; const tokens: string[] = []; if (single) tokens.push(single); if (multi) { tokens.push( ...multi .split(",") .map((t) => t.trim()) .filter(Boolean), ); } return tokens; } /** * Resolve the caller identity for a static-token (or dev-open) auth path. * * Static `ACCESS_TOKEN` / `ACCESS_TOKENS` auth carries no per-caller claims, * so without this the MCP endpoint would run every tool with * `userEmail === undefined` and per-user / per-org scoped actions * (`accessFilter`, `resolveAccess`, `resolveCredential`) would return * empty / wrong data. The `agent-native mcp install` flow writes * `AGENT_NATIVE_OWNER_EMAIL` into the client config env and the stdio proxy * forwards it as the `X-Agent-Native-Owner-Email` request header (see * `mcp/stdio.ts#authHeaders`). We trust that owner hint *only* on the * static-token path — JWT auth already carries a cryptographically verified * `sub`, so the header is ignored there and never widens JWT scope. * * Precedence is server-trusted-first: the server process's * `AGENT_NATIVE_OWNER_EMAIL` env (set out-of-band by the operator / deploy) * ALWAYS wins, and a client-supplied `X-Agent-Native-Owner-Email` header is * honored *only as a fallback when that env is unset*. A static `ACCESS_TOKEN` * is a shared bearer secret; letting a request header override a * server-configured owner would let anyone holding a leaked token act as any * user. The header path remains for the single-tenant local-dev install flow * where the app server process has no owner env and the token *is* the * workspace secret; multi-tenant deployments must use A2A JWT (verified `sub`), * not a static token, for per-user scope. * * Returns `undefined` when no owner email is available (true dev-open: no * token, no secret, no owner) so behavior there stays unchanged. */ function deriveStaticTokenIdentity( ownerEmailHeader: string | undefined, ): MCPCallerIdentity | undefined { const owner = process.env.AGENT_NATIVE_OWNER_EMAIL?.trim() || (typeof ownerEmailHeader === "string" && ownerEmailHeader.trim()) || ""; if (!owner) return undefined; return { userEmail: owner, orgDomain: undefined }; } export function getBearerToken( authHeader: string | undefined, ): string | undefined { if (!authHeader) return undefined; const match = /^Bearer\s+(.+)$/i.exec(authHeader.trim()); return match?.[1]?.trim() || undefined; } function addSecretCandidate( candidates: string[], secret: string | null | undefined, ): void { const trimmed = secret?.trim(); if (!trimmed || candidates.includes(trimmed)) return; candidates.push(trimmed); } async function verifyA2AJwtForMcp( token: string, resourceUrl?: string | string[], ): Promise | null> { const jose = await import("jose"); let unverifiedPayload: Record | null = null; try { unverifiedPayload = jose.decodeJwt(token) as Record; } catch { return null; } const candidateSecrets: string[] = []; addSecretCandidate(candidateSecrets, process.env.A2A_SECRET); const orgDomain = typeof unverifiedPayload.org_domain === "string" ? unverifiedPayload.org_domain : undefined; if (orgDomain) { try { const { getA2ASecretByDomain } = await import("../org/context.js"); addSecretCandidate( candidateSecrets, await getA2ASecretByDomain(orgDomain), ); } catch { // DB not ready or org lookup unavailable — fall back to other candidates. } } const firstPartyMcp = unverifiedPayload.agent_native_first_party_mcp === true; const audiences = firstPartyMcp ? mcpAudienceList(resourceUrl) : null; if (firstPartyMcp && !audiences?.length) return null; for (const secret of candidateSecrets) { const encodedSecret = new TextEncoder().encode(secret); for (const audience of audiences ?? [undefined]) { try { const { payload } = await jose.jwtVerify( token, encodedSecret, audience ? { audience } : undefined, ); return payload as Record; } catch { // Try the next candidate without exposing which secret matched. } } } return null; } function mcpAudienceList(resource: string | string[] | undefined): string[] { const raw = Array.isArray(resource) ? resource : resource ? [resource] : []; const seen = new Set(); const out: string[] = []; for (const value of raw) { const normalized = value.replace(/\/+$/, ""); if (normalized && !seen.has(normalized)) { seen.add(normalized); out.push(normalized); } } return out; } async function isConnectTokenAllowed( jti: string | undefined, ): Promise { if (!jti) return false; try { const { isJtiRevoked, touchTokenUsed } = await import("./connect-store.js"); if (await isJtiRevoked(jti)) return false; // Best-effort usage telemetry — never blocks / throws. void touchTokenUsed(jti); } catch { // Store import / lookup failed — fail open. Signature verification already // passed; this only gates explicit revokes. } return true; } /** * Verify the inbound auth header. Returns: * - { authed: true, identity } when verified — `identity` is derived from * the JWT (`sub` / `org_domain`) for JWT auth, or from the * `AGENT_NATIVE_OWNER_EMAIL` env / `X-Agent-Native-Owner-Email` header * for static-token auth (the `agent-native mcp install` flow). `identity` * is undefined only for true dev-open with no owner hint. * - { authed: false } on rejection. * * When A2A_SECRET is set we extract the JWT's `sub` (caller email) and * `org_domain` claims so the MCP endpoint can wrap tool runs in * `runWithRequestContext({ userEmail, orgId })`. Without that wrap, the * MCP endpoint loses tenant identity and downstream `accessFilter` / * `resolveCredential` calls fall back to platform-wide defaults. * * `ownerEmailHeader` is the forwarded `X-Agent-Native-Owner-Email` value; it * is consulted ONLY on the static-token / dev-open path (never to influence * verified JWT identity), so the install flow runs tools as the configured * owner instead of an unscoped anonymous caller. */ export async function verifyAuth( authHeader: string | undefined, ownerEmailHeader?: string, options: { allowDevOpen?: boolean; resourceUrl?: string | string[] } = {}, ): Promise<{ authed: boolean; identity?: MCPCallerIdentity; /** * The caller presented a real credential — a verified A2A/connect JWT, a * matching ACCESS_TOKEN, or (on the no-auth-configured path) a forwarded * owner-email header from `agent-native mcp install`. Drives the full vs * sparse MCP tool surface in local dev. The pure unauthenticated dev-open * path (no secret, no token, no owner header) is `false`. */ fullSurface?: boolean; /** * The caller explicitly opted up to the full connector catalog by minting * their token with `--full-catalog` (or equivalent). When `true`, the * compact/connector-catalog tier filter (active by default whenever a * `connectorCatalog` is declared) is bypassed for this caller. Derived from a * `catalog_scope: "full"` claim in the verified A2A/connect JWT. */ fullCatalog?: boolean; }> { // No auth configured → allow only when the route caller has already // established that this is a loopback/local dev request. Still honour an // owner hint there so the local install/connect flow stays tenant-scoped. const accessTokens = getAccessTokens(); const hasA2ASecret = !!process.env.A2A_SECRET?.trim(); const token = getBearerToken(authHeader); if (token) { const oauthIdentity = await verifyMcpOAuthAccessToken( token, options.resourceUrl, ); if (oauthIdentity) { if ( oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID && !(await isConnectTokenAllowed(oauthIdentity.jti)) ) { return { authed: false }; } return { authed: true, identity: { userEmail: oauthIdentity.userEmail, ...(oauthIdentity.orgId ? { orgId: oauthIdentity.orgId } : {}), orgDomain: oauthIdentity.orgDomain, oauthScopes: oauthIdentity.scopes, oauthClientId: oauthIdentity.clientId, }, fullSurface: true, // Per-token opt-up: `catalog_scope: "full"` in the OAuth token // bypasses the connector-catalog tier filter on hosted deployments. fullCatalog: oauthIdentity.catalogScope === "full", }; } } if (accessTokens.length === 0 && !hasA2ASecret && !token) { if (options.allowDevOpen === false) { return { authed: false }; } return { authed: true, identity: deriveStaticTokenIdentity(ownerEmailHeader), // `mcp install`'s stdio proxy forwards an owner-email header even when // the local app has no secret configured — that is a real, identified // caller and gets the full surface. A bare browser/curl dev probe with // no owner hint stays on the sparse dev surface. fullSurface: !!(ownerEmailHeader && ownerEmailHeader.trim()), }; } if (!token) return { authed: false }; // Try an A2A JWT via the shared A2A_SECRET first, then the caller org's // synced A2A secret when the token carries org_domain. const payload = await verifyA2AJwtForMcp(token, options.resourceUrl); if (payload) { const tokenScope = typeof payload.scope === "string" ? payload.scope : undefined; if (tokenScope && tokenScope !== MCP_CONNECT_SCOPE) { return { authed: false }; } // Connect-minted tokens (scope === "mcp-connect") carry a random `jti` // and are individually revocable. Only these tokens hit the revoke // store — ordinary A2A delegation JWTs skip the DB lookup entirely so // the hot path is unchanged. The signature was already // cryptographically verified, so failing open here only widens the // explicit-revoke gate, never the trust boundary. if (tokenScope === MCP_CONNECT_SCOPE) { if (!(await isConnectTokenAllowed(payload.jti as string | undefined))) { return { authed: false }; } } return { authed: true, identity: { userEmail: typeof payload.sub === "string" ? payload.sub : undefined, // Org SERVICE tokens (connect-minted, synthetic `svc-*@service.` // subject) carry the org id directly as an `org_id` claim so the // resolved identity is org-scoped even when the org has no domain // mapping. Personal/delegation JWTs don't set the claim — unchanged. ...(typeof payload.org_id === "string" && payload.org_id ? { orgId: payload.org_id as string } : {}), orgDomain: typeof payload.org_domain === "string" ? (payload.org_domain as string) : undefined, ...(payload.agent_native_first_party_mcp === true ? { firstPartyMcp: true } : {}), }, // Verified JWT (connect-minted or A2A delegation) — a real caller. fullSurface: true, // Per-token opt-up: `catalog_scope: "full"` embedded at mint time via // `agent-native connect --full-catalog` bypasses the connector-catalog // tier filter on hosted multi-tenant deployments. fullCatalog: payload.catalog_scope === "full", }; } if (accessTokens.length === 0 && !hasA2ASecret) { if (options.allowDevOpen === false) { return { authed: false }; } return { authed: true, identity: deriveStaticTokenIdentity(ownerEmailHeader), fullSurface: !!(ownerEmailHeader && ownerEmailHeader.trim()), }; } // Try ACCESS_TOKEN / ACCESS_TOKENS exact match. Static tokens carry no // per-caller claims, so derive identity from the forwarded owner-email // hint (install flow) — otherwise tools would run unscoped. Compare in // constant time (matching the rest of this subsystem's secret-comparison // discipline); node:crypto is imported dynamically because this module is // bundled into the serverless function and avoids static Node-only imports. if (accessTokens.length > 0) { const { timingSafeEqual } = await import("node:crypto"); const candidate = Buffer.from(token, "utf8"); const matched = accessTokens.some((configured) => { const expected = Buffer.from(configured, "utf8"); return ( expected.length === candidate.length && timingSafeEqual(expected, candidate) ); }); if (matched) { return { authed: true, identity: deriveStaticTokenIdentity(ownerEmailHeader), // Matched a configured ACCESS_TOKEN — a real caller. fullSurface: true, }; } } return { authed: false }; } export async function resolveOrgIdFromDomain( orgDomain: string | undefined, ): Promise { if (!orgDomain) return undefined; try { const { resolveOrgByDomain } = await import("../org/context.js"); const org = await resolveOrgByDomain(orgDomain); return org?.orgId ?? undefined; } catch { return undefined; } } export async function resolveMcpIdentityOrgId( identity: MCPCallerIdentity | undefined, ): Promise { if (identity?.orgId) return identity.orgId; const orgIdFromDomain = await resolveOrgIdFromDomain(identity?.orgDomain); if (orgIdFromDomain) return orgIdFromDomain; const userEmail = identity?.userEmail?.trim(); if (!userEmail) return undefined; try { const { resolveOrgIdForEmail } = await import("../org/context.js"); return (await resolveOrgIdForEmail(userEmail)) ?? undefined; } catch { return undefined; } }