Skip to main content
The MCP door makes your host product installable in MCP clients such as Claude, ChatGPT, and Cursor. Every door tool call runs through the same guard-bound registry, policy, approvals, and audit path as chat. Outside agents get no weaker perimeter than your own conversation surface. Opening the door is a host decision. It is off by default, and vendo init asks whether you want it. On yes, init generates the Next.js discovery route and points you at the host-owned OAuth wiring; it never invents an auth adapter or turns the flag on by itself.

When to use it

  • You want an installable MCP server that exposes your product’s tools to third-party agents.
  • Users of those agents should act as themselves in your product, with the same policy and approvals you enforce in-product.
  • You already own a session and consent flow you can plug in behind an OAuth seam.
Skip it if you only need to pull remote MCP tools into your own agent. That is what mcpConnector does, and it runs the other direction. See Connectors.

Enable the door

Set mcp: true and pass a HostOAuthAdapter. The recommended shape has two methods: session looks up the current host user (or bounces the browser to your login), and principal resolves that subject to a live principal on every door request. The door renders the consent page itself.
Then re-export the handler from your catch-all route:
oauth is required when mcp is true. Without it, createVendo throws: the door cannot mint principals otherwise. Import the HostOAuthAdapter type from @vendoai/vendo — the umbrella re-exports it so you never have to depend on @vendoai/mcp directly. The door renders the consent page for you when your adapter uses session. It owns the approve and deny buttons, CSRF-protects the POST, rejects a replayed approval, escapes the attacker-controllable client_name value from dynamic client registration, and issues the standard OAuth authorization-code redirect. You do not write any of that.

Theme it

The consent page styles itself from the same --vendo-* CSS custom properties the rest of your product UI reads — --vendo-color-*, --vendo-font-*, --vendo-radius-*, and --vendo-space-*. When you configure Vendo through createVendo, the umbrella hands your resolved .vendo/theme.json to the door automatically. Edit .vendo/theme.json to restyle the page; no consent-specific configuration is needed. The same theme also carries into apps rendered inside MCP clients. See Saved apps ride along.

Replace the page

If you need a custom consent page — different copy, a compliance disclosure, a branded layout the tokens cannot express — add an authorize method alongside session. The door still owns CSRF, single-use replay protection, and the OAuth redirect; you only own the rendered HTML. Post transaction, csrf_token, and decision=approve|deny to the action URL the door supplies in ctx.consent.
Always HTML-escape clientName before rendering it. Clients register their own name through dynamic client registration or a Client ID Metadata Document, so that value is attacker-controllable and can carry phishing text or markup. If your adapter omits session and only implements the legacy authorize, the door hands you the request without a consent context. You then own the entire flow: CSRF, replay protection, and the OAuth redirect. Prefer session plus the prebuilt page unless you have a reason not to.

Route the discovery paths

The door’s transport and OAuth endpoints mount under the wire at /api/vendo/mcp, so your existing catch-all handler already serves them. The OAuth discovery documents live at the origin root, outside /api/vendo. When you opt into MCP during vendo init, a Next.js host gets app/.well-known/[...vendo]/route.ts (under src/app when present), forwarding to the same handler. If you wire Vendo without init, add the equivalent route:
The door answers only its own discovery paths and returns 404 for anything else under /.well-known. If your app also serves its own well-known documents, branch on the pathname and call vendo.handler only for the paths below. The door owns:
  • /.well-known/oauth-protected-resource/api/vendo/mcp (RFC 9728 protected-resource metadata)
  • /.well-known/oauth-authorization-server/api/vendo/mcp (RFC 8414 authorization-server metadata)
  • /.well-known/mcp/server-card.json (server card; /.well-known/mcp-server-card is an alias)
On Express, the adapter that vendo init generates reconstructs the full path from req.originalUrl, so the same handler serves both bases:
Because the door 404s every other well-known path, register your app’s own well-known routes before that second mount.

Set VENDO_BASE_URL

