import { type Guard, type Membership, type Principal, type StoreAdapter, type StoreOps, type ToolRegistry } from "@vendoai/core"; import type { AppsRuntime } from "@vendoai/apps"; import type { VendoTheme } from "@vendoai/apps/contract"; import type { HostOAuthAdapter } from "./oauth/adapter.js"; import { type McpDoorState } from "./state.js"; import type { TurnCredentialPort } from "./turn-credential.js"; export type { McpRunContext } from "./state.js"; export type { LiveTurn, TurnCredentialPort } from "./turn-credential.js"; /** 10-mcp §1. */ export interface McpDoorConfig { /** ALREADY guard-bound by the umbrella (05 §2) — the door never sees an unbound registry. */ tools: ToolRegistry; /** Audit reporting for auth events (§3); tool decisions happen inside the bound registry. */ guard: Guard; /** §3 — the host owns session/principal lookup; the door can own consent too. * Required for an outside-serving door; an `internal` door has no OAuth space * to run, so it needs none and is given none. */ oauth?: HostOAuthAdapter; /** Door-owned protocol state (clients, codes, refresh grants) — wired like every other block. */ store: StoreAdapter; /** The 42-op surface over that SAME store, when the composition could resolve * one. The OAuth space reaches its two drawers through `ops.engine.*` so the * engine allowlist gates every one of them; unset, it serves the same verbs * off the adapter itself (`engineOverAdapter`), which is what a host's BYO * `StoreAdapter` gets. An `internal` door has no OAuth space and reads no * drawer, so it needs none. */ ops?: StoreOps; /** §4 — saved apps ride along as MCP Apps; absent → tools-only door. The * three verbs come off the real runtime (10-mcp §4); the umbrella passes * `vendo.apps` essentially verbatim, narrowing only what `open` may * answer. */ apps?: Pick; /** Build contract §9.1 — the host's org query, keyed on `Principal` (never on * a Request), resolved once per authenticated request and ridden onto every * RunContext this door mints. * * Load-bearing, not decorative: `can()` reads the caller's orgs from the ctx * and never queries them (§9.3), so a door without this seam can never match * an `org:`/`team:` grant — a team app shared with the caller would be absent * from `list` and not-found on `open`, over this door only. Absent seam ⇒ no * orgs asserted ⇒ `can()` degenerates to ownership, which is every unkeyed * deployment. */ memberships?: (principal: Principal) => Promise; /** The same resolved host brand the UI pipeline consumes. The prebuilt * consent page and MCP Apps shim both emit it as `--vendo-*` variables. */ theme?: VendoTheme; /** 10-mcp §5 — the door's canonical mount path (e.g. `/api/vendo/mcp`). When * set, the cold server card advertises THIS transport URL and learned request * paths never override it; when unset the card falls back to `/mcp` until an * authenticated request teaches it a mount. The umbrella passes its fixed * mount so a composed door's card is correct before any traffic arrives. */ mount?: string; /** 10-mcp §5 — the canonical PUBLIC base URL of the deployed host (e.g. * `https://app.example.com`, or `https://app.example.com/maple` for a * deployment mounted under a path prefix). Behind a reverse proxy (Railway, * Fly, any TLS terminator) the request URL carries the proxy-INTERNAL * origin — and a prefix the proxy strips — so deriving discovery metadata * from it advertises unreachable endpoints and binds the RFC 8707 audience * to the wrong resource. When set, the issuer, every advertised endpoint, * the protected-resource `resource`, the 401 challenge's metadata URL, token * audience validation, and the interactive consent URLs (form action, * host-login returnTo) all live under THIS base — origin AND path; only the * door-local path still comes from the request (or `mount`). A path on the * base URL names the prefix the deployment is mounted under, normalized * exactly like `mount`. Forwarded headers (X-Forwarded-*, Host) are * attacker-controllable and are never consulted. * The umbrella defaults this from `VENDO_BASE_URL`. */ baseUrl?: string; /** Trust access tokens from an external OAuth authorization server instead * of serving the door's local authorization-server endpoints. */ remoteAs?: { issuer: string; jwksUri?: string; audience: string; }; /** Enable the generic signed login-federation handshake at `{mount}/federate`. */ federation?: { secret: string; }; /** First-party service auth: the host's own backend exchanges one of these * opaque keys plus a user id for a short-lived user-bound access token at * `{mount}/token`, then talks MCP with it like any other client. Rotation is * listing both the old and the new key until the old one is out of use. * Unset means the grant is neither advertised nor served. */ serviceAuth?: { keys: readonly string[]; }; /** * The tool menu this door offers — the host's curated `surfaces.mcp` list, * resolved by the umbrella (`registry.surfaceMenu("mcp")`) and passed in. * Absent = offer the whole bound surface. The door never reads `.vendo` * files itself: block layering forbids mcp importing actions, so the * composition seam owns the file and the door owns the wire. * * CURATION, NOT SECURITY. A name outside the menu is neither listed nor * callable, but the refusal is the SAME in-band not-found an unknown tool * gets — the menu leaks nothing and grants nothing. Vendo's own `vendo_*` * tools (the apps ride-alongs and any runtime tool the registry owns) are * never curated away: they are the product's plumbing, not the host's API. * * The provider form exists because composition is synchronous while resolving * the menu is not (it reads the authored file through the registry). * * The door calls it FRESH for every listing and every call, and deliberately * does not memoize — not even the resolved value, and never a rejection. The * bound registry grows at runtime (an `add()`), so a menu frozen at the * first request would leave every * late-arriving tool invisible AND uncallable until the process restarted; * caching a failed read would freeze the door just as permanently. The * registry memoizes its own load underneath, so re-asking costs a map lookup. * Providers passed here must therefore be cheap and side-effect free. * * Resolving to `undefined` means unrestricted. */ menuTools?: string[] | (() => string[] | undefined | Promise); /** * Names this door NEVER offers, whatever else says otherwise. * * The MENU curates the host's API and deliberately cannot touch Vendo's own * `vendo_*` plumbing. This is the other decision: what THIS door, as deployed, * does not do. It is checked BEFORE the prefix bypass, so a deployment can * hold back a runtime tool (the placement pair, for a door whose client has no * page to place anything on) without inventing a second menu. * * Same posture as the menu: curation, not security. A withheld name is neither * listed nor callable, and the refusal is the SAME in-band not-found an * unknown name gets — it leaks nothing and grants nothing. The guard, not this * list, is what stops a call that IS offered. * * A plain list rather than a provider: unlike the menu (which is read from a * file that changes under a running process), this is authored in composition * and cannot change without one. */ withholdTools?: string[]; /** * The product name shown to PEOPLE (the connect page) and advertised on the * server card. Wins over the `package.json` name the door otherwise reads, * which is a package id and is often not what the product is called. Set it * when the two differ. */ productName?: string; /** * 10-mcp §3b — the host process's own turn-scoped credential seam. * * The door was built for OUTSIDE agents, and an outside agent has no turn: no * venue but `mcp`, no presence but `present`, no stream to put an approval * card on. A `claudeCode()` box reaching its host's tools over native remote * MCP has all three, and losing them at the door made the door unusable for it * (measured — `packages/vendo/tests/mcp-door-parity.e2e.test.ts`). * * A bearer this port resolves is answered from the LIVE TURN it names: the * turn's own ctx, the turn's own equipped tools, and `turn.tools.call()` for * execution — which means one guard decision, one audit row, one transcript * mirror, the turn's own approval card, and `workspace.commit()`, none of them * reimplemented here. * * Unset (every deployment that never composed a harness): there is no second * credential space and the door behaves exactly as it did before. */ turnCredentials?: TurnCredentialPort; /** * Serve the turn-credential space and NOTHING else. * * A composition that names a harness thinking outside this process needs a * door for that harness alone. `mcp: true` means one thing and must keep * meaning it — "my users may connect third-party agents to my product" — so * the two decisions are decoupled here rather than in the host's config: an * internal door has no authorization server, no discovery documents, no * consent page, no client registration, no outside session, and no listing * for anyone but a live turn. Its mount answers a valid turn bearer, and * answers everything else with a 401 that names no way in. * * `oauth` is meaningless here and is not read. */ internal?: boolean; } export interface McpDoor { /** One fetch-style handler serving: MCP Streamable HTTP transport, the OAuth * endpoints (§3), and the discovery documents (§5). The umbrella mounts it. */ handler: (req: Request) => Promise; /** Host-authorized disconnect for one subject/client pair. Revokes every * existing local grant family and closes its live MCP sessions. */ revokeClient: (subject: string, clientId: string) => Promise; } export declare function createMcpDoor(config: McpDoorConfig): McpDoor; /** Package-internal composition hook for transport/state adapters and tests. * It is deliberately not re-exported from the package root. */ export declare function createMcpDoorWithState(config: McpDoorConfig, state: McpDoorState): McpDoor; //# sourceMappingURL=door.d.ts.map