Set VENDO_BASE_URL to your product’s public origin in every real deployment. It does two jobs for the door:
  • Discovery and audience binding. Behind a reverse proxy (Railway, Fly, any TLS terminator), the request reaching your process carries the proxy-internal origin. The door derives its OAuth discovery documents (issuer, endpoint URLs, the protected-resource resource) and its token audience binding from VENDO_BASE_URL when set, so clients see your public origin instead of an unreachable internal one. Forwarded headers such as X-Forwarded-Host are never trusted. Without it, the door falls back to the request URL, which is fine for local development and wrong behind a proxy.
  • Route-binding base. Host tools that bind to routes need a base origin. The wire normally learns its own origin from the first in-product request, but door requests never teach it (only wire routes do); VENDO_BASE_URL gives door-first traffic a base to resolve host routes against.
If your composition serves the door on a different origin than the one host routes resolve against, pass the door’s public base explicitly: createVendo({ mcp: { baseUrl: "https://app.example.com" }, oauth }).

Configure the door through the umbrella

mcp: true opens the door with defaults. To pass door-specific settings through createVendo — for example when you front the door with a hosted broker or a different origin — replace true with an object:
mcp: { … } accepts the same baseUrl, remoteAs, and federation fields as the top-level createVendo options, and takes precedence over them when both are set. Use the object form when you want one place to gate broker trust behind environment variables, and the top-level form when the door runs on its own alongside your host.

How door calls reach host tools

MCP clients have no host browser session, so Vendo never forwards the inbound MCP bearer to your host routes. Door tool calls reach host APIs through the same actAs seam away automations use:
  • On successful OAuth, the door records the user’s consent for that client.
  • When a door tool call resolves to a host route, Vendo calls actAs(principal, grant) to source auth material for the OAuth’d user.
  • Without a real grant or a consent record, the call fails closed.
  • Without actAs configured, the tool returns a not-implemented error, the same clean degradation as an away automation.
The OAuth-authenticated user is the authority. Risky tools still stop at your per-call guard decision, whatever the token’s scopes.

Delegate to an external authorization server

If your product already runs its own OAuth authorization server, or you use an identity provider that issues bearer tokens for your APIs, point the door at it with remoteAs instead of letting the door mint tokens itself. The door then authenticates every MCP request by verifying the inbound bearer as a JWT signed by that issuer.
Requirements and behavior:
  • Tokens must be ES256-signed JWTs with iss, sub, aud, iat, and exp claims. iss must equal the configured issuer, aud must equal the configured audience, and exp must be in the future.
  • The door caches the JWKS in memory and refetches it when a bearer arrives with an unfamiliar kid, which covers ordinary key rotation.
  • The door calls oauth.principal(subject) with the JWT sub on every request, so returning null still kills a live session — this remains your revocation point.
  • The local /api/vendo/mcp/authorize, /token, and /register endpoints return 404 in this mode, and so does RFC 8414 authorization-server metadata. RFC 9728 protected-resource metadata instead advertises authorization_servers: [remoteAs.issuer], so compliant clients discover and use the external server directly.
  • oauth is still required — the door skips oauth.authorize because the external server owns the interactive step, but it still calls oauth.principal.
Use remoteAs when the external server is authoritative for user identity for your product. Skip it if you want the door to run its own OAuth surface.

Federate login from an external authorization server

federation adds a signed handshake so an external authorization server can have your host complete the interactive login-and-consent step, then hand the answer back. This is useful when the external server owns tokens (see remoteAs above) but does not know how to authenticate your users — your app does.
Set VENDO_MCP_FEDERATION_SECRET to a high-entropy secret shared with the external authorization server. That server crafts an HS256-signed request JWT and redirects the user’s browser to GET /api/vendo/mcp/federate?request=<compact JWS>. The request JWT must carry:
  • iss — the external authorization server’s issuer URL.
  • aud — the door’s canonical URL (for example https://app.example.com/api/vendo/mcp).
  • exp — no more than five minutes in the future.
  • jti — a fresh nonce per handshake.
  • redirect_uri — where to send the browser after login. Its origin must match iss.
  • scopes — the string array the external server wants your user to consent to.
  • client_name — the display name shown in your consent UI.
The door verifies the request, then authenticates the user through your adapter:
  • If your adapter implements authorize, the door calls oauth.authorize(request, { clientName, scopes }). This is the same authorize step your local flow uses, so you do not need a separate consent UI.
  • If your adapter is session-only (no authorize), the door calls oauth.session(request, { returnTo }) with the federate request URL as returnTo, so a logged-out user bounces to your login page and resumes the handshake automatically. The external authorization server owns consent, so no in-product consent UI runs.
If your adapter returns a Response (a login redirect or rendered page), the door forwards it so the browser can complete host login and retry. On a { subject } result, the door redirects to redirect_uri with an HS256-signed assertion parameter that the external authorization server verifies with the same secret. The assertion carries sub (your host subject), iss (the door’s canonical URL), aud (the request’s iss), a matching jti, and a sixty-second exp. The endpoint renders no HTML of its own — it either returns your authorize response verbatim or issues a 302 back to the external server.

Revoke access

Clients and hosts can both retire an authorization without waiting for a token to expire. The door implements the RFC 7009 revocation endpoint at /api/vendo/mcp/revoke, and the authorization-server metadata advertises it alongside the supported read and write scopes so clients discover it automatically. Clients revoke a single token by POSTing application/x-www-form-urlencoded:
Per the spec, the endpoint always returns an empty 200 — unknown tokens and unknown or incorrect hints look the same. Revoking an access token invalidates that opaque token; revoking a refresh token atomically retires the whole authorization-grant family, including its access tokens and any rotated successors, while leaving other authorizations for the same client intact. Returning null from your oauth.principal(subject) remains the account-level kill switch and takes effect on the next transport request. That’s the fastest way to sever every client for a user from your product’s settings UI — the door re-resolves the principal on each transport request and closes live MCP sessions the moment the lookup fails. In remoteAs mode the external authorization server owns revocation and the door’s local /revoke path returns 404.

Saved apps ride along

The door exposes your saved apps as MCP Apps. tools/list includes vendo_apps_list, vendo_apps_open, and vendo_apps_call, and opened apps render through a static HTML shim. Interactions inside a rendered app route back through the same guard-bound path. The door is a viewer and runner: app creation and editing stay in-product.

HTTP apps open in-product

Rung-4 HTTP apps run on a machine-served origin the shim cannot host, so the door opens them as a link-out instead. vendo_apps_open returns a themed card with an “Open in ” call to action and the target URL, and text-only clients receive a human-readable message with the URL inline. The link opens in a new tab. The card is driven by a versioned structured envelope so newer clients dispatch on kind rather than sniffing shape:
  • productName comes from your MCP server identity, so the CTA always names your product.
  • appName is best-effort — the door omits it when the app has no title.
  • The card themes itself from the same --vendo-* CSS tokens as the consent page, so no extra configuration is needed to match your brand.
Rung 1–3 apps still render inline through the shim; only rung 4 link-outs use the card.

Host branding crosses the boundary

An app a user saved in your product still looks like your product when it renders inside Claude or ChatGPT. The shim reads the same --vendo-* tokens your product UI reads and wraps every rendered app in a VendoProvider. It also propagates the tokens into the sandboxed frame that hosts generated components, so payload UI, notices, link-out cards, and generated components all inherit your theme. You do not configure this per-app. When you use createVendo({ mcp: true }), the umbrella forwards your validated .vendo/theme.json to the door and the door serves it in the shim. Edit .vendo/theme.json to restyle both the consent page and MCP-rendered apps in one place.

Verify

When the door is open, doctor verifies both OAuth metadata documents resolve, the server card parses, and — if you have started registry paperwork — that your server.json and mcp-registry-auth challenge match the live door. GET /status returns blocks.mcp: true. See HTTP routes for the door’s route table and Handler options for the mcp and oauth options. To connect a client, point it at https://your-app.example.com/api/vendo/mcp. The client discovers OAuth from the WWW-Authenticate challenge on its first 401, walks the user through your authorize step, and starts calling tools. To make the deployed door discoverable through the official registry, follow Publish to the MCP registry.